# Button (/docs/button)





The `Button` is a polymorphic interactive primitive built on top of Ark UI's `ark.button`. Visual treatment splits into two orthogonal props:

* **`variant`** — the *shape* (`filled`, `light`, `outline`, `subtle`)
* **`color`** — the *palette* (`brand`, `neutral`, `red`, `orange`, `yellow`, `green`, `teal`, `cyan`, `blue`, `purple`, `pink`)

Color is delivered via CSS variables on a `data-color` attribute, so a single class set works for every palette and dark mode gets the right shade automatically.

```tsx
import { Button } from "@42-central/ui-react/button";

<Button variant="filled" color="brand" onClick={() => console.log("hi")}>
  Get started
</Button>;
```

## Playground [#playground]

Tweak the visual props live — controls are curated in the story file. The code panel updates as you change them.

<story.Playground />

## Variants [#variants]

Four visual treatments. Each one reads off the same color slots (`--c-solid`, `--c-soft`, `--c-text`), so they all swap palettes uniformly.

<Preview align="center" code="&#x22;<Button variant=\&#x22;filled\&#x22;>Filled</Button>\n<Button variant=\&#x22;light\&#x22;>Light</Button>\n<Button variant=\&#x22;outline\&#x22;>Outline</Button>\n<Button variant=\&#x22;subtle\&#x22;>Subtle</Button>&#x22;">
  <div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
    <Button variant="filled">
      Filled
    </Button>

    <Button variant="light">
      Light
    </Button>

    <Button variant="outline">
      Outline
    </Button>

    <Button variant="subtle">
      Subtle
    </Button>
  </div>
</Preview>

## Colors [#colors]

Every variant accepts any color in the palette. Adding a new color is a CSS-only change — append a `[data-color="…"]` block to `theme.css` and a name to `COLORS`. Omitting `color` resolves to `neutral` via the `:root` cascade.

<Preview align="center" code="null">
  <div className="flex flex-col gap-3">
    <div className="flex flex-wrap gap-2 justify-center align-center">
      {COLORS.map((c) => (
              <Button key={c} variant="filled" color={c} className="min-w-26">
                {c}
              </Button>
            ))}
    </div>

    <div className="flex flex-wrap gap-2 justify-center align-center">
      {COLORS.map((c) => (
              <Button key={c} variant="light" color={c} className="min-w-26">
                {c}
              </Button>
            ))}
    </div>

    <div className="flex flex-wrap gap-2 justify-center align-center">
      {COLORS.map((c) => (
              <Button key={c} variant="outline" color={c} className="min-w-26">
                {c}
              </Button>
            ))}
    </div>

    <div className="flex flex-wrap gap-2 justify-center align-center">
      {COLORS.map((c) => (
              <Button key={c} variant="subtle" color={c} className="min-w-26">
                {c}
              </Button>
            ))}
    </div>
  </div>
</Preview>

For a destructive action, use `variant="filled" color="red"`. There is no separate `destructive` variant — intent is conveyed through `color`.

## Sizes [#sizes]

Three sizes covering the standard density needs. For an icon-only square button, reach for [`ActionIcon`](/docs/action-icon).

<Preview align="center" code="&#x22;<Button size=\&#x22;sm\&#x22;>Small</Button>\n<Button size=\&#x22;md\&#x22;>Medium</Button>\n<Button size=\&#x22;lg\&#x22;>Large</Button>&#x22;">
  <div className="flex flex-wrap items-center gap-3 align-center">
    <Button size="xs">
      Extra small
    </Button>

    <Button size="sm">
      Small
    </Button>

    <Button size="md">
      Medium
    </Button>

    <Button size="lg">
      Large
    </Button>

    <Button size="xl">
      Extra large
    </Button>
  </div>
</Preview>

## Slots [#slots]

`startSlot` and `endSlot` render before and after the children. Both are wrapped in `aria-hidden` so they don't pollute the accessible name. Pass a bare `lucide-react` icon — the button's size variant scales it automatically.

<Preview align="center" code="&#x22;import { ArrowRight, ExternalLink, Plus } from 'lucide-react';\n\n<Button startSlot={<ExternalLink />}>Open</Button>\n<Button endSlot={<ArrowRight />}>Continue</Button>\n<Button\n  variant=\&#x22;outline\&#x22;\n  color=\&#x22;neutral\&#x22;\n  startSlot={<Plus />}\n  endSlot={<Kbd size=\&#x22;sm\&#x22;>⌘N</Kbd>}\n>\n  New file\n</Button>&#x22;">
  <div className="flex flex-wrap items-center gap-3">
    <Button startSlot="<ExternalLink />">
      Open
    </Button>

    <Button color="brand" endSlot="<ArrowRight />">
      Continue
    </Button>

    <Button variant="outline" color="neutral" startSlot="<Plus />" endSlot="<Kbd size=&#x22;sm&#x22;>⌘N</Kbd>">
      New file
    </Button>
  </div>
</Preview>

## Loading [#loading]

`loading` swaps in an inline spinner, disables pointer interaction, and sets `aria-busy="true"`.

