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
npm i mihirmodi-ui motionThe 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
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
| Prop | Type | Notes |
|---|---|---|
| variant"primary" | "secondary" | "ghost" | "glass" | "primary" | "secondary" | "ghost" | "glass"= "secondary" | Primary is ink. Keep it to one per screen. |
| size"sm" | "md" | "lg" | "icon" | "icon-sm" | "sm" | "md" | "lg" | "icon" | "icon-sm"= "md" | 28, 36 and 40px tall; the icon sizes are square. |
| loadingboolean | boolean | Spinner in place of the leading mark; presses refused; width held. |
| icon / trailingReactNode | ReactNode | A mark before the label, and one after it — an arrow, a shortcut. |
| buttonClass(variant, size)function | function | The 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.
"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>
);
});
"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>
);
}