Button

Trigger an action.

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

PropTypeDefault
loadingbooleanfalseShow 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.
classNamestring | ((state: ButtonState) => string | undefined)CSS class applied to the element, or a function that returns a class based on the component's state.
focusableWhenDisabledbooleanWhether the button should be focusable when disabled.
nativeButtonbooleanWhether 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>).
renderComponentRenderFn<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.
styleCSSProperties | ((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.

ButtonGroup · DropdownMenu · ConfirmDialog