Button
The action trigger, with three variants, eight semantic roles, and five sizes on the shared control ladder. Styling is a class plus data attributes, so the same button works in hand-written HTML.
- Stable
- React 19
- No runtime dependencies
- Precompiled
- WCAG AA on all eight roles
Code
<Button onClick={save}>Save changes</Button><button type="button" class="wayra-button" data-variant="filled" data-color="primary" data-size="md">Save changes</button>type defaults to button, never submit. A button that submits a form by accident
is a bug you only find in production, so submitting is opt-in with type="submit".
Everything else is optional. Every prop below has a default, and every prop maps to a
data-* attribute you can also write by hand.
Playground
Section titled “Playground”Change the props and copy the code.
Generated code
<Button ripple>Button</Button>Variants
Section titled “Variants”Three levels of emphasis, running from the one action a screen is asking for down to the
ones it merely offers. filled is the call to action, outline the bordered secondary
action, and ghost a borderless action with a tinted wash on hover.
Code
<Button>Filled</Button><Button variant="outline">Outline</Button><Button variant="ghost">Ghost</Button><button class="wayra-button" data-variant="filled">Filled</button><button class="wayra-button" data-variant="outline">Outline</button><button class="wayra-button" data-variant="ghost">Ghost</button>Use one filled button per view. When a screen has two, neither one reads as the primary
action and the hierarchy stops doing its job.
Hover and pressed states are not extra tokens. They are color-mix() derivations of the
active role, so changing one color restyles every state of every variant.
| Variant | Hover | Pressed |
|---|---|---|
filled |
88% role + pole | 80% role + pole |
outline |
12% role wash | 20% role wash |
ghost |
8% role wash | 13% role wash |
The filled pole is the end of the scale away from the label: black under a light
-content, white under a dark one. A step toward black would push a dark label under
4.5:1 (pressed sunset in dark measured 3.64:1). The pole is read from the -content
color with relative color syntax. In a browser without it, both steps mix toward black.
Colors
Section titled “Colors”Eight semantic roles, mapped from data-color by one shared rule. Adding a color is one
block in that mapping, not one rule per component.
Code
<Button color="primary">primary</Button><Button color="secondary">secondary</Button><Button color="accent">accent</Button><Button color="neutral">neutral</Button><Button color="info">info</Button><Button color="success">success</Button><Button color="warning">warning</Button><Button color="error">error</Button><button class="wayra-button" data-variant="filled" data-color="primary">primary</button><button class="wayra-button" data-variant="filled" data-color="secondary">secondary</button><button class="wayra-button" data-variant="filled" data-color="accent">accent</button><button class="wayra-button" data-variant="filled" data-color="neutral">neutral</button><button class="wayra-button" data-variant="filled" data-color="info">info</button><button class="wayra-button" data-variant="filled" data-color="success">success</button><button class="wayra-button" data-variant="filled" data-color="warning">warning</button><button class="wayra-button" data-variant="filled" data-color="error">error</button>The same eight roles as outlines. This is the matrix that fails first. A role is exactly
two tokens, and the solid does double duty: the filled background, plus the
outline and ghost text and border.
Code
<Button variant="outline" color="primary">primary</Button><Button variant="outline" color="secondary">secondary</Button><Button variant="outline" color="accent">accent</Button><Button variant="outline" color="neutral">neutral</Button><Button variant="outline" color="info">info</Button><Button variant="outline" color="success">success</Button><Button variant="outline" color="warning">warning</Button><Button variant="outline" color="error">error</Button><button class="wayra-button" data-variant="outline" data-color="primary">primary</button><button class="wayra-button" data-variant="outline" data-color="secondary">secondary</button><button class="wayra-button" data-variant="outline" data-color="accent">accent</button><button class="wayra-button" data-variant="outline" data-color="neutral">neutral</button><button class="wayra-button" data-variant="outline" data-color="info">info</button><button class="wayra-button" data-variant="outline" data-color="success">success</button><button class="wayra-button" data-variant="outline" data-color="warning">warning</button><button class="wayra-button" data-variant="outline" data-color="error">error</button>Height is the only metric derived from --wayra-size-field. Padding and font size have
their own progressions. Buttons, inputs, and textareas walk the same ladder, so a form row
lines up by construction.
Code
<Button size="xs">xs</Button><Button size="sm">sm</Button><Button size="md">md</Button><Button size="lg">lg</Button><Button size="xl">xl</Button><button class="wayra-button" data-variant="filled" data-size="xs">xs</button><button class="wayra-button" data-variant="filled" data-size="sm">sm</button><button class="wayra-button" data-variant="filled" data-size="md">md</button><button class="wayra-button" data-variant="filled" data-size="lg">lg</button><button class="wayra-button" data-variant="filled" data-size="xl">xl</button>| Size | Height | Padding inline | Font size | Pairs with icon |
|---|---|---|---|---|
xs |
21.1px | 0.5rem | 11px | 12px |
sm |
28.2px | 0.75rem | 12px | 13px |
md |
35.2px | 1rem | 14px | 14px |
lg |
42.2px | 1.25rem | 18px | 16px |
xl |
49.3px | 1.5rem | 22px | 18px |
None of these are magic numbers. Every measurement is a token or a multiple of one, so
overriding --wayra-size-field rescales every control in the library.
shape sets the corner, and in two cases the aspect ratio too.
Code
<Button>rounded</Button><Button shape="rectangle">rectangle</Button><Button shape="pill">pill</Button><Button shape="square" aria-label="Bookmark"><BookmarkIcon /></Button><Button shape="circle" aria-label="Play"><PlayIcon /></Button><button class="wayra-button" data-variant="filled" data-shape="rounded">rounded</button><button class="wayra-button" data-variant="filled" data-shape="rectangle">rectangle</button><button class="wayra-button" data-variant="filled" data-shape="pill">pill</button><button class="wayra-button" data-variant="filled" data-shape="square" aria-label="Bookmark"><svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" aria-hidden="true"><path d="M4 1h8v14l-4-3-4 3V1Z"/></svg></button><button class="wayra-button" data-variant="filled" data-shape="circle" aria-label="Play"><svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" aria-hidden="true"><path d="M6 3l5 5-5 5V3Z"/></svg></button>| Shape | Effect |
|---|---|
rounded |
default — --wayra-radius-field, scaled per size |
rectangle |
radius forced to 0, whatever the theme says |
pill |
radius forced fully round |
square |
1:1 box, no inline padding — icon only; keeps whatever radius is active |
circle |
square plus a fully round radius |
pill, rectangle, and circle are absolute: they do not follow the theme’s corner
radius. If they did, a theme that rounds at 0.375rem would make pill, rounded, and
circle look identical. A theme decides the default corner, and a shape overrides it.
Inside a 24rem block container.
Code
<Button>auto</Button><Button width="wide">wide</Button><Button width="block">block</Button><button class="wayra-button" data-variant="filled" data-width="auto">auto</button><button class="wayra-button" data-variant="filled" data-width="wide">wide</button><button class="wayra-button" data-variant="filled" data-width="block">block</button>auto hugs the content. wide fills the container up to --wayra-button-max-width (16rem),
and block fills it entirely. Both need a block-level container to grow into. Dropped in a
flex row, they only fill the space that row gives them.
Icons are plain children, so there is no icon prop. The button lays them out with flex,
centered, and with a gap, and you decide the size.
Code
<Button><HomeIcon size={14} />Home</Button><Button variant="outline">Open<ExternalIcon size={14} /></Button><Button variant="ghost" shape="circle" aria-label="More options"><DotsIcon size={14} /></Button><button class="wayra-button" data-variant="filled"><svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" aria-hidden="true"><path d="M8 1 1 7h2v8h4v-5h2v5h4V7h2L8 1Z"/></svg>Home</button><button class="wayra-button" data-variant="outline">Open<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" aria-hidden="true"><path d="M6 1h9v9h-2V4.4L5.4 12 4 10.6 11.6 3H6V1ZM1 4h4v2H3v7h7v-2h2v4H1V4Z"/></svg></button><button class="wayra-button" data-variant="ghost" data-shape="circle" aria-label="More options"><svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" aria-hidden="true"><circle cx="3" cy="8" r="1.5"/><circle cx="8" cy="8" r="1.5"/><circle cx="13" cy="8" r="1.5"/></svg></button>Color is free. Anything drawn with currentColor, which is the default in Lucide,
Heroicons, and react-icons, follows the button’s variant and role with no configuration. A
hand-written SVG needs fill="currentColor" or stroke="currentColor".
An icon-only button has no text to be named by, so it needs an aria-label. Storybook’s
accessibility gate fails any story that forgets one.
States
Section titled “States”Loading
Section titled “Loading”clicks accepted: 0 — keep clicking while it spins, the button is inert but still focusable
Code
import { useState } from "react";import { Button } from "wayra-ui";
export function LoadingDemo() { const [saving, setSaving] = useState(false); const [accepted, setAccepted] = useState(0);
function save() { setAccepted((n) => n + 1); setSaving(true); setTimeout(() => setSaving(false), 2000); }
return ( <div className="hu-demo-col"> <div className="hu-demo-row"> <Button loading={saving} onClick={save}> {saving ? "Saving" : "Save"} </Button> <Button loading={saving} variant="outline" onClick={save}> {saving ? "Saving" : "Save draft"} </Button> </div> <p className="hu-readout"> clicks accepted: {accepted} — keep clicking while it spins, the button is inert but still focusable </p> </div> );}loading renders a spinner sized to the button’s own font, marks the button aria-busy,
and blocks activation while keeping it focusable. The button is deliberately not
dimmed. The spinner already carries the state, and fading the button would fade the
spinner with it.
It renders aria-busy rather than aria-disabled on purpose. A loading button is
working, not unavailable, and those are different announcements. Under
prefers-reduced-motion the spinner keeps turning, because it is the only signal that
work is in flight, but it moves in eight discrete steps instead of a continuous sweep.
Soft-disabled
Section titled “Soft-disabled”focus: nothing · clicks accepted: 0
The tab stop skips disabled entirely. It stops on softDisabled, which is announced as unavailable and still refuses to fire.
Code
import { useState } from "react";import { Button } from "wayra-ui";
export function SoftDisabledDemo() { const [focused, setFocused] = useState("nothing"); const [clicks, setClicks] = useState(0);
return ( <div className="hu-demo-col"> <div className="hu-demo-row"> <span className="hu-hint">Tab from here →</span> <Button onFocus={() => setFocused("Publish")} onClick={() => setClicks((n) => n + 1)} > Publish </Button> <Button disabled onFocus={() => setFocused("disabled")} onClick={() => setClicks((n) => n + 1)} > disabled </Button> <Button softDisabled onFocus={() => setFocused("softDisabled")} onClick={() => setClicks((n) => n + 1)} > softDisabled </Button> </div> <p className="hu-readout"> focus: {focused} · clicks accepted: {clicks} </p> <p className="hu-hint"> The tab stop skips <code>disabled</code> entirely. It stops on{" "} <code>softDisabled</code>, which is announced as unavailable and still refuses to fire. </p> </div> );}disabled takes a button out of the tab order, and many screen readers skip it entirely.
The user never learns the action exists, let alone why it is unavailable. softDisabled
renders aria-disabled instead: the button stays focusable and announced, and JavaScript
blocks activation.
Both look the same and neither activates. Reach for softDisabled whenever the reason
matters, such as a tooltip, a hint, or a validation message the user has to reach.
Only activation is blocked, here and while loading: a click, Enter and Space. Every other
event still reaches your handlers, so the arrow keys of a roving-focus toolbar move past a
soft-disabled item instead of getting stuck on it.
Toggle
Section titled “Toggle”aria-pressed: bold=true italic=false underline=false
Code
import { useState } from "react";import { Button } from "wayra-ui";
const MARKS = ["Bold", "Italic", "Underline"];
export function ToggleDemo() { const [active, setActive] = useState<string[]>(["Bold"]);
function toggle(mark: string) { setActive((current) => current.includes(mark) ? current.filter((m) => m !== mark) : [...current, mark], ); }
return ( <div className="hu-demo-col"> {/* biome-ignore lint/a11y/useSemanticElements: a fieldset carries form semantics and wants a legend; this is a formatting toolbar. */} <div className="hu-demo-row" role="group" aria-label="Text formatting"> {MARKS.map((mark) => ( <Button key={mark} variant="ghost" color="neutral" pressed={active.includes(mark)} onClick={() => toggle(mark)} > {mark} </Button> ))} </div> <p className="hu-readout"> aria-pressed:{" "} {MARKS.map((m) => `${m.toLowerCase()}=${active.includes(m)}`).join(" ")} </p> </div> );}pressed renders aria-pressed and styles the button as held down. Leave it out for a
plain button. A false value still makes it a toggle, just an unpressed one, and screen
readers announce it that way.
To animate between two icon faces inside a Button, use <Swap interactive={false} /> —
see Swap.
Ripple
Section titled “Ripple”The ripple expands from the pointer, or from the center when the button is activated with Enter or Space.
Code
import { Button } from "wayra-ui";
export function RippleDemo() { return ( <div className="hu-demo-col"> <div className="hu-demo-row"> <Button ripple>Press me</Button> <Button ripple variant="outline" color="accent"> Focus me, then hit Enter </Button> <Button ripple shape="circle" aria-label="Play"> <svg viewBox="0 0 16 16" width={16} height={16} fill="currentColor" role="presentation" > <title>Play</title> <path d="M6 3l5 5-5 5V3z" /> </svg> </Button> </div> <p className="hu-hint"> The ripple expands from the pointer, or from the center when the button is activated with Enter or Space. </p> </div> );}Off by default, opt in per button. The ripple expands from the pointer and is drawn in
currentColor, so it reads correctly on every variant and role with no configuration.
Keyboard activation has no coordinates to expand from, so it starts at the center instead.
The effect is not a mouse-only affordance. Holding Space does not stack one ripple per key
repeat. The ripple is skipped on secondary pointer buttons such as a right click, on
unstyled buttons, and while the button is loading or soft-disabled. It draws nothing
under prefers-reduced-motion or forced-colors.
Turning it on also makes the button a positioned, overflow: hidden box. Buttons without
the prop keep their default stacking and overflow, which is why the ripple is a prop and
not the default.
asChild
Section titled “asChild”child onClick: 0 · Button onClick: 0
The anchor keeps its href and never gains a type. The slotted button keeps type="submit" and its own class, and both click handlers run — the child's first.
Code
import { useState } from "react";import { Button } from "wayra-ui";
export function AsChildDemo() { const [childRuns, setChildRuns] = useState(0); const [buttonRuns, setButtonRuns] = useState(0);
return ( <div className="hu-demo-col"> <div className="hu-demo-row"> <Button asChild variant="outline"> <a href="#as-child">Read the docs</a> </Button>
<Button asChild onClick={() => setButtonRuns((n) => n + 1)}> <button type="submit" className="tracked" onClick={() => setChildRuns((n) => n + 1)} > Submit </button> </Button> </div> <p className="hu-readout"> child onClick: {childRuns} · Button onClick: {buttonRuns} </p> <p className="hu-hint"> The anchor keeps its <code>href</code> and never gains a{" "} <code>type</code>. The slotted button keeps <code>type="submit"</code>{" "} and its own class, and both click handlers run — the child's first. </p> </div> );}asChild renders the single child element instead of a <button>, and hands it the
design-system class, the data-* attributes, and the button’s handlers. It is how a link,
or a framework’s <Link>, gets button styling without a polymorphic as prop or a Slot
dependency.
Where the button and the child both set a prop, the child’s wins, and the data-* and
state attributes the button manages (aria-busy, aria-disabled, aria-pressed) win over
both. Four props are merged instead, because replacing one would silently lose your work:
classNameis concatenated, not replaced.styleobjects are merged. Where both set the same property, the child’s value wins.- Both refs receive the element, the one on the button and the one on the child.
- Matching handlers are chained so both run. The child’s handler runs first, since it is the more specific one.
type is never forwarded, because it means nothing outside <button>. Set it on the child
if the child is a button. disabled is not forwarded either: on a slotted child it acts as
softDisabled. The child gets aria-disabled, stays focusable, wears the disabled fade,
and a click neither navigates nor reaches a handler. The CSS never assumed <button> either:
interactive states key on :not(:disabled) rather than :enabled, so a slotted <a>
still gets them.
Server Components
Section titled “Server Components”asChild works from a Server Component. A child written there reaches the client
deferred, and the button suspends until it lands, so the old UI stays put inside a
transition the same way a plain {children} would. Building the pair in a client module
skips that wait. A child that is not a single element — text, or two of them — is
rendered inside a plain <button> instead.
API reference
Section titled “API reference”ButtonProps extends ComponentProps<"button">, so every native attribute, including
onClick, form, name, autoFocus, and ref, is available and typed.
| Prop | Type | Default | Description |
|---|---|---|---|
variant |
"filled" | "outline" | "ghost" |
"filled" |
Emphasis level. |
color |
WayraColor |
"primary" |
One of the eight semantic roles. Narrows React’s legacy color attribute, and is consumed as data-color rather than forwarded to the DOM. |
size |
"xs" | "sm" | "md" | "lg" | "xl" |
"md" |
Height, padding, and font size step. |
shape |
"rounded" | "rectangle" | "pill" | "square" | "circle" |
"rounded" |
Corner, plus a 1:1 box for the two icon-only values. |
width |
"auto" | "wide" | "block" |
"auto" |
How far the button grows in its container. |
loading |
boolean |
false |
Spinner, aria-busy, activation blocked, focus kept, no dimming. |
softDisabled |
boolean |
false |
Activation blocked like disabled, but focusable and announced via aria-disabled. Other events still reach your handlers. |
disabled |
boolean |
false |
Native. Leaves the tab order, so use softDisabled when the reason matters. Under asChild it acts as softDisabled instead. |
pressed |
boolean |
— | Toggle state, rendered as aria-pressed. Omit it for a plain button. |
ripple |
boolean |
false |
Ripple from the pointer, or from the center on Enter and Space. |
asChild |
boolean |
false |
Render the single child element instead of a <button>, merging class, style, ref and handlers. A deferred child suspends; a non-element child falls back to a <button>. |
unstyled |
boolean |
false |
Drop the wayra-button class, keep the data attributes. |
type |
"button" | "submit" | "reset" |
"button" |
Pinned to button by default. Opt in to submitting. |
Accessibility
Section titled “Accessibility”| Key | Behavior |
|---|---|
Tab / Shift+Tab |
Moves focus. Skips disabled, and stops on softDisabled and on loading. |
Enter |
Activates. The ripple starts at the center. Blocked while loading or softDisabled, and your onKeyDown does not see it. |
Space |
Activates on release, natively. Holding it does not stack ripples. Blocked like Enter. |
- Focus. The ring uses
:focus-visible, so a mouse press leaves no ring. It is a 3.5px wash of the button’s own accent, drawn by one shared rule, so any state that re-points the accent moves the ring with it. - Naming. A text label names the button for you. An icon-only button has no name of
its own, so pass an
aria-labeland mark the iconaria-hidden. - Windows high contrast.
forced-colors: activedropsbox-shadow, so the ring is redrawn as a 2pxCanvasTextoutline and theoutlineandghostborder is redrawn fromcurrentColor. Disabled switches toGrayText. - Reduced motion. Transitions and the press transform are dropped. The ripple is
hidden but still animates for 1ms, because
display: nonewould suppress theanimationendthat removes the node. - Contrast. All eight roles clear 4.5:1 in both jobs and both schemes, in every shipped
theme except
wayraand the dark half ofselva(Theming). Afilledbutton keeps that ratio when hovered and pressed, because its state steps move away from the label. Theoutlineandghostwashes do not yet: pressed, they can fall to about 3.4:1. Disabled controls sit atopacity: 0.4, which WCAG 1.4.3 exempts as inactive controls, andprefers-contrast: moreraises it to0.65.
Do and don’t
Section titled “Do and don’t”Do
One filled button per view. The secondary action steps down to ghost or outline, so the hierarchy is readable at a glance.
Don't
Two solids compete. Color alone does not establish hierarchy — emphasis does.
Do
Label the action, not the interaction. A button's name should read as what happens next.
Don't
“Click here”, “Submit”, and “OK” describe the mechanism, not the outcome. They are also what a screen reader reads out of context.
Do
softDisabled plus a reason. The button keeps its tab stop, so the user can reach it and find out why.
Don't
A bare disabled is a dead end: out of the tab order, skipped by many screen readers, and silent about what would fix it.
Do
Icon-only buttons carry an aria-label. The icon itself is aria-hidden, because it is decoration once the button is named.
Don't
No label, no accessible name. The control is announced as “button” and nothing else.
Do
One real control. The pressed state lives on the button itself via aria-pressed, so there is nothing nested inside it.
Don't
A checkbox inside a button is invalid HTML and an axe nested-interactive failure.