Button
A versatile button component with multiple variants and sizes. Built with CVA and CSS Modules — copy it into your project and own it completely.
npx visor add buttonVariants
Sizes
Dialog footer (dlg)
The compact size="dlg" face is the dialog-footer button from the Animal
dialog substrate — 11px/600 text, tokenized ~8/12px padding, radius-lg. Its
three intents reuse the variant axis: primary via variant="default", cancel
via variant="ghost" (renders a hairline-outlined, secondary-ink face at dlg
size), and destructive via variant="destructive" (renders a subtle-danger
face at dlg size). Pair it with the Dialog Form
block.
Icon-only (icon)
size="icon" is a square mark around one glyph: the pencil, the remove X, the
trash, a row's tools. The box is the glyph (--button-icon-size, 1rem) plus a
hit-target pad (--button-icon-pad, --spacing-2) on every side, so it is
always as wide as it is tall. Pair it with variant="ghost" for a bare mark: it
rests in secondary ink (--button-icon-ghost-color) and comes up to primary on
hover. On variant="outline" the edge is the shared control edge, so
edges: off removes it.
An icon-only button has no text, so it needs an aria-label; in
development it warns in the console without one. For a hover label, compose
Tooltip.
Label Centering
Bare text in a Button is wrapped in a <span data-slot="button-text"> whose line box is trimmed to the capital-height band (text-box: trim-both cap alphabetic). Flexbox then centres the letters themselves rather than the font's ascent and descent, so the label sits optically centred in every font (a regular line box sits up to a pixel high in fonts like Product Sans). Box sizes are unchanged; the dlg size, whose height comes from padding, is left untrimmed. Browsers without text-box (Firefox) keep the previous rendering. Elements you pass as children (icons) are not wrapped. The same mechanism centres Toggle Group, Tabs, Chip, Badge, Select and Input; its spec is docs/label-centering.md.
Disabled State
Gated State
A permission-gated button is visually dimmed but remains hover-able, surfacing a tooltip that explains why the action is unavailable. Unlike the native disabled state, the button stays in the tab order so assistive technology users can discover the reason.
Use gated to apply the visual state, and gatedReason to provide the tooltip text. A TooltipProvider ancestor is required when gatedReason is set.
The gated prop uses aria-disabled="true" instead of the native disabled attribute, so the button stays keyboard-focusable. Click handlers are suppressed internally — no need to guard them in your code.
Pending State
pending reports that the button's action is in flight. It paints a busy glyph, sets aria-busy="true", and suppresses activation so the action cannot be double-submitted — without ever changing the control's geometry.
Holding the box is the point. The idle children stay in place at zero opacity and the glyph paints over them, so the button keeps its width and its height. A pending state that swaps Save for Saving… on a content-sized button resizes the control and shoves every sibling in a justify-content: flex-end footer — which is the visible defect this prop exists to remove, not a cosmetic detail.
All three buttons above are the same width. The third one shows a longer label than the idle one and still does not grow — the busy text is clamped inside the reserved box.
Two thresholds, so it reads as progress
| Threshold | Default | Theme property | Prop |
|---|---|---|---|
| Delay before painting | 200ms | --button-pending-delay | pendingDelay |
| Minimum time on screen | 300ms | --button-pending-min-duration | pendingMinDuration |
An action that resolves in 150ms shows nothing at all. An action that resolves in 250ms shows the glyph for long enough to be legible instead of flashing for a single frame. Neither custom property is declared on the component's own class, so a theme's :root value is not shadowed.
Accessibility contract
aria-busy="true"is set while the action is in flight. A label change alone is not announced, which is why the attribute carries the signal.- The accessible name does not change.
pendingLabelis decorative (aria-hidden), so the control is not renamed mid-action. pendingdoes not set the nativedisabledattribute — likegated, it usesaria-disabledso the button stays focusable. It is the user's focus anchor while they wait.- Button deliberately does not invent a
role="status"region. Pair it with your own when the message needs to be spoken. - The glyph is the Spinner primitive, which pauses under
prefers-reduced-motion.
const [saving, setSaving] = React.useState(false);
async function handleSave() {
setSaving(true);
try {
await save();
} finally {
setSaving(false);
}
}
<Button pending={saving} onClick={handleSave}>
Save changes
</Button>;Under asChild the state attributes and the activation suppression still apply,
but no chrome is injected — Slot takes a single child, so there is nowhere to put
the overlay. Render a real <button> when you need the glyph.
Theming
A theme sets case, tracking, type size and rounding without a new size, through
the button component tokens. The unprefixed keys set every size; a size key
sets one. See Component tokens.
components:
button:
dlg-font-size: "10px"
dlg-text-transform: uppercase
dlg-letter-spacing: "0.06em"
dlg-radius: "999px"The outline face draws its edge from the shared control tokens at every size,
so edges: off removes it with every other control's edge.
Installation
npx visor add buttonThis copies two files into your project:
components/ui/button/button.tsx— the componentcomponents/ui/button/button.module.css— the styles
Usage
import { Button } from '@/components/ui/button/button';
export default function Example() {
return <Button>Click me</Button>;
}API Reference
ButtonProps
| Prop | Type | Default | Description |
|---|---|---|---|
variant | 'default' | 'secondary' | 'outline' | 'ghost' | 'destructive' | 'default' | Visual style variant of the button. |
size | 'sm' | 'md' | 'lg' | 'md' | Size of the button, affecting height and padding. |
asChild | boolean | false | Merge props onto the immediate child element instead of rendering a <button>. |
disabled | boolean | false | Disables the button and applies reduced-opacity styling. |
className | string | — | Additional CSS class names to merge onto the element. |
gated | boolean | false | Renders the button in a permission-gated state: visually dimmed (opacity 0.4), cursor not-allowed, and click handlers suppressed. Uses aria-disabled instead of native disabled so the button remains keyboard-focusable (required for the tooltip to be accessible). |
gatedReason | string | — | Explanation surfaced in an anchored tooltip when the button is gated. Only rendered when gated is also true. Requires a TooltipProvider ancestor. Example: "You can't delete this — you're not an owner". |
onClick | React.MouseEventHandler<HTMLButtonElement> | — | Click event handler. |
...props | React.ButtonHTMLAttributes<HTMLButtonElement> | — | All standard HTML button attributes are forwarded. |
The component also accepts all standard <button> HTML attributes.
Source Files
After running npx visor add button, you'll have:
button.tsx
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import styles from "./button.module.css";
const buttonVariants = cva(styles.base, {
variants: {
variant: {
default: styles.variantDefault,
secondary: styles.variantSecondary,
outline: styles.variantOutline,
ghost: styles.variantGhost,
destructive: styles.variantDestructive,
},
size: {
sm: styles.sizeSm,
md: styles.sizeMd,
lg: styles.sizeLg,
},
},
defaultVariants: {
variant: "default",
size: "md",
},
});
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, ...props }, ref) => {
return (
<button
className={cn(buttonVariants({ variant, size }), className)}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = "Button";
export { Button, buttonVariants };button.module.css
All styles use CSS custom properties from @loworbitstudio/visor-core, so the button automatically adapts to your active theme.
Customization
After copying the component, you own it completely.
A busy state is not a customization — use the built-in pending
prop. Hand-rolling one is how a codebase ends up with thirty different busy flags,
each resizing its own control:
// Don't — this resizes the button and announces nothing
{saving ? "Saving…" : "Save"}
// Do
<Button pending={saving}>Save</Button>Genuine customizations are new variants and sizes — add a branch to the CVA
config and a matching class in button.module.css:
const buttonVariants = cva(styles.base, {
variants: {
variant: {
// ...existing variants
success: styles.variantSuccess,
},
},
});