Button#

One part on a native <button>. There is no behavior to add — the platform already handles activation, form submission and disabled — so what zero adds is the anatomy: a stable selector carrying data-color / data-size / data-variant, which is where a design system puts its fill styles.

Import#

TSX
import { Button } from '@sigx/zero/button';

Button is a compound with a single member, Button.Root. It is also re-exported from the @sigx/zero root, together with buttonAnatomy.

Usage#

TSX
import { Button } from '@sigx/zero/button';

<Button.Root color="primary" variant="outline" size="lg" onClick={save}>
    Save
</Button.Root>

type defaults to button, not the platform's submit — a button inside a form does not post it unless you say type="submit".

A link that looks like a button is asChild over an <a>. It stays a real link — middle-click, "copy link" and the link role all work — and wears the button's anatomy and recipe:

TSX
<Button.Root asChild variant="outline">
    {(p) => <a href="/docs" {...p}>Docs</a>}
</Button.Root>

With asChild the default slot receives the part's attribute bag; spread it so the anatomy lands on your element. A native <button disabled> is inert by itself; an asChild element is not, so a disabled asChild button gets aria-disabled="true" and its click is suppressed by the component.

A design system's recipes live in @layer zero.recipes, and any unlayered rule beats any layered one whatever its specificity — so an app stylesheet with a plain a { color: … } repaints every link button. That is the layering promise (app CSS always wins) working as designed. Hand the colour back with one unlayered rule in the app:

CSS
a[data-scope="button"][data-part="root"] { color: revert-layer; text-decoration: revert-layer; }

revert-layer rolls the property back to the layered cascade, the button recipe included. Every shipped button recipe sets text-decoration: none, so the underline reverts to none rather than to the browser's link default.

The loading button#

loading is Button's one state — work in flight:

TSX
<Button.Root loading={saving()} onClick={save}>Save</Button.Root>

It renders data-state="loading", aria-busy="true" and aria-disabled="true", and blocks activation: a click neither calls onClick nor submits the form, and there is no press feedback. It does not set the native disabled, which would drop focus from the button the user just pressed. The label stays — it is what the reader is waiting on — and a spinner part, an empty aria-hidden span, renders before it for the design system to draw. Style it by name (parts.spinner, states.loading) rather than through a skin's pseudo-element. An asChild element gets the state and the ARIA but no spinner, since its children are the caller's. Announce a long operation with your own live region when a label change alone won't be heard.

Attributes and forms#

Button.Root forwards aria-*, your own data-*, id, title and role to the element (or the asChild bag) — see attribute pass-through. An icon-only button is named the ordinary way:

TSX
<Button.Root aria-label="Close" data-testid="close" onClick={close}>×</Button.Root>

The native name, value and form land on the built-in <button>, so a submitter posts its value and a button outside a form's subtree can submit it by id. An asChild element carries its own.

Anatomy#

PartElementStatesFlagsNotes
rootbuttonloading (absent at rest)disabled, focus-visible, pressed, press-animatingCarries the variant axes. aria-busy + aria-disabled while loading. asChild.
spinnerspan——aria-hidden. Rendered before the label while loading, on the built-in <button> only. Inside root.

A button's one machine state is loading; it has nothing to be open or checked about, and a button with a persistent pressed mode is a Toggle. :active remains available to recipes; data-pressed / data-press-animating and the --press-x / --press-y / --press-r custom properties are the runtime press feedback on top of it — pointer-anchored, keyboard-parity, and able to outlive release so a one-shot ripple always plays out.

Props#

Button.Root#

PropTypeDefaultDescription
type'button' | 'submit' | 'reset''button'The native button type.
disabledbooleanfalseInert; renders data-disabled (and aria-disabled under asChild).
loadingbooleanfalseWork in flight: data-state="loading", aria-busy, aria-disabled, activation blocked, focus kept; renders the spinner part.
name / value / formstring—The native button's form attributes, on the built-in <button>.
color / size / variant / axes / modsdesign-system vocabulary—The variant axes, rendered as data-* on root.
asChildbooleanfalseRender through the default slot, which receives the part bag.
classstring—Extra classes.
aria-* / data-* / id / title / roleHTML attributes—Forwarded to the element; a contract-owned data-* throws.
onClick(e: MouseEvent) => void—Click handler; not called while disabled or loading.
onKeydown / onFocus / onBlurhandlers—Compose with the component's own focus and press tracking.

Handlers are declared props rather than forwarded rest props — sigx passes no rest props, so onClick has to be part of the type to reach the element.

In the shipped design systems#

Button is the worked example of the two-axis composition: a design system routes color through a component token pair (--btn-accent and its content colour) that the variant rules read, so the two axes compose instead of multiplying into a rule per combination.

colorsizevariantmods
@sigx/zero-basicthe eight recommended rolesxs–xlsolid (default) · outline · soft · ghost—
@sigx/zero-daisyuithe eight recommended rolesxs–xlsolid (default) · outline · soft · ghost · dash · linkwide · block · square · circle · active

@sigx/zero-daisyui also ships the daisy-native surface: import { Button } from '@sigx/zero-daisyui/components' gives a single-import <Button wide loading variant="dash" color="primary"> with the modifiers as flat booleans (loading there is zero's own prop, not a modifier, and the recipe draws the spinner part) — see Vendor-named component APIs.