Usage
import { Button } from "@voila.dev/ui/button";<Button variant="outline">Save</Button>Button wraps Base UI's button,
so keyboard behaviour, focus management and the disabled semantics come from
there. What this component adds is the variant system, the size scale, and a
loading state that does the right thing for screen readers.
Recipes
Confirm a destructive action
destructive is deliberately a tinted fill rather than a solid red block: a red
wall in a dialog footer reads as an error message, not a button. Pair it with an
outline cancel so the safe way out has weight too.
Save while the request is in flight
loading does three things at once: renders a leading Spinner, sets
disabled, and sets aria-busy. That last one is why you should prefer it over
rolling your own. Keep the label meaningful while it spins: "Saving…" tells the
user what is happening; a bare spinner does not.
An icon-only toolbar action
An icon-only button has no accessible name of its own. Always pass
aria-label. Icons are auto-sized here and alongside text; you never set
size-4 yourself.
Navigation that reads as a button
Base UI's render prop swaps the underlying element while keeping the styling
and behaviour:
<Button render={<Link to="/projects/$id" params={{ id }} />}>Open project</Button>API
| Prop | Type | Default | |
|---|---|---|---|
loading | boolean | false | Show a leading spinner and mark the button aria-busy, disabling
interaction while an async action is in flight (e.g. a form submit). |
shape | "default" | "pill" | "default" | pill forces a fully rounded button; default follows the theme's
--radius, so it changes with the brand. |
size | "default" | "icon" | "icon-lg" | "icon-sm" | "icon-xs" | "lg" | "sm" | "xs" | "default" | Height and horizontal padding. The icon-* set is square, for a button
whose entire content is one icon — give those an aria-label. |
variant | "brand" | "default" | "destructive" | "ghost" | "highlight" | "link" | "outline" | "primary" | "secondary" | "default" | Weight of the action. default is the one thing the page exists for, at
most one per view; secondary an equal-weight alternative; outline
for toolbars and anything that repeats in a row; ghost inside dense
surfaces; destructive for what cannot be undone; link for an action
that reads as navigation. |
className | string | ((state: ButtonState) => string | undefined) | — | CSS class applied to the element, or a function that returns a class based on the component's state. |
focusableWhenDisabled | boolean | — | Whether the button should be focusable when disabled. |
nativeButton | boolean | — | Whether the component renders a native <button> element when replacing it
via the render prop.
Set to false if the rendered element is not a button (for example, <div>). |
render | ComponentRenderFn<HTMLProps, ButtonState> | ReactElement<unknown, string | JSXElementConstructor<any>> | — | Allows you to replace the component's HTML element
with a different tag, or compose it with another component. Accepts a ReactElement or a function that returns the element to render. |
style | CSSProperties | ((state: ButtonState) => CSSProperties | undefined) | — | Style applied to the element, or a function that returns a style object based on the component's state. |
Plus the DOM props of the element it renders. Source: button/components/button.tsx.
The variant and size names are also exported as arrays
(buttonVariantOptions, buttonSizeOptions), and the cva itself as
buttonVariants from @voila.dev/ui/button if you need the classes without the
component.
Every state is in the Storybook story.
Own it
The source is @voila.dev/ui/src/button/components/button.tsx. To change the
variants, edit the cva block. It is the same file whether you keep the package
or copy it into your repo.
Reach for className for layout (w-full, mt-2), not for colour. If a
button needs a colour the variants do not have, the token behind the variant is
what should change; see Theming.