FoundationsButton
The action primitive — six variants and four sizes on cva, composition via Slot.
The thinking
Every other interactive surface in the system is built on this one, so it has to carry meaning, not just a fill color. The load-bearing idea is that the six variants are a single ladder of loudness, each rung quieter than the one above it, and the rung you reach for encodes how much weight the action deserves. So nothing here is decorative: the press is
active:scale-[0.98] and nothing else, because the one thing a button cannot do is lag the finger that pressed it. I delegate the click sound and lean on Slot for composition for the same reason — a button that ships its own audio and renders its own tag is doing two jobs it should have handed off. (The restraint is the feature.)Interface design
The variants are rungs of the depth ladder, not styles: fill, then outline, then ghost, each step quieter than the last. The primary fill hovers to the
primary-hover token instead of an alpha trick, so in dark mode it brightens away from the L .19 ground rather than going muddy. Destructive rests on destructive-bright and settles to destructive on hover — the loud red is the warning, the calmer one is the commitment. And because white stays reserved for interactive chrome in light mode, the secondary button sits on --secondary, not the white card surface.Interaction
The press is
active:scale-[0.98] — transform only, 150ms, ease-swift. A press that lags the finger reads as a broken button, not a slow one, so nothing in that transition is allowed to touch layout or paint. Hover gets the same 150ms and nothing more; hover is intent, not commitment, and the scale waits for actual force. The one piece of intent-reading here is the piece I didn't write: because this renders a real <button>, pressing, sliding off, and releasing cancels the action — the browser's own forgiveness, inherited rather than reimplemented.Sound design
Buttons own no sound. The global SoundLayer plays the tap through one capturing click listener for anything that carries an interactive role — a
<button> speaks because it is a button, so this component ships zero audio code. The layer fires on click, not pointerdown, so the sound lands with the consequence. Components that own a paired sound, like the dock's theme toggle, mark themselves data-silent and the layer steps aside.Empathy
Disabled is
disabled:opacity-50 plus disabled:pointer-events-none — the button fades to half and stops taking the pointer, and because it renders a real <button>, the browser also drops it from the tab order and the SoundLayer's :disabled check keeps it quiet. Keyboard focus draws focus-visible:ring-1 on the ring token and a mouse click never does — the ring answers the keyboard, where nothing else marks your place. Under reduced motion, motion-reduce:transition-none drops the easing but keeps the pressed state; the scale snaps instead of settles. Long labels stay on one line — whitespace-nowrap widens the button rather than wrapping it — and I don't truncate; if the label doesn't fit, I'd rather rewrite the label than hide it. The honest caveat is asChild: hand Slot a child and the semantics travel with it, so the role becomes the composer's job.import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
// sound is delegated: the SoundLayer hears <button> semantics through one
// capturing listener, so this file ships zero audio code. data-silent is
// the opt-out for components that own a paired sound.
const buttonVariants = cva(
// press feedback is transform-only at 150ms — the press must never lag the finger
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition duration-150 ease-swift active:scale-[0.98] motion-reduce:transition-none focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
// hover lands on the primary-hover token, not an alpha trick —
// in dark mode the fill brightens away from the ground
default: "bg-primary text-primary-foreground hover:bg-primary-hover",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary-hover",
outline:
"border border-input bg-background hover:bg-secondary hover:text-secondary-foreground",
ghost: "hover:bg-secondary hover:text-secondary-foreground",
// rests bright, settles to the base red on hover — the loud color
// is the warning, the calm one is the commitment
destructive:
"bg-destructive-bright text-destructive-foreground hover:bg-destructive",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-sm",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
ref?: React.Ref<HTMLButtonElement>;
}
const Button = ({
ref,
className,
variant,
size,
asChild = false,
...props
}: ButtonProps) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
};
Button.displayName = "Button";
export { Button, buttonVariants };