Button

A button whose label can change without a jump: the width springs to the new words while they swap in place.

View as markdownFreeMITSince 0.1.0

Install#

npx shadcn@latest add @pixilui/button

Once per app, add the registry to components.json:

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

Adds components/pixilui/button.tsx. Needs motion, clsx, tailwind-merge.

Preview#

Variant
Size
Loading
Theme
Accent

The label changes and the button grows into the new words on a spring, never a jump.

Code#

components/pixilui/button.tsx
"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.
Example
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#

PropTypeDefaultDescription
aria-labelstringIcon-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.
loadingbooleanfalseShows a spinner, sets aria-busy and aria-disabled, and ignores presses. Focus stays on the button.
iconReactNodeLeading icon. The spinner takes its place while loading.
iconEndReactNodeTrailing icon, after the label.
childrenReactNode | undefinedThe 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
  1. Change the children to show progress: {pending ? "Sending" : "Send invoice"} with loading={pending}. The width morph handles the rest.
  2. Pass icons as props (icon, iconEnd); never import an icon library inside the component.
  3. Use variant="primary" once per surface; secondary or ghost for the rest.
  4. Do not use the disabled prop for in-flight work; use loading so focus survives.