Button
PolymorphicInteractive but stateless primitive. Four variants × eleven colors, slots, loading, and asChild — with a playground to try them.
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.
import { Button } from "@42-central/ui-react/button";
<Button variant="filled" color="brand" onClick={() => console.log("hi")}>
Get started
</Button>;Playground
Tweak the visual props live — controls are curated in the story file. The code panel updates as you change them.
Component
Dir
Presets
Props
Code
<Component size="md">
Default
</Component>Variants
Four visual treatments. Each one reads off the same color slots (--c-solid, --c-soft, --c-text), so they all swap palettes uniformly.
Code
<Button variant="filled">Filled</Button>
<Button variant="light">Light</Button>
<Button variant="outline">Outline</Button>
<Button variant="subtle">Subtle</Button>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.
For a destructive action, use variant="filled" color="red". There is no separate destructive variant — intent is conveyed through color.
Sizes
Three sizes covering the standard density needs. For an icon-only square button, reach for ActionIcon.
Code
<Button size="sm">Small</Button>
<Button size="md">Medium</Button>
<Button size="lg">Large</Button>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.
Code
import { ArrowRight, ExternalLink, Plus } from 'lucide-react';
<Button startSlot={<ExternalLink />}>Open</Button>
<Button endSlot={<ArrowRight />}>Continue</Button>
<Button
variant="outline"
color="neutral"
startSlot={<Plus />}
endSlot={<Kbd size="sm">⌘N</Kbd>}
>
New file
</Button>Loading
loading swaps in an inline spinner, disables pointer interaction, and sets aria-busy="true".
Code
const [pending, startTransition] = useTransition();
<Button
loading={pending}
onClick={() => startTransition(submit)}
>
Save changes
</Button>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
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.
Code
<Button disabled>Disabled</Button>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.
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.
Code
<Button asChild color="brand" startSlot={<ArrowRight />} endSlot={<ExternalLink />}>
<a href="/dashboard">Go</a>
</Button>Form submission
Buttons default to type="button" to prevent accidental form submission. Opt in explicitly when you want submit semantics:
<form action={createPost}>
<Button type="submit" color="brand">
Publish
</Button>
</form>API
Prop
Type
All other props are forwarded to the underlying button (or asChild element).
Accessibility
- Focus ring is exposed only on keyboard focus (
focus-visible:), so pointer interactions stay quiet. The ring is a fixedbrand-500and does not trackcolor— see Focus. aria-busyandaria-disabledare 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, useActionIcon, which forces an explicitaria-label.
Changelog
- #319
Spinner's accessible name comes from
label, andlabel={null}takes it out of the accessibility tree, so loading Buttons and ActionIcons no longer expose an extra "Spinner" image
- #79
With
asChild, slots and the loading spinner stay visible, and the spinner dims correctly
Button, withstartSlot/endSlotand a loading state