<Preview align="center" code="&#x22;const [pending, startTransition] = useTransition();\n\n<Button\n  loading={pending}\n  onClick={() => startTransition(submit)}\n>\n  Save changes\n</Button>&#x22;">
  <div className="flex flex-wrap items-center gap-3">
    <Button>
      Saving
    </Button>

    <Button color="teal">
      Submitting
    </Button>

    <Button color="red">
      Deleting
    </Button>
  </div>
</Preview>

While loading, any user-supplied `startSlot` is suppressed so the spinner can take its place without layout shift, hover styling is suppressed, and the cursor switches to a progress spinner.

## Disabled [#disabled]

Both native `:disabled` and Ark's `data-disabled` attribute are styled, so the disabled treatment lands whether the underlying element is a `<button>` or rendered through `asChild`.

<Preview align="center" code="`<Button disabled>Disabled</Button>`">
  <div className="flex flex-wrap items-center gap-3">
    <Button>
      Disabled
    </Button>

    <Button variant="light" color="purple">
      Disabled
    </Button>

    <Button variant="outline" color="neutral">
      Disabled
    </Button>
  </div>
</Preview>

## Polymorphic with `asChild` [#polymorphic-with-aschild]

Render any other element while keeping the button's styling and behavior. Useful for wrapping a Next.js `<Link>` so you don't lose client-side navigation.

```tsx
import Link from "next/link";

<Button asChild color="brand">
  <Link href="/dashboard">Open dashboard</Link>
</Button>;
```

`startSlot`/`endSlot` and `loading` still render — they're composed into the rendered child's own children rather than the child itself, since Ark's `asChild` only allows a single element through.

<Preview align="center" code="&#x22;<Button asChild color=\&#x22;brand\&#x22; startSlot={<ArrowRight />} endSlot={<ExternalLink />}>\n  <a href=\&#x22;/dashboard\&#x22;>Go</a>\n</Button>&#x22;">
  <div className="flex flex-wrap items-center gap-3">
    <Button color="brand" startSlot="<ArrowRight />" endSlot="<ExternalLink />">
      <a href="#">
        Go
      </a>
    </Button>

    <Button color="teal" startSlot="<ArrowRight />" endSlot="<ExternalLink />">
      <a href="#">
        Go
      </a>
    </Button>
  </div>
</Preview>

## Form submission [#form-submission]

Buttons default to `type="button"` to prevent accidental form submission. Opt in explicitly when you want submit semantics:

```tsx
<form action={createPost}>
  <Button type="submit" color="brand">
    Publish
  </Button>
</form>
```

## API [#api]

<TypeTable
  type="{
  variant: {
    type: &#x22;'filled' | 'light' | 'outline' | 'subtle'&#x22;,
    default: &#x22;'filled'&#x22;,
    description: &#x22;Visual treatment.&#x22;,
  },
  color: {
    type: &#x22;'brand' | 'neutral' | 'red' | 'orange' | 'yellow' | 'green' | 'teal' | 'cyan' | 'blue' | 'purple' | 'pink' | (string & {})&#x22;,
    default: &#x22;'neutral'&#x22;,
    description:
      'Accent palette. Custom values are accepted as long as a matching [data-color=&#x22;…&#x22;] block exists in theme.css.',
  },
  size: {
    type: &#x22;'sm' | 'md' | 'lg'&#x22;,
    default: &#x22;'md'&#x22;,
    description: &#x22;Height + padding. Use ActionIcon for square icons.&#x22;,
  },
  loading: {
    type: &#x22;boolean&#x22;,
    default: &#x22;false&#x22;,
    description: &#x22;Shows spinner, disables interaction, sets aria-busy.&#x22;,
  },
  disabled: {
    type: &#x22;boolean&#x22;,
    default: &#x22;false&#x22;,
    description: &#x22;Native disabled. Also coerced to true when loading.&#x22;,
  },
  startSlot: {
    type: &#x22;ReactNode&#x22;,
    description:
      &#x22;Decorative element at the inline-start of children. aria-hidden.&#x22;,
  },
  endSlot: {
    type: &#x22;ReactNode&#x22;,
    description:
      &#x22;Decorative element at the inline-end of children. aria-hidden.&#x22;,
  },
  asChild: {
    type: &#x22;boolean&#x22;,
    default: &#x22;false&#x22;,
    description: &#x22;Render children as the root element (Ark UI polymorphism).&#x22;,
  },
  type: {
    type: &#x22;'button' | 'submit' | 'reset'&#x22;,
    default: &#x22;'button'&#x22;,
    description:
      &#x22;Native button type. Defaults to 'button' to avoid accidental submits.&#x22;,
  },
  testId: {
    type: &#x22;string&#x22;,
    description: &#x22;data-testid on the button element. See the Testing guide.&#x22;,
  },
}"
/>

All other props are forwarded to the underlying `button` (or `asChild` element).

## Accessibility [#accessibility]

* Focus ring is exposed only on keyboard focus (`focus-visible:`), so pointer interactions stay quiet. The ring is a fixed `brand-500` and does **not** track `color` — see [Focus](/docs/focus).
* `aria-busy` and `aria-disabled` are set whenever the button is loading or disabled, regardless of the visual state.
* Slot contents are `aria-hidden`; the accessible name comes from the children. For icon-only triggers, use [`ActionIcon`](/docs/action-icon), which forces an explicit `aria-label`.
