AZMARA PLATFORM
Components

Button

Triggers an action. Six variants, density-aware sizes, a loading state that keeps keyboard focus, and an icon-only form the types force you to name.

Usage

import { Button } from "@azmr/ui/button";
Preview theme
Preview density
import { Button } from "@azmr/ui/button";

export default function ButtonVariants() {
  return (
    <div className="flex flex-col gap-4">
      <div className="flex flex-wrap items-center gap-3">
        <Button>Default</Button>
        <Button variant="secondary">Secondary</Button>
        <Button variant="outline">Outline</Button>
        <Button variant="ghost">Ghost</Button>
        <Button variant="destructive">Destructive</Button>
        <Button variant="link">Link</Button>
      </div>
      <div className="flex flex-wrap items-center gap-3">
        <Button size="sm">Small</Button>
        <Button>Medium</Button>
        <Button size="lg">Large</Button>
        <Button disabled>Disabled</Button>
      </div>
    </div>
  );
}

Use default for the primary action on a screen, secondary and outline for supporting ones, ghost for toolbars, destructive for irreversible actions and link for navigation that should read as text. The md size follows the density token: switch the preview above to Compact or Spacious and it resizes, exactly as it will inside an app that sets data-density.

A button is type="button" unless you say otherwise, so one inside a form never submits by accident. Pass type="submit" to opt in.

Loading

Preview theme
Preview density
"use client";

import { Button } from "@azmr/ui/button";
import { useEffect, useRef, useState } from "react";

export default function ButtonLoading() {
  const [saving, setSaving] = useState(false);
  const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
  useEffect(() => () => clearTimeout(timer.current), []);

  function save() {
    setSaving(true);
    timer.current = setTimeout(() => setSaving(false), 1500);
  }

  // While loading the button ignores clicks and keys but stays focusable, so a keyboard user
  // keeps their place.
  return (
    <Button loading={saving} loadingText="Saving" onClick={save}>
      Save changes
    </Button>
  );
}

loading shows a spinner, sets aria-busy, and ignores clicks and keyboard activation. It stays in the tab order, so a keyboard user who pressed Enter does not lose their place and a screen reader still finds the button. loadingText replaces the label while it is busy.

Preview theme
Preview density
Read the docs
import { Button } from "@azmr/ui/button";
import { Icon } from "@azmr/ui/icons";
import { ArrowRight, Plus, X } from "lucide-react";

export default function ButtonIcons() {
  return (
    <div className="flex flex-wrap items-center gap-3">
      <Button startIcon={<Icon icon={Plus} />}>New customer</Button>
      <Button variant="outline" endIcon={<Icon icon={ArrowRight} />}>
        Continue
      </Button>
      {/* An icon-only button needs an accessible name; the types require it. */}
      <Button size="icon" variant="ghost" aria-label="Close">
        <Icon icon={X} />
      </Button>
      {/* Render as a link, with button styling and the right semantics. */}
      <Button
        variant="link"
        nativeButton={false}
        render={<a href="https://platform.azmara.io/docs" />}
      >
        Read the docs
      </Button>
    </div>
  );
}

startIcon and endIcon sit either side of the label; while loading, the spinner replaces the start icon. An icon-only button must be given an accessible name: with size="icon" TypeScript requires aria-label or aria-labelledby. To render a link with button styling, pass render={<a href="..." />} and nativeButton={false}.

Props

Prop

Type

Everything a native button accepts passes through (onClick, type, disabled, aria-*, ref, ...), plus these from the underlying primitive:

PropTypeDescription
renderReactElement or functionRender a different element (such as <a>) with the button's behaviour and styles.
nativeButtonbooleanSet to false when render is not a native <button>.
focusableWhenDisabledbooleanKeep a disabled button in the tab order. loading does this for you.
classNamestringMerged last, so it can override defaults: className="h-12" replaces the default height.

Accessibility

  • It is a real <button> (or the element you render), so it has the right role, name from its content, and disabled semantics with no ARIA added.
  • Focus uses a full-strength ring that meets WCAG 2.2 focus appearance; the same ring is used on every control.
  • loading communicates busy state with aria-busy rather than changing the label silently.
Keyboard interactions
KeyAction
EnterorSpaceActivates the button. Ignored while loading.
TabMoves focus to the button. A loading button stays in the tab order.
ShiftorTabMoves focus to the previous control.

On this page