VisorVisor
ComponentsGeneral

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 button

Variants

Sizes

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

ThresholdDefaultTheme propertyProp
Delay before painting200ms--button-pending-delaypendingDelay
Minimum time on screen300ms--button-pending-min-durationpendingMinDuration

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. pendingLabel is decorative (aria-hidden), so the control is not renamed mid-action.
  • pending does not set the native disabled attribute — like gated, it uses aria-disabled so 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 button

This copies two files into your project:

  • components/ui/button/button.tsx — the component
  • components/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

PropTypeDefaultDescription
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.
asChildbooleanfalseMerge props onto the immediate child element instead of rendering a <button>.
disabledbooleanfalseDisables the button and applies reduced-opacity styling.
classNamestring—Additional CSS class names to merge onto the element.
gatedbooleanfalseRenders 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).
gatedReasonstring—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".
onClickReact.MouseEventHandler<HTMLButtonElement>—Click event handler.
...propsReact.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,
    },
  },
});