Stems UI
Menu

Button

Displays a button or a component that looks like a button.

Link as 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/button

Requires 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's disabled prop. Defaults to false.
    • focusableWhenDisabled: keep the button focusable while disabled. The host gets aria-disabled="true" instead of the native disabled attribute, and activation is blocked. Defaults to false.
    • nativeButton: whether the host is a native <button>. Defaults to true for <button> hosts and false otherwise.

Behavior notes

  • Native <button> hosts get type="button" unless type is set (Base UI: submit buttons must set type="submit" explicitly).
  • Non-native hosts get role="button" and tabIndex (0, or -1 when disabled and not focusableWhenDisabled). They activate (dispatch click) on Enter keydown and Space keyup, like a native button. Space keydown is 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-phase click listener calls preventDefault() and stopImmediatePropagation(), so on('click') handlers on the host and ancestors don't run and forms are not submitted. Enter and Space keydown are prevented too.
  • With focusableWhenDisabled, pass disabled as an option, not as the host disabled prop: a host disabled attribute 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 exports buttonVariants() for this).
KeyAction (non-native hosts)
EnterActivates the button on keydown.
SpaceActivates the button on keyup.

Styling hooks

AttributeValues
data-disabledPresent when the button is disabled.