docs / core / button
Button
the most-used thing in the system, so it does the least. one solid per region carries the action; everything else steps back. a button that performs is a button competing with the thing you clicked it for.
intensity · recedesrsc · client
provenance
shaped from personal-website · sisu-pluslayer
core / primitivesintensity
recedes · safe anywhere, including dense surfacescomposition
sits inside Card, PageHeader, Toolbar, Panel, Dialog footerasChild puts the skin on a link when the action navigatesnever inside another Button, never as a row's only click targetdanger styling is not a variant. destruction gets a Dialog firsta11y
focus ring on--ring · loading sets aria-busy, not disabled · the label never emptiesjest-axedependencies
motion · class-variance-authority · Spinner from the same packagelive demo · try it out
customize
variantvisual weight
sizedensity
shapepill fully rounds it
icon onlysquare, needs aria-label
loadingstill focusable
disabledprefer an inline error
usage
import { Button } from "usva/primitives/button";
<Button>Save changes</Button>props
| prop | type | default | notes |
|---|---|---|---|
| variant | "solid" | "soft" | "outline" | "ghost" | "onSurface" | "glass" | "solid" | visual weight. one solid per region. onSurface is a theme-tonal fill for a button on a surface or gradient you own; it adapts with the theme. glass is a fixed dark frost, blurred, for a control floating over a live atmosphere, where the theme cannot guarantee contrast. |
| size | "sm" | "md" | "lg" | "md" | sm exists for dense rows. it is a smaller button, not a subtler one. |
| shape | "rounded" | "pill" | "rounded" | pill fully rounds the button into a chip. the default follows the size radius. |
| asChild | boolean | false | merges props onto the single child instead of rendering a <button>. how a link earns the button skin. |
| disabled | boolean | false | dims to 50% and drops pointer events. loading is a status, not this. |
| status | "idle" | "loading" | "success" | "error" | "idle" | the content machine. keeps its width: loading swaps in the spinner, success and error flash their icon and settle back to idle. |
| loadingText / successText / errorText | ReactNode | — | the label beside the spinner, check and alert for each non-idle status. |
| iconOnly | boolean | false | a square button for one glyph. requires aria-label, it throws in dev without one. |
| tooltip / side | ReactNode · "top" | "bottom" | "left" | "right" | — | a visible label on hover and focus. what icon-only buttons use instead of text. |
| active | boolean | false | the pressed look, for toggles. |
| settleDelay | number | 1200 | how long success or error holds before returning to idle. |
| onSettle | () => void | — | fires when a success or error settles back to idle. |
get it
npx shadcn add https://usva.build/r/button.jsonsource · components/ui/button.tsxexactly what this command copies
"use client";
import { cva, type VariantProps } from "class-variance-authority";
import { motion, useReducedMotion } from "motion/react";
import * as React from "react";
import { cn } from "@/lib/utils";
import { Spinner } from "./spinner";
declare const process: { env: { NODE_ENV?: string } };
export type ButtonStatus = "idle" | "loading" | "success" | "error";
export const buttonVariants = cva(
cn(
"relative isolate inline-flex cursor-pointer select-none items-center justify-center gap-2 whitespace-nowrap rounded-lg font-medium tracking-[-0.01em] outline-none",
"transition-control duration-fast ease-soft",
"hover:-translate-y-px active:scale-[0.96] motion-reduce:transition-none motion-reduce:transform-none",
"before:pointer-events-none before:absolute before:inset-0 before:-z-10 before:rounded-[inherit] before:bg-transparent before:transition-tint before:duration-fast",
"after:absolute after:inset-x-0 after:content-['']",
"focus-visible:ring-focus",
"disabled:pointer-events-none disabled:opacity-50 disabled:saturate-[0.7]",
"data-[status=loading]:pointer-events-none",
"data-[status=success]:bg-none data-[status=success]:bg-success data-[status=success]:text-on-accent",
"data-[status=error]:bg-none data-[status=error]:bg-danger data-[status=error]:text-on-accent",
"[&_svg]:pointer-events-none [&_svg]:shrink-0",
),
{
variants: {
variant: {
solid:
"bg-accent bg-gradient-accent font-semibold text-on-accent shadow-raised hover:glow-ring hover:before:bg-ink/10 active:before:bg-ink/5",
soft: "bg-surface-2 text-ink shadow-raised hover:before:bg-ink/5",
ghost: "bg-transparent text-muted hover:text-ink hover:before:bg-ink/5",
outline:
"border border-border bg-transparent text-ink hover:border-border-strong hover:before:bg-ink/5 focus-visible:border-transparent",
onSurface:
"border border-ink/10 bg-ink/[0.055] font-semibold text-ink hover:border-ink/20 hover:before:bg-ink/5",
glass:
"border border-white/15 bg-black/40 text-white/90 shadow-raised backdrop-blur-sm hover:bg-black/55 hover:before:bg-white/5",
},
size: {
sm: "h-8 gap-1.5 rounded-md px-3 text-xs after:-inset-y-1.5",
md: "h-10 px-4 text-sm after:-inset-y-0.5",
lg: "h-12 rounded-xl px-6 text-[0.9375rem] after:inset-y-0",
},
iconOnly: { true: "px-0", false: "" },
active: { true: "glow-ring text-accent", false: "" },
shape: { rounded: "", pill: "" },
},
compoundVariants: [
// the `after` inset expands the hit area to 44px without growing the box,
// so it has to scale inversely with the visual size
{
iconOnly: true,
size: "sm",
class: "w-8 rounded-lg after:-inset-1.5 [&_svg]:size-4",
},
{
iconOnly: true,
size: "md",
class: "w-10 rounded-xl after:-inset-0.5 [&_svg]:size-[1.15rem]",
},
{ iconOnly: true, size: "lg", class: "w-12 [&_svg]:size-5" },
// icon-only outline keeps the quiet reading the old IconButton had
{
iconOnly: true,
variant: "outline",
active: false,
class: "bg-surface text-muted hover:text-ink",
},
{ active: true, variant: "outline", class: "border-transparent" },
{ shape: "pill", class: "rounded-full" },
],
defaultVariants: {
variant: "solid",
size: "md",
iconOnly: false,
active: false,
shape: "rounded",
},
},
);
const TOOLTIP_SIDE: Record<string, string> = {
top: "bottom-full left-1/2 mb-2 -translate-x-1/2",
bottom: "top-full left-1/2 mt-2 -translate-x-1/2",
left: "right-full top-1/2 mr-2 -translate-y-1/2",
right: "left-full top-1/2 ml-2 -translate-y-1/2",
};
const SPINNER_SIZE = { sm: "sm", md: "sm", lg: "md" } as const;
const GRID_STYLE: React.CSSProperties = {
display: "grid",
placeItems: "center",
};
const CELL_STYLE: React.CSSProperties = { gridArea: "1 / 1" };
const SIZER_STYLE: React.CSSProperties = {
...CELL_STYLE,
visibility: "hidden",
};
// Inline, not `sr-only`: a consumer's Tailwind build never scans this file.
const SR_ONLY_STYLE: React.CSSProperties = {
position: "absolute",
width: 1,
height: 1,
margin: -1,
padding: 0,
overflow: "hidden",
clipPath: "inset(50%)",
whiteSpace: "nowrap",
border: 0,
};
type MotionConflicts =
| "onAnimationStart"
| "onAnimationEnd"
| "onDrag"
| "onDragStart"
| "onDragEnd";
export interface ButtonProps
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, MotionConflicts>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
status?: ButtonStatus;
loadingText?: React.ReactNode;
successText?: React.ReactNode;
errorText?: React.ReactNode;
/** How long `success` / `error` hold before the button settles back to idle. */
settleDelay?: number;
onSettle?: () => void;
/** Optional visible tooltip on hover/focus. */
tooltip?: React.ReactNode;
side?: "top" | "bottom" | "left" | "right";
}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
(
{
className,
variant,
size = "md",
iconOnly = false,
active = false,
shape,
asChild,
status = "idle",
loadingText,
successText,
errorText,
settleDelay = 1200,
onSettle,
tooltip,
side = "top",
onClick,
children,
...props
},
ref,
) => {
const reduce = useReducedMotion();
const display = useSettlingStatus(status, settleDelay, onSettle);
const tooltipId = React.useId();
const busy = display === "loading";
if (
process.env.NODE_ENV !== "production" &&
iconOnly &&
!props["aria-label"] &&
!props["aria-labelledby"]
) {
throw new Error("usva: an icon-only Button needs an aria-label.");
}
if (asChild)
return (
<Slot
{...props}
slotRef={ref}
onClick={onClick}
className={cn(
buttonVariants({ variant, size, iconOnly, active, shape }),
className,
)}
>
{children}
</Slot>
);
const content: Record<ButtonStatus, React.ReactNode> = {
idle: children,
loading: (
<>
<Spinner
aria-hidden="true"
label=""
tone="current"
size={SPINNER_SIZE[size ?? "md"]}
/>
{loadingText ?? (iconOnly ? null : "Loading")}
</>
),
success: (
<>
<CheckIcon />
{successText ?? <span style={SR_ONLY_STYLE}>{children}</span>}
</>
),
error: (
<>
<AlertIcon />
{errorText ?? <span style={SR_ONLY_STYLE}>{children}</span>}
</>
),
};
// The lift is declared twice on purpose. Motion writes an inline transform on the
// first press and never gives it back, which outranks the `hover:-translate-y-px`
// class from here on. That class is what the motion-free `asChild` path uses.
const button = (
<motion.button
ref={ref}
data-status={display}
aria-busy={busy || undefined}
aria-describedby={tooltip ? tooltipId : undefined}
whileHover={reduce ? undefined : { y: -1 }}
whileTap={reduce ? undefined : { scale: 0.96, y: 0 }}
transition={
reduce
? { duration: 0 }
: { type: "spring", stiffness: 420, damping: 34 }
}
onClick={busy ? undefined : onClick}
className={cn(
buttonVariants({ variant, size, iconOnly, active, shape }),
className,
)}
{...props}
>
{/* Every state is stacked in one grid cell so the button reserves the widest
state's width up front and never reshapes between them. Only the active
label is shown; the rest are hidden sizers. Grid placement is inline-styled,
not Tailwind, so a consumer's build can never tree-shake the layout away. */}
<span style={GRID_STYLE}>
{(Object.keys(content) as ButtonStatus[]).map((s) => (
<span
key={s}
aria-hidden
style={SIZER_STYLE}
className="inline-flex items-center gap-2 whitespace-nowrap"
>
{content[s]}
</span>
))}
<motion.span
key={display}
style={CELL_STYLE}
initial={reduce ? false : { opacity: 0, scale: 0.96 }}
animate={{ opacity: 1, scale: 1 }}
transition={
reduce
? { duration: 0 }
: { duration: 0.2, ease: [0.22, 1, 0.36, 1] }
}
className="inline-flex items-center gap-2 whitespace-nowrap"
>
{content[display]}
</motion.span>
</span>
</motion.button>
);
if (!tooltip) return button;
return (
<span className="group relative isolate inline-flex">
{button}
<span
id={tooltipId}
role="tooltip"
className={cn(
"pointer-events-none absolute z-overlay whitespace-nowrap rounded-md border border-border bg-overlay px-2 py-1 font-mono text-[0.625rem] font-semibold uppercase tracking-[0.08em] text-ink shadow-floating",
"opacity-0 transition-opacity duration-fast ease-soft group-hover:opacity-100 group-focus-within:opacity-100 motion-reduce:transition-none",
TOOLTIP_SIDE[side],
)}
>
{tooltip}
</span>
</span>
);
},
);
Button.displayName = "Button";
/**
* `success` and `error` are momentary: they hold for `settleDelay`, then the button
* returns to idle on its own. The callback lives in a ref so an inline `onSettle`
* doesn't restart the timer on every render.
*/
function useSettlingStatus(
status: ButtonStatus,
settleDelay: number,
onSettle?: () => void,
): ButtonStatus {
const [display, setDisplay] = React.useState<ButtonStatus>(status);
const settle = React.useRef(onSettle);
React.useEffect(() => {
settle.current = onSettle;
}, [onSettle]);
React.useEffect(() => {
setDisplay(status);
if (status !== "success" && status !== "error") return;
const timer = setTimeout(() => {
setDisplay("idle");
settle.current?.();
}, settleDelay);
return () => clearTimeout(timer);
}, [status, settleDelay]);
return display;
}
function CheckIcon() {
return (
<svg
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className="h-4 w-4"
aria-hidden="true"
>
<path d="M2.5 6.5 5 9l4.5-5.5" />
</svg>
);
}
function AlertIcon() {
return (
<svg
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className="h-4 w-4"
aria-hidden="true"
>
<path d="M6 3v3.5" />
<path d="M6 8.75h.01" />
</svg>
);
}
type Handler = (...args: unknown[]) => void;
function childRef(element: React.ReactElement): React.Ref<unknown> | undefined {
return Number.parseInt(React.version, 10) >= 19
? (element.props as { ref?: React.Ref<unknown> }).ref
: (element as unknown as { ref?: React.Ref<unknown> }).ref;
}
/**
* Carries the ref under a name of its own. React 18 reserves `ref`, strips it
* from a function component's props and warns, so a Slot that read `props.ref`
* would forward nothing there while working on 19.
*/
function Slot({
children,
className,
style,
slotRef,
...props
}: React.ButtonHTMLAttributes<HTMLElement> & {
children?: React.ReactNode;
slotRef?: React.Ref<unknown>;
}) {
const element = React.isValidElement<React.HTMLAttributes<HTMLElement>>(
children,
)
? children
: null;
const theirRef = element ? childRef(element) : undefined;
const composedRef = React.useCallback(
(node: unknown) => {
for (const target of [slotRef, theirRef]) {
if (typeof target === "function") target(node);
else if (target)
(target as React.MutableRefObject<unknown>).current = node;
}
},
[slotRef, theirRef],
);
if (!element) return null;
const childProps = element.props as Record<string, unknown>;
const merged: Record<string, unknown> = { ...props, ...childProps };
for (const [key, ours] of Object.entries(props)) {
if (!key.startsWith("on") || typeof ours !== "function") continue;
const theirs = childProps[key];
merged[key] =
typeof theirs === "function"
? (...args: unknown[]) => {
(theirs as Handler)(...args);
(ours as Handler)(...args);
}
: ours;
}
merged.className = cn(className, childProps.className as string);
merged.style = { ...style, ...(childProps.style as React.CSSProperties) };
merged.ref = composedRef;
return React.cloneElement(
element,
merged as React.HTMLAttributes<HTMLElement>,
);
}