Skip to content

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.

Change the props and copy the code.

variant
color
size
shape
width
state
Generated code
TSX
<Button ripple>Button</Button>

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.

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.

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.

Tab from here →

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.

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.

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.

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:

  • className is concatenated, not replaced.
  • style objects 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.

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.

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.
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-label and mark the icon aria-hidden.
  • Windows high contrast. forced-colors: active drops box-shadow, so the ring is redrawn as a 2px CanvasText outline and the outline and ghost border is redrawn from currentColor. Disabled switches to GrayText.
  • Reduced motion. Transitions and the press transform are dropped. The ripple is hidden but still animates for 1ms, because display: none would suppress the animationend that removes the node.
  • Contrast. All eight roles clear 4.5:1 in both jobs and both schemes, in every shipped theme except wayra and the dark half of selva (Theming). A filled button keeps that ratio when hovered and pressed, because its state steps move away from the label. The outline and ghost washes do not yet: pressed, they can fall to about 3.4:1. Disabled controls sit at opacity: 0.4, which WCAG 1.4.3 exempts as inactive controls, and prefers-contrast: more raises it to 0.65.

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

Add a title first

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.