← All components

Button

Four materials and one fill, so the thing to do next is the only dark shape on the screen.

The idea

Only the primary button is filled, and a screen should have one: the fill is how the eye finds what to do next, and two of them make the reader choose before they have read either. The rest are a hairline, nothing, or glass for floating over content. The press is a 3% shrink on the house curve — felt rather than seen — and a loading button swaps its mark for a spinner in the same box, so the label never jumps under the cursor. There is no red variant: what makes a destructive button safe is the words on it and the confirmation in front of it.

Composes Spinner.

Install

terminal1 line
npm i mihirmodi-ui motion

The tokens, the theme script and the routing provider are a one-time job — see Setup. Or skip the package entirely and take the source at the foot of this page.

Usage

Button — usage9 lines
import { Button } from "mihirmodi-ui";

// One primary per screen — the fill is how the eye finds the next step.
<Button variant="primary" loading={saving} onClick={save}>
  Save changes
</Button>
<Button>Cancel</Button>
<Button variant="ghost">Skip</Button>
<Button size="icon" aria-label="New"><PlusIcon /></Button>

Props

PropNotes
variant"primary" | "secondary" | "ghost" | "glass"Primary is ink. Keep it to one per screen.
size"sm" | "md" | "lg" | "icon" | "icon-sm"28, 36 and 40px tall; the icon sizes are square.
loadingbooleanSpinner in place of the leading mark; presses refused; width held.
icon / trailingReactNodeA mark before the label, and one after it — an arrow, a shortcut.
buttonClass(variant, size)functionThe same classes for a link or a row that should wear a button without being one.

Source

Exactly what the package ships — this is read out of the source at build time, not a copy. Take it and adapt it if you would rather not add a dependency.

packages/ui/src/components/actions/Button.tsx91 lines
"use client";

import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from "react";
import { Spinner } from "./Spinner.js";

export type ButtonVariant = "primary" | "secondary" | "ghost" | "glass";
export type ButtonSize = "sm" | "md" | "lg" | "icon" | "icon-sm";

export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
  /**
   * `primary` is the one ink fill on a screen. `secondary` is a hairline on the
   * page. `ghost` has no edge until you point at it. `glass` floats over content.
   */
  variant?: ButtonVariant;
  size?: ButtonSize;
  /** Swap the label's leading mark for a spinner and refuse presses, keeping the width. */
  loading?: boolean;
  /** A mark before the label. */
  icon?: ReactNode;
  /** A mark after the label — an arrow, a shortcut. */
  trailing?: ReactNode;
};

/** The class strings, exported so a link or a menu row can wear a button without being one. */
export const buttonVariants: Record<ButtonVariant, string> = {
  primary: "bg-ink text-background-100 hover:bg-[color-mix(in_srgb,var(--color-ink)_86%,var(--color-background-100))]",
  secondary:
    "border-[0.5px] border-gray-alpha-400 bg-background-100 text-gray-1000 hover:border-gray-alpha-600 hover:bg-gray-100",
  ghost: "text-muted hover:bg-gray-alpha-200 hover:text-gray-1000",
  glass: "glass-nav dock-shadow border-[0.5px] border-gray-alpha-400 text-gray-1000 hover:bg-[var(--glass-menu-bg)]",
};

export const buttonSizes: Record<ButtonSize, string> = {
  sm: "h-7 gap-1.5 rounded-[8px] px-2.5 text-[12px]",
  md: "h-9 gap-2 rounded-[10px] px-3.5 text-[13px]",
  lg: "h-10 gap-2 rounded-[12px] px-4 text-[14px]",
  icon: "h-9 w-9 rounded-[10px]",
  "icon-sm": "h-7 w-7 rounded-[8px]",
};

/** Everything a button shares whatever it wears: the press, the transition, the disabled dim. */
export const buttonBase =
  "inline-flex shrink-0 select-none items-center justify-center whitespace-nowrap font-medium leading-none transition-[background-color,border-color,color,transform,opacity] duration-150 ease-geist active:scale-[0.97] disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none motion-reduce:active:scale-100";

export function buttonClass(variant: ButtonVariant = "secondary", size: ButtonSize = "md", extra = "") {
  return `${buttonBase} ${buttonVariants[variant]} ${buttonSizes[size]} ${extra}`;
}

/**
 * A button, in four materials and five sizes.
 *
 * Only `primary` is filled, and a screen should have one of them: the fill is
 * how the eye finds the thing to do next, and two fills make the reader choose
 * between them before they have read either. Everything else is a hairline or
 * nothing at all. There is deliberately no red "destructive" variant — the
 * palette is monochrome, and what makes a destructive button safe is the
 * words on it and the confirmation in front of it (see AlertDialog), not a
 * colour.
 *
 * The press is a 3% shrink on the house curve: enough to feel the button
 * take the click, too small to read as movement. While `loading`, the leading
 * mark becomes a spinner in the same box, so the label does not jump and the
 * button does not change width under the cursor.
 */
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
  { variant = "secondary", size = "md", loading = false, icon, trailing, className = "", children, disabled, type = "button", ...rest },
  ref
) {
  const lead = loading ? <Spinner size={size === "sm" || size === "icon-sm" ? 12 : 14} /> : icon;
  const iconOnly = size === "icon" || size === "icon-sm";
  return (
    <button
      ref={ref}
      type={type}
      disabled={disabled || loading}
      aria-busy={loading || undefined}
      className={buttonClass(variant, size, className)}
      {...rest}
    >
      {iconOnly ? (
        (lead ?? children)
      ) : (
        <>
          {lead && <span className="flex shrink-0 items-center">{lead}</span>}
          {children}
          {trailing && <span className="-mr-0.5 flex shrink-0 items-center opacity-70">{trailing}</span>}
        </>
      )}
    </button>
  );
});
packages/ui/src/components/actions/Spinner.tsx47 lines
"use client";

export type SpinnerProps = {
  /** Diameter in px. */
  size?: number;
  /** The accessible name. Omit it when the spinner sits inside something that already says it is busy. */
  label?: string;
  className?: string;
};

const TICKS = 8;

/**
 * Eight ticks whose brightness walks round the circle, rather than an arc that
 * spins. A spinning arc is one shape moving fast, and at 14px it smears into a
 * grey ring; ticks that fade in turn are eight shapes holding still, so the
 * motion stays legible at any size and costs only opacity.
 *
 * It takes the colour of the text around it, so on an ink button it is
 * light and on the page it is dark — nothing to configure. Under reduced motion
 * the walk stops and the ticks hold at a gradient, which still reads as
 * "working" without anything moving.
 */
export function Spinner({ size = 16, label, className = "" }: SpinnerProps) {
  return (
    <span
      role={label ? "status" : undefined}
      aria-label={label}
      aria-hidden={label ? undefined : true}
      className={`ui-spinner relative inline-block shrink-0 ${className}`}
      style={{ width: size, height: size }}
    >
      {Array.from({ length: TICKS }, (_, i) => (
        <span
          key={i}
          className="absolute inset-0"
          style={{ transform: `rotate(${(360 / TICKS) * i}deg)` }}
        >
          <span
            className="ui-spinner-tick absolute left-1/2 top-0 h-[28%] w-[11%] min-w-[1.5px] -translate-x-1/2 rounded-full bg-current"
            style={{ animationDelay: `${(-1 + i / TICKS).toFixed(3)}s`, opacity: 0.25 + (0.75 * i) / TICKS }}
          />
        </span>
      ))}
    </span>
  );
}