Button
A button whose label can change without a jump: the width springs to the new words while they swap in place.
Install#
npx shadcn@latest add @pixilui/buttonOnce per app, add the registry to components.json:
{
"registries": {
"@pixilui": "https://pixilui.com/r/{name}.json"
}
}Or install from the URL, no namespace needed: npx shadcn@latest add https://pixilui.com/r/button.json
- 1
Install the npm packages.
npm install motion clsx tailwind-merge - 2
Add the items it depends on, the same way.
- 3
Copy each file to the path shown.
"use client"; import { forwardRef, useCallback, useRef, type ComponentPropsWithoutRef, type KeyboardEvent, type MouseEvent, type PointerEvent, type ReactNode, } from "react"; import { AnimatePresence, animate, motion, useReducedMotion } from "motion/react"; import { cn } from "./lib/utils"; import { useMorphSize } from "./lib/use-morph-size"; import { motionTokens, reducedTransition } from "./motion-tokens"; type ButtonOwnProps = { /** Visual weight. Keep one primary per surface. */ variant?: "primary" | "secondary" | "ghost" | "danger"; /** Control height: sm is 36px, md 44px, lg 50px. */ size?: "sm" | "md" | "lg"; /** Shows a spinner, sets aria-busy and aria-disabled, and ignores presses. Focus stays on the button. */ loading?: boolean; /** Leading icon. The spinner takes its place while loading. */ icon?: ReactNode; /** Trailing icon, after the label. */ iconEnd?: ReactNode; }; type ButtonLabelProps = | { /** The label. When it changes, the button morphs to the new width and the words swap. */ children: ReactNode; } | { children?: undefined; /** Icon-only buttons need an accessible name. */ "aria-label": string; icon: ReactNode; }; export type ButtonProps = Omit<ComponentPropsWithoutRef<"button">, "children"> & ButtonOwnProps & ButtonLabelProps; const variants = { primary: "bg-accent-solid text-accent-contrast shadow-control inset-shadow-highlight hover-fine:bg-accent-solid/90", secondary: "bg-surface text-foreground shadow-control hairline hairline-strong hover-fine:bg-surface-muted", ghost: "bg-transparent text-foreground hover-fine:bg-surface-muted", danger: "bg-danger text-danger-foreground shadow-resting hover-fine:bg-danger/90", } as const; const sizes = { sm: "h-control-sm px-3.5 text-14", md: "h-control-md px-4 text-14", lg: "h-control-lg px-5 text-16", } as const; const gaps = { sm: "gap-1.5", md: "gap-2", lg: "gap-2" } as const; const iconOnlySizes = { sm: "w-control-sm px-0", md: "w-control-md px-0", lg: "w-control-lg px-0", } as const; function labelKeyOf(children: ReactNode) { return typeof children === "string" || typeof children === "number" ? String(children) : "node"; } /** A ring that turns while work is in flight. Still under reduced motion. */ function ButtonSpinner() { return ( <svg viewBox="0 0 16 16" fill="none" aria-hidden="true" className="animate-spin motion-reduce:animate-none"> <circle cx="8" cy="8" r="6.25" stroke="currentColor" strokeOpacity="0.25" strokeWidth="1.5" /> <path d="M14.25 8A6.25 6.25 0 0 0 8 1.75" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" /> </svg> ); } export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button( { variant = "primary", size = "md", loading = false, icon, iconEnd, children, type = "button", disabled, className, onClick, onPointerDown, onPointerUp, onPointerLeave, onPointerCancel, onKeyDown, onKeyUp, "aria-disabled": ariaDisabledProp, ...rest }, forwardedRef, ) { const reduced = useReducedMotion(); const buttonRef = useRef<HTMLButtonElement | null>(null); const labelKey = labelKeyOf(children); const { containerRef, measureRef } = useMorphSize<HTMLSpanElement, HTMLSpanElement>("width", labelKey); const ariaDisabled = ariaDisabledProp === true || ariaDisabledProp === "true"; const inert = loading || ariaDisabled || Boolean(disabled); const iconOnly = children === undefined || children === null; const setRef = useCallback( (node: HTMLButtonElement | null) => { buttonRef.current = node; if (typeof forwardedRef === "function") forwardedRef(node); else if (forwardedRef) forwardedRef.current = node; }, [forwardedRef], ); const press = (down: boolean) => { const el = buttonRef.current; if (!el || reduced || (down && inert)) return; animate(el, { scale: down ? motionTokens.scale.press : 1 }, motionTokens.spring.snappy); }; const swap = reduced ? reducedTransition : { duration: motionTokens.duration.quick, ease: motionTokens.ease.swap }; const blur = `blur(${motionTokens.blur.subtle}px)`; return ( <button ref={setRef} type={type} disabled={disabled} aria-busy={loading || undefined} aria-disabled={loading || ariaDisabled || undefined} data-variant={variant} data-size={size} data-loading={loading ? "" : undefined} className={cn( "relative inline-flex max-w-full shrink-0 select-none items-center justify-center whitespace-nowrap rounded-control font-body font-medium outline-none", "transition-[background-color,color,box-shadow,opacity] duration-instant ease-swap", "focus-visible:focus-ring disabled:opacity-50 aria-disabled:cursor-not-allowed aria-busy:cursor-progress", "[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", sizes[size], iconOnly && iconOnlySizes[size], variants[variant], className, )} onClick={(event: MouseEvent<HTMLButtonElement>) => { if (loading || ariaDisabled) { event.preventDefault(); return; } onClick?.(event); }} onPointerDown={(event: PointerEvent<HTMLButtonElement>) => { if (event.button === 0) press(true); onPointerDown?.(event); }} onPointerUp={(event: PointerEvent<HTMLButtonElement>) => { press(false); onPointerUp?.(event); }} onPointerLeave={(event: PointerEvent<HTMLButtonElement>) => { press(false); onPointerLeave?.(event); }} onPointerCancel={(event: PointerEvent<HTMLButtonElement>) => { press(false); onPointerCancel?.(event); }} onKeyDown={(event: KeyboardEvent<HTMLButtonElement>) => { if ((event.key === " " || event.key === "Enter") && !event.repeat) press(true); onKeyDown?.(event); }} onKeyUp={(event: KeyboardEvent<HTMLButtonElement>) => { press(false); onKeyUp?.(event); }} {...rest} > <span ref={containerRef} className="relative inline-flex min-w-0 max-w-full items-center overflow-hidden"> <span ref={measureRef} className={cn("inline-flex w-max min-w-0 shrink-0 items-center", gaps[size])}> {loading ? <ButtonSpinner /> : icon} {!iconOnly && ( <AnimatePresence initial={false} mode="popLayout"> <motion.span key={labelKey} className="truncate" initial={reduced ? { opacity: 0 } : { opacity: 0, filter: blur }} animate={{ opacity: 1, filter: "blur(0px)" }} exit={reduced ? { opacity: 0 } : { opacity: 0, filter: blur, transition: { duration: motionTokens.duration.instant } }} transition={swap} > {children} </motion.span> </AnimatePresence> )} {iconEnd} </span> </span> </button> ); }); - 4
Check that your global stylesheet imports the foundation, after Tailwind.
@import "tailwindcss"; @import "../components/pixilui/foundation.css";
Adds components/pixilui/button.tsx. Needs motion, clsx, tailwind-merge.
Preview#
The label changes and the button grows into the new words on a spring, never a jump.
Code#
"use client";
import {
forwardRef,
useCallback,
useRef,
type ComponentPropsWithoutRef,
type KeyboardEvent,
type MouseEvent,
type PointerEvent,
type ReactNode,
} from "react";
import { AnimatePresence, animate, motion, useReducedMotion } from "motion/react";
import { cn } from "./lib/utils";
import { useMorphSize } from "./lib/use-morph-size";
import { motionTokens, reducedTransition } from "./motion-tokens";
type ButtonOwnProps = {
/** Visual weight. Keep one primary per surface. */
variant?: "primary" | "secondary" | "ghost" | "danger";
/** Control height: sm is 36px, md 44px, lg 50px. */
size?: "sm" | "md" | "lg";
/** Shows a spinner, sets aria-busy and aria-disabled, and ignores presses. Focus stays on the button. */
loading?: boolean;
/** Leading icon. The spinner takes its place while loading. */
icon?: ReactNode;
/** Trailing icon, after the label. */
iconEnd?: ReactNode;
};
type ButtonLabelProps =
| {
/** The label. When it changes, the button morphs to the new width and the words swap. */
children: ReactNode;
}
| {
children?: undefined;
/** Icon-only buttons need an accessible name. */
"aria-label": string;
icon: ReactNode;
};
export type ButtonProps = Omit<ComponentPropsWithoutRef<"button">, "children"> & ButtonOwnProps & ButtonLabelProps;
const variants = {
primary: "bg-accent-solid text-accent-contrast shadow-control inset-shadow-highlight hover-fine:bg-accent-solid/90",
secondary: "bg-surface text-foreground shadow-control hairline hairline-strong hover-fine:bg-surface-muted",
ghost: "bg-transparent text-foreground hover-fine:bg-surface-muted",
danger: "bg-danger text-danger-foreground shadow-resting hover-fine:bg-danger/90",
} as const;
const sizes = {
sm: "h-control-sm px-3.5 text-14",
md: "h-control-md px-4 text-14",
lg: "h-control-lg px-5 text-16",
} as const;
const gaps = { sm: "gap-1.5", md: "gap-2", lg: "gap-2" } as const;
const iconOnlySizes = {
sm: "w-control-sm px-0",
md: "w-control-md px-0",
lg: "w-control-lg px-0",
} as const;
function labelKeyOf(children: ReactNode) {
return typeof children === "string" || typeof children === "number" ? String(children) : "node";
}
/** A ring that turns while work is in flight. Still under reduced motion. */
function ButtonSpinner() {
return (
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true" className="animate-spin motion-reduce:animate-none">
<circle cx="8" cy="8" r="6.25" stroke="currentColor" strokeOpacity="0.25" strokeWidth="1.5" />
<path d="M14.25 8A6.25 6.25 0 0 0 8 1.75" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
);
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
{
variant = "primary",
size = "md",
loading = false,
icon,
iconEnd,
children,
type = "button",
disabled,
className,
onClick,
onPointerDown,
onPointerUp,
onPointerLeave,
onPointerCancel,
onKeyDown,
onKeyUp,
"aria-disabled": ariaDisabledProp,
...rest
},
forwardedRef,
) {
const reduced = useReducedMotion();
const buttonRef = useRef<HTMLButtonElement | null>(null);
const labelKey = labelKeyOf(children);
const { containerRef, measureRef } = useMorphSize<HTMLSpanElement, HTMLSpanElement>("width", labelKey);
const ariaDisabled = ariaDisabledProp === true || ariaDisabledProp === "true";
const inert = loading || ariaDisabled || Boolean(disabled);
const iconOnly = children === undefined || children === null;
const setRef = useCallback(
(node: HTMLButtonElement | null) => {
buttonRef.current = node;
if (typeof forwardedRef === "function") forwardedRef(node);
else if (forwardedRef) forwardedRef.current = node;
},
[forwardedRef],
);
const press = (down: boolean) => {
const el = buttonRef.current;
if (!el || reduced || (down && inert)) return;
animate(el, { scale: down ? motionTokens.scale.press : 1 }, motionTokens.spring.snappy);
};
const swap = reduced ? reducedTransition : { duration: motionTokens.duration.quick, ease: motionTokens.ease.swap };
const blur = `blur(${motionTokens.blur.subtle}px)`;
return (
<button
ref={setRef}
type={type}
disabled={disabled}
aria-busy={loading || undefined}
aria-disabled={loading || ariaDisabled || undefined}
data-variant={variant}
data-size={size}
data-loading={loading ? "" : undefined}
className={cn(
"relative inline-flex max-w-full shrink-0 select-none items-center justify-center whitespace-nowrap rounded-control font-body font-medium outline-none",
"transition-[background-color,color,box-shadow,opacity] duration-instant ease-swap",
"focus-visible:focus-ring disabled:opacity-50 aria-disabled:cursor-not-allowed aria-busy:cursor-progress",
"[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
sizes[size],
iconOnly && iconOnlySizes[size],
variants[variant],
className,
)}
onClick={(event: MouseEvent<HTMLButtonElement>) => {
if (loading || ariaDisabled) {
event.preventDefault();
return;
}
onClick?.(event);
}}
onPointerDown={(event: PointerEvent<HTMLButtonElement>) => {
if (event.button === 0) press(true);
onPointerDown?.(event);
}}
onPointerUp={(event: PointerEvent<HTMLButtonElement>) => {
press(false);
onPointerUp?.(event);
}}
onPointerLeave={(event: PointerEvent<HTMLButtonElement>) => {
press(false);
onPointerLeave?.(event);
}}
onPointerCancel={(event: PointerEvent<HTMLButtonElement>) => {
press(false);
onPointerCancel?.(event);
}}
onKeyDown={(event: KeyboardEvent<HTMLButtonElement>) => {
if ((event.key === " " || event.key === "Enter") && !event.repeat) press(true);
onKeyDown?.(event);
}}
onKeyUp={(event: KeyboardEvent<HTMLButtonElement>) => {
press(false);
onKeyUp?.(event);
}}
{...rest}
>
<span ref={containerRef} className="relative inline-flex min-w-0 max-w-full items-center overflow-hidden">
<span ref={measureRef} className={cn("inline-flex w-max min-w-0 shrink-0 items-center", gaps[size])}>
{loading ? <ButtonSpinner /> : icon}
{!iconOnly && (
<AnimatePresence initial={false} mode="popLayout">
<motion.span
key={labelKey}
className="truncate"
initial={reduced ? { opacity: 0 } : { opacity: 0, filter: blur }}
animate={{ opacity: 1, filter: "blur(0px)" }}
exit={reduced ? { opacity: 0 } : { opacity: 0, filter: blur, transition: { duration: motionTokens.duration.instant } }}
transition={swap}
>
{children}
</motion.span>
</AnimatePresence>
)}
{iconEnd}
</span>
</span>
</button>
);
});Usage#
Use it when
- Any action someone triggers: submit, send, deploy, approve.
- Actions whose label follows the work (Send, Sending, Sent) so the button tells the story without a toast.
Use something else when
- Navigation to another page: use a link styled as you need.
- A destructive action on its own: pair the danger variant with a confirm step or hold-to-confirm.
import { useState } from "react";
import { Check, Ellipsis, Eye, Send } from "lucide-react";
import { Button } from "@/components/pixilui/button";
export const knobs = {
variant: ["primary", "secondary", "ghost", "danger"],
size: ["md", "sm", "lg"],
loading: [false, true],
} as const;
type Phase = "idle" | "sending" | "sent";
const labels: Record<Phase, string> = {
idle: "Send invoice",
sending: "Sending to Northwind Freight",
sent: "Sent",
};
export default function ButtonDemo({ variant = "primary", size = "md", loading = false }: KnobValues<typeof knobs>) {
const [phase, setPhase] = useState<Phase>("idle");
const send = () => {
if (phase !== "idle") {
setPhase("idle");
return;
}
setPhase("sending");
setTimeout(() => setPhase("sent"), 1600);
};
return (
<div className="flex flex-wrap items-center justify-center gap-3">
<Button variant="ghost" size={size} aria-label="More actions" icon={<Ellipsis strokeWidth={1.5} />} />
<Button variant="secondary" size={size} icon={<Eye strokeWidth={1.5} />}>
Preview
</Button>
<Button
variant={variant}
size={size}
loading={loading || phase === "sending"}
icon={phase === "sent" ? <Check strokeWidth={1.5} /> : <Send strokeWidth={1.5} />}
onClick={send}
>
{labels[phase]}
</Button>
</div>
);
}API#
| Prop | Type | Default | Description |
|---|---|---|---|
aria-label | string | Icon-only buttons need an accessible name. | |
variant | "primary" | "secondary" | "ghost" | "danger" | "primary" | Visual weight. Keep one primary per surface. |
size | "sm" | "md" | "lg" | "md" | Control height: sm is 36px, md 44px, lg 50px. |
loading | boolean | false | Shows a spinner, sets aria-busy and aria-disabled, and ignores presses. Focus stays on the button. |
icon | ReactNode | Leading icon. The spinner takes its place while loading. | |
iconEnd | ReactNode | Trailing icon, after the label. | |
children | ReactNode | undefined | The label. When it changes, the button morphs to the new width and the words swap. |
Also takes every native <button> attribute, and a ref to it.
Keyboard#
- Tab
- Moves focus to the button; the focus ring shows only for keyboard focus.
- Enter
- Activates the button.
- Space
- Activates the button on release, with the same press scale as a pointer.
Accessibility#
- A native button element, type="button" unless you pass type="submit".
- Loading sets aria-busy and aria-disabled instead of disabled, so focus stays on the button while work runs.
- Icon-only buttons require aria-label at the type level.
- The spinner is aria-hidden; the label carries the meaning, so change it (Sending) for screen readers too.
Motion#
- Press scales to 0.97 on the snappy spring and springs back on release.
- A new label widens the button on the morph spring and narrows it on the smooth spring, so it never overshoots smaller.
- The old label fades out in 120ms through a 2px blur; the new one clears its blur on the swap ease.
- Reduced motion: no press scale, the width changes instantly, labels cross-fade.
Responsive#
- Never wider than its container; long labels clip rather than wrap.
Performance#
- Width is measured with one ResizeObserver per button and only animates when the label changes.
Notes for AI#
Also in the markdown page and llms.txt
- Change the children to show progress: {pending ? "Sending" : "Send invoice"} with loading={pending}. The width morph handles the rest.
- Pass icons as props (icon, iconEnd); never import an icon library inside the component.
- Use variant="primary" once per surface; secondary or ghost for the rest.
- Do not use the disabled prop for in-flight work; use loading so focus survives.