Button
Displays a button or a component that looks like a button.
Default clicked 0 times
Codeapp/demos/public/button.tsx
import { clientEntry, on, type Handle } from 'remix/component'
import { Button, buttonVariants } from '@stemsui/registry/ui/button.tsx'
export const ButtonDemo = clientEntry(import.meta.url, function ButtonDemo(handle: Handle) {
let saving = false
let clicks = 0
return () => (
<div class="flex flex-col items-center gap-6">
<div class="flex flex-wrap items-center justify-center gap-2">
<Button
mix={on('click', () => {
clicks++
void handle.update()
})}
>
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 class="flex flex-wrap items-center justify-center gap-2">
<Button size="sm" variant="outline">
Small
</Button>
<Button variant="outline">Default</Button>
<Button size="lg" variant="outline">
Large
</Button>
<Button size="icon" variant="outline" aria-label="Add">
<PlusIcon />
</Button>
<Button disabled>Disabled</Button>
<a href="#button" class={buttonVariants({ variant: 'outline' })}>
Link as button
</a>
</div>
<div class="flex items-center gap-3">
<Button
disabled={saving}
focusableWhenDisabled
aria-labelledby="save-label"
mix={on('click', () => {
saving = true
void handle.update()
setTimeout(() => {
saving = false
void handle.update()
}, 2000)
})}
>
<span id="save-label">{saving ? 'Saving…' : 'Save (stays focusable)'}</span>
</Button>
<span class="text-sm text-muted-foreground" data-testid="button-clicks">
Default clicked {clicks} {clicks === 1 ? 'time' : 'times'}
</span>
</div>
</div>
)
})
function PlusIcon() {
return () => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M5 12h14" />
<path d="M12 5v14" />
</svg>
)
}
Installation
npx shadcn add @stemsui/buttonRequires the one-time setup. Behavior: stems-ui/button.
API reference
The styled component is built on the headless module below: its props mirror these options, and the same events and data attributes apply.
stems-ui/button
button wires a host element as an accessible button: native <button> defaults, button
semantics for other hosts, and a disabled state that can stay focusable.
Base UI equivalent: Button.
Usage
import { on } from 'remix/component'
import { button } from 'stems-ui/button'
function SaveButton(handle: Handle) {
let saving = false
return () => (
<button
mix={[
button({ disabled: saving, focusableWhenDisabled: true }),
on('click', async () => {
saving = true
await handle.update()
// ...
}),
]}
>
{saving ? 'Saving…' : 'Save'}
</button>
)
}
A non-<button> host:
<div mix={button()}>Button that can contain complex children</div>
stems-ui/button
button(options?)disabled: ignore user interaction. Falls back to the host'sdisabledprop. Defaults tofalse.focusableWhenDisabled: keep the button focusable while disabled. The host getsaria-disabled="true"instead of the nativedisabledattribute, and activation is blocked. Defaults tofalse.nativeButton: whether the host is a native<button>. Defaults totruefor<button>hosts andfalseotherwise.
Behavior notes
- Native
<button>hosts gettype="button"unlesstypeis set (Base UI: submit buttons must settype="submit"explicitly). - Non-native hosts get
role="button"andtabIndex(0, or-1when disabled and notfocusableWhenDisabled). They activate (dispatchclick) on Enterkeydownand Spacekeyup, like a native button. Spacekeydownis prevented so the page does not scroll. Keys from descendants are ignored. - When the browser can't block activation itself (non-native hosts, or
focusableWhenDisabled), a capture-phaseclicklistener callspreventDefault()andstopImmediatePropagation(), soon('click')handlers on the host and ancestors don't run and forms are not submitted. Enter and Spacekeydownare prevented too. - With
focusableWhenDisabled, passdisabledas an option, not as the hostdisabledprop: a hostdisabledattribute always removes the button from the tab order. - Explicit host props win (
attrs()semantics). - Don't use
button()on links. Style the<a>like a button instead (the registry exportsbuttonVariants()for this).
| Key | Action (non-native hosts) |
|---|---|
| Enter | Activates the button on keydown. |
| Space | Activates the button on keyup. |
Styling hooks
| Attribute | Values |
|---|---|
data-disabled | Present when the button is disabled. |