Button

A button triggers an action. It comes in four intents (default, secondary, destructive and ghost), in a regular and a small size, can carry an icon, and has square icon-only forms. Every one of them has hover, focus, pressed and disabled states.

import { ArrowRight } from "lucide-react";
import { Button } from "@/components/ui/button";

export default function ButtonDemo() {
  return (
    <>
      <Button>Button</Button>
      <Button variant="secondary">Button</Button>
      <Button variant="destructive">Button</Button>
      <Button variant="ghost">Button</Button>
      <Button disabled>Button</Button>
      <Button>
        Button
        <ArrowRight />
      </Button>
      <Button size="icon" aria-label="Next">
        <ArrowRight />
      </Button>
      <Button size="sm">Small</Button>
      <Button size="icon-sm" variant="secondary" aria-label="Next">
        <ArrowRight />
      </Button>
    </>
  );
}

The code

The component is a single file, read from the repository when the site is built. Press Show code to see all of it.

import type { ComponentProps } from "react";
import { Button as BaseButton } from "@base-ui/react/button";
import { cx } from "@/lib/cx";

type Variant = "default" | "secondary" | "destructive" | "inverse" | "ghost";
type Size = "default" | "sm" | "icon" | "icon-sm";

// Every variant has hover, focus, pressed and disabled states. Hover and pressed are guarded
// with not-data-[disabled] so a disabled button never reacts. Pressing scales the button to
// 97% and the scale animates (skipped for reduced motion); the transition names `scale`
// because Tailwind applies it as its own CSS property, not through `transform`. Width is in the
// list too, so a button that changes between two set widths (a label becoming an icon) grows or
// shrinks smoothly. Height and text size come from the size, so every size sets its own.
const base = [
  "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md border",
  "font-semibold select-none cursor-pointer",
  "[&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0",
  "transition-[background-color,border-color,color,scale,width] duration-150 ease-out",
  "outline-hidden focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-text",
  "motion-safe:not-data-[disabled]:active:scale-97",
  "data-[disabled]:cursor-not-allowed data-[disabled]:border-border data-[disabled]:bg-surface data-[disabled]:text-text-secondary",
].join(" ");

const variants: Record<Variant, string> = {
  default: [
    "border-transparent bg-accent text-bg",
    "not-data-[disabled]:hover:bg-accent/85",
    "not-data-[disabled]:active:bg-accent/70",
  ].join(" "),
  secondary: [
    "border-text bg-transparent text-text",
    "not-data-[disabled]:hover:bg-text/10",
    "not-data-[disabled]:active:bg-text/20",
  ].join(" "),
  destructive: [
    "border-transparent bg-danger text-bg",
    "not-data-[disabled]:hover:bg-danger/85",
    "not-data-[disabled]:active:bg-danger/70",
  ].join(" "),
  // A neutral, light fill for places where green is not wanted, like this site's own header.
  inverse: [
    "border-transparent bg-text text-bg",
    "not-data-[disabled]:hover:bg-text/85",
    "not-data-[disabled]:active:bg-text/70",
  ].join(" "),
  // No fill and no outline until you point at it: a faint wash on hover, a stronger one pressed.
  ghost: [
    "border-transparent bg-transparent text-text",
    "not-data-[disabled]:hover:bg-text/10",
    "not-data-[disabled]:active:bg-text/20",
  ].join(" "),
};

// "icon" and "icon-sm" are square, text-less buttons: a width replaces the side padding.
const sizes: Record<Size, string> = {
  default: "h-9 px-4 text-base",
  sm: "h-7 px-3 text-sm",
  icon: "h-9 w-9 text-base",
  "icon-sm": "h-7 w-7 text-sm",
};

export type ButtonProps = Omit<ComponentProps<typeof BaseButton>, "className"> & {
  variant?: Variant;
  size?: Size;
  className?: string;
};

export function Button({ variant = "default", size = "default", className, ...props }: ButtonProps) {
  return <BaseButton className={cx(base, variants[variant], sizes[size], className)} {...props} />;
}

Props

PropTypeDefaultWhat it does
variantdefault | secondary | destructive | ghost | inversedefaultThe intent of the button, and its color. ghost has no fill and no outline until you point at it.
sizedefault | sm | icon | icon-smdefaultsm is a smaller button, for tight places. icon and icon-sm make square buttons for an icon with no label.
disabledbooleanfalseTurns off the button and its hover and pressed states.
renderelement—Renders the button as another element, such as a link. Pass nativeButton={false} with it.
classNamestring—Extra classes, added after the button's own.

Every other prop goes straight to the Base UI Button underneath.

The thinking

A button should be predictable: it does one thing when pressed, and its label says what. So there is one component with a small set of intents, not a different button for every use.

  • Built on Base UI. Disabled handling, keyboard activation and focus come from Base UI's Button. This file only adds the look.
  • Intent is a variant. default is the main action, secondary a quieter one, and destructive is for actions that remove something or can't be undone.
  • Green belongs to the component. The default button uses the accent. The inverse variant is a light fill for places where green isn't wanted, like this site's own header and hero.

Interface design

  • Size: 36px tall with 16px of side padding. sm is 28px tall with 12px of side padding and 14px text. The icon-only buttons are squares of 36px and 28px. Corners use the md radius, 6px.
  • Label: 16px, semibold. An icon goes after the label, 8px away, and is 16px unless you size it yourself.
  • Color: default fills with the accent, destructive with the danger color, and inverse with the main text color. secondary has no fill and a 1px outline, and ghost has neither until it is hovered. See Theming for the tokens.
  • Text on a fill: always bg, per the theming rule. That gives 14.6:1 contrast on the accent, 5.5:1 on danger and 15.7:1 on the light fill.
  • Disabled: a flat surface fill and a quiet label, so it reads as off whichever variant it was.

Interaction

  • Hover: the fill eases to 85%. A ghost button gets a faint 10% wash, since it has no fill to ease.
  • Focus: keyboard focus draws a 2px outline in the main text color, 2px away from the button. A mouse click doesn't show it.
  • Pressed: the button shrinks slightly, to 97%, and the fill drops to 70% (a ghost button's wash deepens to 20%). Both animate over 150ms. People who ask their system for reduced motion get the color change without the shrink.
  • Disabled: no hover, no press, and a not-allowed cursor. Base UI also takes it out of the tab order unless you set focusableWhenDisabled.

Empathy

  • It is a real button. Enter and Space activate it, and screen readers announce it as one. As a link it keeps link behavior.
  • Icon-only needs a name. The icon button has no text, so always give it an aria-label, as the demo does.
  • Never color alone. A destructive button is red, and its label still says what it does. The focus outline has 15.7:1 contrast against the page.
  • Target size: 36px clears the 24px minimum of WCAG 2.2 AA, and so does the 28px small size, but both are smaller than the 44px often advised for touch. Give them room around them on phones, and keep the small size for dense places like a toolbar.