Skip to examples
Bento / Kitchen sink
Bento / primitives

Forms

Every sized control is exactly its size’s control token tall, in every preset and density, so an input, a select, and a button of one size line up. Choice controls sit on a line of text.

Button

variant · size · loading · disabled · asChild

variant
size
icon + label
loading
disabled

An icon-only button must be named. With size="icon", leaving out aria-label is a type error.

Loading keeps the label, so the button does not change width under the pointer. It blocks clicks and is announced busy, but is not faded: the action is in progress, not unavailable.

A disabled link gets aria-disabled, leaves the tab order, and ignores the pointer. Script can still activate it, so do not render a link you do not want followed.

Sourcecomponents/forms/doc.ts · components/forms/button/doc.ts · components/forms/button/button.tsx · components/forms/button/button.variants.ts · components/forms/button/button.module.css

components/forms/doc.ts

/**
 * forms — controls that take input, and the rules they share.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Rules for every control
 *
 * R1  Sizes are "sm", "md", and "lg", default "md". A control of a given size
 *     is exactly `--control-sm`, `--control-md`, or `--control-lg` tall in
 *     every preset and density, whatever its font, so any two controls of the
 *     same size line up in a row.
 * R2  Text inside a control is `--text-control`.
 * R3  Keyboard focus shows a 2px ring in `--accent`, offset 2px; a control in
 *     an invalid state shows it in `--crit` instead.
 * R4  Disabled is 45% opacity with a not-allowed cursor, and nothing reacts to
 *     hover.
 * R5  Every control accepts its element's native attributes and a ref, and a
 *     caller's class is merged after its own.
 * R6  A control owns its own appearance and states only. Its label, hint, and
 *     error belong to the field around it.
 * R7  Choice controls — Checkbox, Radio, Switch — are fixed in size (16px, or
 *     an 18px-high track) so they sit on a line of text, and are not covered
 *     by R1. They are labelled by an inline Label around them.
 * R8  Every control is labelled: by a Field, by an inline Label, or, for an
 *     icon-only Button, by `aria-label`.
 * R9  Pickers (Combobox, MultiSelect, DatePicker, DateRangePicker) take the
 *     label, hint, and error as props instead of sitting inside a Field: the
 *     headless library must wire them to parts the caller never renders
 *     (segments, a listbox, a calendar). They look identical to a Field.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § MECHANICS — NOT the oracle.                                             │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * R1 is why a single-line control sets `height`, not `min-height`, and zero
 * block padding: padding plus line-height would otherwise decide the height,
 * and they differ with each preset's font. Compact density retunes the three
 * tokens, so R1 covers it without a density class on any control.
 */
export {};

components/forms/button/doc.ts

/**
 * Button — an action, or a link that looks like one.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     variant?    "primary" | "secondary" | "quiet" | "danger"
 *                                                       default "secondary"
 *     size?       "sm" | "md" | "lg" | "icon"           default "md"
 *     disabled?   boolean
 *     loading?    boolean
 *     aria-label  REQUIRED when size is "icon"
 *     …every attribute of a button, and a ref
 *     plus one way to render a link instead (see Mechanics: it differs by
 *     framework)
 *
 * # Behaviour
 *
 * R1  Renders a native button whose type is "button" unless the caller says
 *     otherwise, so a button inside a form never submits it by accident.
 * R2  `primary` is filled with `--fill` and is the one action a screen wants
 *     taken; `secondary` is bordered on a panel; `quiet` has no border or
 *     background and uses `--accent` text; `danger` is outlined in
 *     `--crit-line` with `--crit` text and a `--crit-tint` hover — findable,
 *     never the most inviting thing on the screen.
 * R3  Height is exactly the size's control token (forms R1), with inline
 *     padding `--space-4`, `--space-6`, or `--space-8`. The label does not
 *     wrap.
 * R4  Corner radius is `--button-radius` where a preset sets it (v4's pill
 *     buttons), and `--radius-2` otherwise.
 * R5  Hover changes the background of every variant; disabled buttons do not
 *     react to hover.
 * R6  Rendered as a link, it looks and sizes the same. A disabled link is
 *     announced as disabled, cannot be clicked, and is removed from the tab
 *     order.
 * R7  `icon` is a square at the md control height with no padding. It has no
 *     visible text, so an accessible name is required, and omitting
 *     `aria-label` is a type error.
 * R8  `loading` shows a spinner before the label and keeps the label, so the
 *     button does not change width or move under the pointer. It blocks
 *     activation like `disabled` and is announced busy, but is not faded: the
 *     action is in progress, not unavailable. Under reduced motion the spinner
 *     turns more slowly.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § MECHANICS — NOT the oracle.                                             │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * React: `asChild` renders the caller's element through a slot, carrying the
 * button's classes. A disabled non-button gets `aria-disabled` and
 * `tabIndex={-1}`, and the stylesheet stops pointer events on it. That does
 * not stop activation of an element focused programmatically; closing that
 * gap would need a synthesised click handler. Do not render a link you do not
 * want followed.
 *
 * Svelte: `href` renders an anchor through bits-ui's button, which on a
 * disabled link also removes the href — so R6 holds completely there.
 */
export {};

components/forms/button/button.tsx

import { Slot, Slottable } from "@radix-ui/react-slot";
import type { ComponentPropsWithRef } from "react";

import { LoaderCircle } from "@/components/utility/icon";
import { cn } from "@/lib/utils/cn";
import styles from "./button.module.css";
import { buttonVariants, type ButtonVariants } from "./button.variants";

type BaseProps = ComponentPropsWithRef<"button"> &
  Omit<ButtonVariants, "size"> & {
    asChild?: boolean;
    /** Shows a spinner, keeps the label, and blocks activation. */
    loading?: boolean;
  };

/** A union: the `icon` size renders no text, so it has nothing to announce
 *  unless a label is supplied. Requiring `aria-label` on that arm alone turns
 *  the commonest accessible-name failure into a type error. */
export type ButtonProps =
  | (BaseProps & { size?: "sm" | "md" | "lg" | null })
  | (BaseProps & { size: "icon"; "aria-label": string });

export function Button({
  asChild = false,
  variant,
  size,
  type = "button",
  disabled,
  loading = false,
  className,
  children,
  ...props
}: ButtonProps) {
  const Component = asChild ? Slot : "button";
  const inert = disabled || loading;
  // A native button disables itself. Any other element has no disabled state,
  // so it is marked and taken out of the tab order instead; see doc.ts.
  const stateProps = asChild
    ? inert
      ? { "aria-disabled": true, tabIndex: -1 }
      : undefined
    : { type, disabled: inert };

  return (
    <Component
      {...props}
      {...stateProps}
      aria-busy={loading || undefined}
      className={cn(buttonVariants({ variant, size }), className)}
    >
      {loading ? (
        <LoaderCircle className={styles.spinner} aria-hidden="true" />
      ) : null}
      {/* Slottable marks which child receives the merged props, so the spinner
          renders inside the caller's element rather than beside it. */}
      {asChild ? <Slottable>{children}</Slottable> : children}
    </Component>
  );
}

components/forms/button/button.variants.ts

import { cva, type VariantProps } from "class-variance-authority";

import styles from "./button.module.css";

export const buttonVariants = cva(styles.root, {
  variants: {
    variant: {
      primary: styles.primary,
      secondary: styles.secondary,
      quiet: styles.quiet,
      danger: styles.danger,
    },
    size: { sm: styles.sm, md: styles.md, lg: styles.lg, icon: styles.icon },
  },
  defaultVariants: { variant: "secondary", size: "md" },
});

export type ButtonVariants = VariantProps<typeof buttonVariants>;

components/forms/button/button.module.css

@layer primitive {
  .root {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: var(--space-3);
    border: 1px solid var(--line-strong);
    border-radius: var(--button-radius, var(--radius-2));
    background: var(--surface-panel);
    color: var(--ink);
    font: inherit;
    font-size: var(--text-control, var(--text-13));
    font-weight: var(--weight-strong);
    line-height: 1;
    white-space: nowrap;
    cursor: pointer;
    transition:
      background-color var(--dur-2) var(--ease),
      border-color var(--dur-2) var(--ease),
      color var(--dur-2) var(--ease);
  }

  /* Icons in the label follow the text size. */
  .root > svg {
    width: 1.15em;
    height: 1.15em;
    flex: none;
  }

  .sm {
    min-height: var(--control-sm);
    padding-inline: var(--space-4);
  }
  .md {
    min-height: var(--control-md);
    padding-inline: var(--space-6);
  }
  .lg {
    min-height: var(--control-lg);
    padding-inline: var(--space-8);
  }
  /* Square at the md height; the icon is the whole label. */
  .icon {
    width: var(--control-md);
    min-height: var(--control-md);
    padding: 0;
  }

  .secondary:hover:not(:disabled) {
    background: var(--surface-hover-2);
  }
  .primary {
    border-color: var(--fill);
    background: var(--fill);
    color: var(--fill-ink);
  }
  .primary:hover:not(:disabled) {
    border-color: var(--fill-hover);
    background: var(--fill-hover);
  }
  .quiet {
    border-color: transparent;
    background: transparent;
    color: var(--accent);
  }
  .quiet:hover:not(:disabled) {
    background: var(--surface-hover);
  }
  /* Outlined rather than filled: a destructive action should be findable, not
     the most inviting thing on the screen. */
  .danger {
    border-color: var(--crit-line);
    color: var(--crit);
  }
  .danger:hover:not(:disabled) {
    background: var(--crit-tint);
  }

  .root:focus-visible {
    outline: 2px solid var(--accent);
    outline-offset: 2px;
  }
  .root:disabled,
  .root[aria-disabled="true"] {
    opacity: 0.45;
    cursor: not-allowed;
  }
  /* A disabled link cannot use the disabled attribute; this stops the
     pointer, and removal from the tab order stops the keyboard. */
  .root[aria-disabled="true"] {
    pointer-events: none;
  }
  /* Loading blocks activation like disabled, but is not faded: the action was
     taken and is in progress, it is not unavailable. */
  .root[aria-busy="true"] {
    opacity: 1;
    cursor: progress;
  }

  .spinner {
    animation: spin 0.8s linear infinite;
  }
  @media (prefers-reduced-motion: reduce) {
    .spinner {
      animation-duration: 2.4s;
    }
  }
  @keyframes spin {
    to {
      transform: rotate(360deg);
    }
  }
}

Field

label · hint · error · required — wires one control

Shown to everyone you invite.

That handle is taken.

Letters, numbers, and dashes.

Children is a function that receives exactly the attributes the control must carry: its id, and aria-describedby, aria-invalid, and required when they apply. Spread them and the label, hint, and error are wired; there is nothing to remember.

The error is announced first, before the hint, and the required mark is visual only: required on the control is the announcement.

Sourcecomponents/forms/field/doc.ts · components/forms/field/field.tsx · components/forms/label/doc.ts · components/forms/label/label.tsx

components/forms/field/doc.ts

/**
 * Field — a label, a hint, and an error around one control.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     label       content, REQUIRED
 *     hint?       content — always shown when given
 *     error?      content — a validation message
 *     required?   boolean
 *     children    a FUNCTION, called with the props the control must carry
 *
 * The function receives exactly the attributes to spread onto the control:
 *
 *     id                 the identifier the label points at
 *     aria-describedby?  the error and hint identifiers, or absent
 *     aria-invalid?      true, or absent
 *     required?          true, or absent
 *
 * # Behaviour
 *
 * R1  The label is associated with the control by identifier. Identifiers are
 *     unique per instance: two Fields with the same label never collide.
 * R2  `aria-describedby` names the error FIRST and the hint second, because a
 *     reader announces them in that order and the error is more urgent. With
 *     neither, the attribute is absent — never empty.
 * R3  `aria-invalid` and `required` are present only when true. Absent, never
 *     false: `aria-invalid="false"` is a different announcement.
 * R4  A required field shows a mark after its label, hidden from assistive
 *     technology; `required` on the control is the announcement.
 * R5  The error renders directly under the control, the hint under that.
 *
 * # Deliberately absent
 *
 * The control's value, name, and change handler, and validation. Field owns
 * the three things around a control, not its data; the error is a string the
 * caller supplies from wherever it comes from.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § MECHANICS — NOT the oracle.                                             │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * Why children is a function: a Field taking a node would have to reach into
 * the child to wire it, which breaks silently when the control is nested one
 * level deeper. A function cannot be forgotten the same way — there is nothing
 * to render without calling it — and its argument arrives already named as the
 * attributes it becomes. Svelte uses a snippet with a parameter for the same
 * reason. Identifiers come from the framework's own generator (useId,
 * $props.id) because they must match between server render and hydration.
 */
export {};

components/forms/field/field.tsx

import { useId, type ReactNode } from "react";

import { cn } from "@/lib/utils/cn";
import { Label } from "../label";
import styles from "./field.module.css";

/** Exactly the attributes the control must carry, named as the attributes
 *  they become, so a caller spreads them and is done. */
export type FieldControlProps = {
  id: string;
  "aria-describedby"?: string;
  "aria-invalid"?: true;
  required?: true;
};

export type FieldProps = {
  label: ReactNode;
  hint?: ReactNode;
  error?: ReactNode;
  required?: boolean;
  className?: string;
  /** Called with the props the control must carry; see doc.ts. */
  children: (control: FieldControlProps) => ReactNode;
};

export function Field({
  label,
  hint,
  error,
  required,
  className,
  children,
}: FieldProps) {
  // Unique per instance and stable across a server render and hydration.
  const id = useId();
  const hintId = hint ? `${id}-hint` : undefined;
  const errorId = error ? `${id}-error` : undefined;
  // Error first: it is announced first and it is the more urgent.
  const describedBy = [errorId, hintId].filter(Boolean).join(" ") || undefined;

  return (
    <div className={cn(styles.root, className)}>
      <Label htmlFor={id}>
        {label}
        {/* Visual only: `required` on the control is the announcement. */}
        {required ? (
          <span className={styles.required} aria-hidden="true">
            *
          </span>
        ) : null}
      </Label>
      {children({
        id,
        "aria-describedby": describedBy,
        // Absent, never false: aria-invalid="false" is its own announcement.
        "aria-invalid": error ? true : undefined,
        required: required || undefined,
      })}
      {error ? (
        <p id={errorId} className={styles.error}>
          {error}
        </p>
      ) : null}
      {hint ? (
        <p id={hintId} className={styles.hint}>
          {hint}
        </p>
      ) : null}
    </div>
  );
}

components/forms/label/doc.ts

/**
 * Label — the name of a control.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     variant?   "field" | "inline"          default "field"
 *     …every attribute of a label, and a ref
 *
 * # Behaviour
 *
 * R1  Clicking the label focuses or activates its control, including controls
 *     the platform does not associate with a label natively (a checkbox,
 *     radio, switch, or select trigger rendered as a button).
 * R2  `field` names a field and sits above its control, in strong weight.
 * R3  `inline` wraps a choice control and its text on one line, in regular
 *     weight, with a pointer cursor; when the control inside is disabled, the
 *     whole label fades and shows a not-allowed cursor.
 * R4  Text is `--text-control`, the same size as the text inside controls.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § MECHANICS — NOT the oracle.                                             │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * The one labelling implementation in the tree, so R1's click-to-focus exists
 * once. R3's fade reads the disabled state of the control inside with `:has`.
 */
export {};

components/forms/label/label.tsx

"use client";

import * as Primitive from "@radix-ui/react-label";
import type { ComponentPropsWithRef } from "react";

import { cn } from "@/lib/utils/cn";
import { labelVariants, type LabelVariants } from "./label.variants";

export type LabelProps = ComponentPropsWithRef<typeof Primitive.Root> &
  LabelVariants;

/** The one labelling implementation. The primitive adds click-to-focus for
 *  controls the platform does not associate natively (the checkbox, radio,
 *  switch, and select triggers are buttons), which should exist once. */
export function Label({ variant, className, ...props }: LabelProps) {
  return (
    <Primitive.Root
      {...props}
      className={cn(labelVariants({ variant }), className)}
    />
  );
}

Fieldset

legend · hint · error — one question, several controls

Notify me

You can change this later.

Permissions

Choose at least one permission.

Sourcecomponents/forms/fieldset/doc.ts · components/forms/fieldset/fieldset.tsx · components/forms/fieldset/fieldset.module.css

components/forms/fieldset/doc.ts

/**
 * Fieldset — a group of controls that answer one question.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     legend     content, REQUIRED — the question
 *     hint?      content
 *     error?     content
 *     children   the controls
 *     …every attribute of a fieldset, and a ref
 *
 * # Behaviour
 *
 * R1  The legend is announced before every control inside the group.
 * R2  The hint follows the legend (it qualifies the question); the error
 *     follows the controls (it is about the answer). The group is described by
 *     the error first, then the hint, as with Field.
 * R3  No border, padding, or margin: a group sits in a form like any other
 *     field. Put it on a surface when it needs a frame.
 * R4  It never forces its parent wider than the parent allows.
 *
 * # Use it for
 *
 * A radio group, a set of related checkboxes, a date split across inputs —
 * any time one question has several controls.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § MECHANICS — NOT the oracle.                                             │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * The platform fieldset defaults to min-content width, which breaks grids;
 * R4 is `min-width: 0`. The platform's own border, padding, and legend
 * position are reset for R3. Unlike Flover's Fieldset, it is unframed by
 * default, because Bento's presets already frame surfaces with Box.
 */
export {};

components/forms/fieldset/fieldset.tsx

import { useId, type ComponentPropsWithRef, type ReactNode } from "react";

import { cn } from "@/lib/utils/cn";
import styles from "./fieldset.module.css";

export type FieldsetProps = Omit<ComponentPropsWithRef<"fieldset">, "title"> & {
  /** Names the group; announced before every control inside it. */
  legend: ReactNode;
  hint?: ReactNode;
  error?: ReactNode;
  children: ReactNode;
};

/** A group of controls that answer one question. The counterpart to Field: a
 *  single control receives the wiring, a group keeps it on the container, so
 *  children are plain nodes here. */
export function Fieldset({
  legend,
  hint,
  error,
  className,
  children,
  ...props
}: FieldsetProps) {
  const id = useId();
  const hintId = hint ? `${id}-hint` : undefined;
  const errorId = error ? `${id}-error` : undefined;
  const describedBy = [errorId, hintId].filter(Boolean).join(" ") || undefined;

  return (
    <fieldset
      {...props}
      aria-describedby={describedBy}
      data-invalid={error ? "" : undefined}
      className={cn(styles.root, className)}
    >
      <legend className={styles.legend}>{legend}</legend>
      {/* A hint qualifies the question and belongs with it. */}
      {hint ? (
        <p id={hintId} className={styles.hint}>
          {hint}
        </p>
      ) : null}
      <div className={styles.body}>{children}</div>
      {/* An error is about the answer and belongs after it. */}
      {error ? (
        <p id={errorId} className={styles.error}>
          {error}
        </p>
      ) : null}
    </fieldset>
  );
}

components/forms/fieldset/fieldset.module.css

@layer primitive {
  .root {
    display: grid;
    align-content: start;
    /* A fieldset defaults to min-content width and breaks grids. */
    min-width: 0;
    margin: 0;
    padding: 0;
    gap: var(--space-4);
    border: 0;
  }
  /* A legend is laid out outside the fieldset's grid, so the gap does not
     reach it; its own margin sets the space below it. */
  .legend {
    margin-bottom: var(--space-3);
    padding: 0;
    color: var(--ink);
    font-size: var(--text-control, var(--text-13));
    font-weight: var(--weight-strong);
  }
  .body {
    display: grid;
    gap: var(--space-4);
  }
  .hint,
  .error {
    font-size: var(--text-12);
    line-height: var(--leading-snug);
  }
  .hint {
    color: var(--ink-3);
  }
  .error {
    color: var(--crit);
  }
}

Input and Textarea

size · mono · read-only · invalid · disabled

size
states
textarea
Sourcecomponents/forms/input/doc.ts · components/forms/input/input.tsx · components/forms/input/textarea.tsx · components/forms/input/input.variants.ts · components/forms/input/input.module.css

components/forms/input/doc.ts

/**
 * Input and Textarea — text the user types.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     size?   "sm" | "md" | "lg"       default "md"
 *     mono?   boolean                  default false
 *     …every attribute of an input except its native `size`, and a ref
 *
 * # Behaviour
 *
 * R1  Renders one native input that fills its parent's width.
 * R2  Height is exactly the size's control token (forms R1), so an Input and a
 *     Button of the same size line up. Inline padding is `--space-4`,
 *     `--space-5`, or `--space-6`.
 * R3  `mono` sets the monospace family with tabular figures, for identifiers,
 *     keys, and codes.
 * R4  The border darkens on hover. A read-only input sits on `--surface-sunk`
 *     so it does not look editable.
 * R5  `aria-invalid="true"` turns the border and the focus ring `--crit`, and
 *     the invalid border holds while the pointer is over the field.
 * R6  The value is the caller's: uncontrolled, or controlled through the
 *     framework's usual binding.
 *
 * # Textarea
 *
 * The same control with rows (default 4). It shares every rule above except
 * R2: its height follows its rows (at least two md control heights) and it can
 * be resized vertically; the size sets its padding.
 *
 * # Deliberately absent
 *
 * A label, hint, or error (those belong to Field), and prefix and suffix
 * slots.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § MECHANICS — NOT the oracle.                                             │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * The native `size` attribute (a width in characters) is omitted so `size`
 * can mean the control size. Hover is written inside `:where()` so it adds no
 * specificity and R5's invalid border wins over it. React takes `value` and
 * `onChange` as usual; Svelte's `value` is `$bindable`, for `bind:value`.
 */
export {};

components/forms/input/input.tsx

import type { ComponentPropsWithRef } from "react";

import { cn } from "@/lib/utils/cn";
import { inputVariants, type InputVariants } from "./input.variants";

export type InputProps = Omit<ComponentPropsWithRef<"input">, "size"> &
  InputVariants;

export function Input({ className, size, mono, ...props }: InputProps) {
  return (
    <input
      {...props}
      className={cn(inputVariants({ size, mono }), className)}
    />
  );
}

components/forms/input/textarea.tsx

import type { ComponentPropsWithRef } from "react";

import { cn } from "@/lib/utils/cn";
import { inputVariants, type InputVariants } from "./input.variants";

export type TextareaProps = ComponentPropsWithRef<"textarea"> & InputVariants;

/** The same control with rows. It shares Input's variant map, so the two
 *  cannot drift apart in border, focus, hover, or invalid styling. */
export function Textarea({
  className,
  size,
  mono,
  rows = 4,
  ...props
}: TextareaProps) {
  return (
    <textarea
      {...props}
      rows={rows}
      className={cn(inputVariants({ size, mono, multiline: true }), className)}
    />
  );
}

components/forms/input/input.variants.ts

import { cva, type VariantProps } from "class-variance-authority";

import styles from "./input.module.css";

export const inputVariants = cva(styles.root, {
  variants: {
    size: { sm: styles.sm, md: styles.md, lg: styles.lg },
    mono: { true: styles.mono },
    // Set by Textarea only.
    multiline: { true: styles.multiline },
  },
  defaultVariants: { size: "md" },
});

export type InputVariants = Omit<
  VariantProps<typeof inputVariants>,
  "multiline"
>;

components/forms/input/input.module.css

@layer primitive {
  .root {
    box-sizing: border-box;
    width: 100%;
    /* Height comes from the size token, never from font metrics, so an input
       and a button of the same size line up in a row. */
    padding-block: 0;
    border: 1px solid var(--line-strong);
    border-radius: var(--radius-2);
    background: var(--surface-panel);
    color: var(--ink);
    font: inherit;
    font-size: var(--text-control, var(--text-13));
    line-height: normal;
    transition:
      border-color var(--dur-2) var(--ease),
      background-color var(--dur-2) var(--ease);
  }

  .sm {
    height: var(--control-sm);
    padding-inline: var(--space-4);
  }
  .md {
    height: var(--control-md);
    padding-inline: var(--space-5);
  }
  .lg {
    height: var(--control-lg);
    padding-inline: var(--space-6);
  }
  /* Textarea: the size sets padding and text, and the height follows the rows
     instead of the control token. Written after the sizes so it wins. */
  .multiline {
    height: auto;
    min-height: calc(var(--control-md) * 2);
    padding-block: var(--space-4);
    line-height: var(--leading-body);
    resize: vertical;
  }
  .mono {
    font-family: var(--font-mono);
    font-variant-numeric: tabular-nums;
  }

  .root::placeholder {
    color: var(--ink-3);
  }
  /* :where keeps hover at the specificity of .root, so the invalid border
     below still wins while the pointer is over the field. */
  .root:where(:hover:not(:disabled, :focus-visible)) {
    border-color: var(--line-heavy);
  }
  .root:read-only:not(:disabled) {
    background: var(--surface-sunk);
  }
  .root:focus-visible {
    outline: 2px solid var(--accent);
    outline-offset: 2px;
  }
  .root[aria-invalid="true"] {
    border-color: var(--crit);
  }
  .root[aria-invalid="true"]:focus-visible {
    outline-color: var(--crit);
  }
  .root:disabled {
    opacity: 0.45;
    cursor: not-allowed;
  }
}

Checkbox

unchecked · checked · indeterminate · invalid · disabled

states

Indeterminate is a state, not a style. It comes from the data — some of a group are selected — and activating it checks it.

Sourcecomponents/forms/checkbox/doc.ts · components/forms/checkbox/checkbox.tsx · components/forms/checkbox/checkbox.module.css

components/forms/checkbox/doc.ts

/**
 * Checkbox — one yes/no choice that is submitted with a form.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     checked?           true | false | "indeterminate"   (controlled)
 *     defaultChecked?    the same                         (uncontrolled)
 *     onCheckedChange?   called with the new state
 *     disabled?, required?, name?, value?
 *
 * # Behaviour
 *
 * R1  A 16px box: bordered when unchecked, filled with `--fill` and a tick when
 *     checked, filled with a dash when indeterminate.
 * R2  Indeterminate is a state from the data ("some of these are selected"),
 *     never a style choice. Activating it checks it.
 * R3  Space toggles it; it is announced as a checkbox with its state.
 * R4  `aria-invalid="true"` turns the border `--crit`.
 * R5  Inside a form it submits `value` (default "on") under `name` when
 *     checked, like a native checkbox.
 * R6  It is labelled by a Label: `inline` around it, or a Field around it.
 *
 * # Checkbox or Switch?
 *
 * A checkbox is part of a form that is submitted. A switch takes effect the
 * moment it changes.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § MECHANICS — NOT the oracle.                                             │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * The primitive renders a button with role="checkbox" and, inside a form, a
 * hidden native input so R5 holds. Both glyphs render and the state attribute
 * hides the wrong one, so they can never both show.
 */
export {};

components/forms/checkbox/checkbox.tsx

"use client";

import * as Primitive from "@radix-ui/react-checkbox";
import type { ComponentPropsWithRef } from "react";

import { Check, Minus } from "@/components/utility/icon";
import { cn } from "@/lib/utils/cn";
import styles from "./checkbox.module.css";

export type CheckboxProps = ComponentPropsWithRef<typeof Primitive.Root>;

/** `checked` may be true, false, or "indeterminate": a state that comes from
 *  the data (some children selected), not a variant the author picks. */
export function Checkbox({ className, ...props }: CheckboxProps) {
  return (
    <Primitive.Root {...props} className={cn(styles.root, className)}>
      <Primitive.Indicator className={styles.indicator}>
        <Check
          className={cn(styles.glyph, styles.check)}
          strokeWidth={3}
          aria-hidden="true"
        />
        <Minus
          className={cn(styles.glyph, styles.mixed)}
          strokeWidth={3}
          aria-hidden="true"
        />
      </Primitive.Indicator>
    </Primitive.Root>
  );
}

components/forms/checkbox/checkbox.module.css

@layer primitive {
  .root {
    display: inline-grid;
    width: 16px;
    height: 16px;
    flex: none;
    place-items: center;
    padding: 0;
    border: 1px solid var(--line-heavy);
    border-radius: var(--radius-1);
    background: var(--surface-panel);
    color: var(--fill-ink);
    cursor: pointer;
    transition:
      background-color var(--dur-2) var(--ease),
      border-color var(--dur-2) var(--ease);
  }
  .root:where(:hover:not(:disabled)) {
    border-color: var(--ink-3);
  }
  .root[data-state="checked"],
  .root[data-state="indeterminate"] {
    border-color: var(--fill);
    background: var(--fill);
  }
  .root[aria-invalid="true"] {
    border-color: var(--crit);
  }
  .root:focus-visible {
    outline: 2px solid var(--accent);
    outline-offset: 2px;
  }
  .root:disabled {
    opacity: 0.45;
    cursor: not-allowed;
  }
  .indicator {
    display: grid;
    place-items: center;
  }
  .glyph {
    width: 12px;
    height: 12px;
  }
  /* The state decides which glyph shows, so both can never appear at once. */
  .root[data-state="checked"] .mixed,
  .root[data-state="indeterminate"] .check {
    display: none;
  }
}

RadioGroup

one choice; arrows move, Tab leaves

vertical

A group is one tab stop. Arrow keys move the selection; name the group with a Fieldset legend.

Sourcecomponents/forms/radio-group/doc.ts · components/forms/radio-group/radio-group.tsx · components/forms/radio-group/radio-group.module.css

components/forms/radio-group/doc.ts

/**
 * RadioGroup and Radio — one choice from a short list.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     RadioGroup   value?, defaultValue?, onValueChange?, name?, required?,
 *                  disabled?, orientation?
 *     Radio        value, REQUIRED; disabled?
 *
 * # Behaviour
 *
 * R1  Exactly one option can be selected. Arrow keys move the selection
 *     between options; Tab enters and leaves the group as one stop.
 * R2  Each Radio is a 16px circle with an 8px `--fill` dot when selected.
 * R3  Options stack with a `--space-4` gap.
 * R4  The group is named by a Fieldset legend, and each option by an inline
 *     Label around it.
 * R5  Inside a form the selected value submits under `name`.
 *
 * # RadioGroup or Select?
 *
 * Up to about five options that should all be visible: a radio group. More,
 * or where space is short: a select.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § MECHANICS — NOT the oracle.                                             │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * The primitive supplies roving focus (R1) and the hidden input for R5.
 */
export {};

components/forms/radio-group/radio-group.tsx

"use client";

import * as Primitive from "@radix-ui/react-radio-group";
import type { ComponentPropsWithRef } from "react";

import { cn } from "@/lib/utils/cn";
import styles from "./radio-group.module.css";

export type RadioGroupProps = ComponentPropsWithRef<typeof Primitive.Root>;
export type RadioProps = ComponentPropsWithRef<typeof Primitive.Item>;

/** Owns the value and arrow-key navigation. It does not own the question:
 *  that is a Fieldset legend, announced before every option. */
export function RadioGroup({ className, ...props }: RadioGroupProps) {
  return <Primitive.Root {...props} className={cn(styles.group, className)} />;
}

export function Radio({ className, ...props }: RadioProps) {
  return (
    <Primitive.Item {...props} className={cn(styles.radio, className)}>
      <Primitive.Indicator className={styles.indicator} />
    </Primitive.Item>
  );
}

components/forms/radio-group/radio-group.module.css

@layer primitive {
  .group {
    display: grid;
    gap: var(--space-4);
  }
  .radio {
    display: inline-grid;
    width: 16px;
    height: 16px;
    flex: none;
    place-items: center;
    padding: 0;
    border: 1px solid var(--line-heavy);
    border-radius: var(--radius-pill);
    background: var(--surface-panel);
    cursor: pointer;
    transition: border-color var(--dur-2) var(--ease);
  }
  .radio:where(:hover:not(:disabled)) {
    border-color: var(--ink-3);
  }
  .radio[data-state="checked"] {
    border-color: var(--fill);
  }
  .radio:focus-visible {
    outline: 2px solid var(--accent);
    outline-offset: 2px;
  }
  .radio:disabled {
    opacity: 0.45;
    cursor: not-allowed;
  }
  .indicator {
    width: 8px;
    height: 8px;
    border-radius: var(--radius-pill);
    background: var(--fill);
  }
}

Switch

takes effect immediately

states

Switch or checkbox? A switch changes a setting now. Anything that waits for a Save button is a checkbox.

Sourcecomponents/forms/switch/doc.ts · components/forms/switch/switch.tsx · components/forms/switch/switch.module.css

components/forms/switch/doc.ts

/**
 * Switch — a setting that takes effect immediately.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     checked?, defaultChecked?, onCheckedChange?, disabled?, name?, value?
 *
 * # Behaviour
 *
 * R1  A 32×18 track with a 12px thumb: `--surface-sunk` and a muted thumb when
 *     off, `--fill` with a `--fill-ink` thumb on the right when on.
 * R2  Announced as a switch, "on" or "off" — not "checked".
 * R3  Space and Enter toggle it.
 * R4  It changes a setting now. Anything that waits for a Save button is a
 *     Checkbox.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § MECHANICS — NOT the oracle.                                             │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * The thumb moves with a transform so the transition is cheap; the motion
 * tokens shorten it under reduced motion.
 */
export {};

components/forms/switch/switch.tsx

"use client";

import * as Primitive from "@radix-ui/react-switch";
import type { ComponentPropsWithRef } from "react";

import { cn } from "@/lib/utils/cn";
import styles from "./switch.module.css";

export type SwitchProps = ComponentPropsWithRef<typeof Primitive.Root>;

/** An immediate on/off setting. Announced as a switch, so it reads "on/off"
 *  rather than "checked"; see doc.ts for when a checkbox is right instead. */
export function Switch({ className, ...props }: SwitchProps) {
  return (
    <Primitive.Root {...props} className={cn(styles.root, className)}>
      <Primitive.Thumb className={styles.thumb} />
    </Primitive.Root>
  );
}

components/forms/switch/switch.module.css

@layer primitive {
  .root {
    position: relative;
    display: inline-flex;
    width: 32px;
    height: 18px;
    flex: none;
    align-items: center;
    padding: 0;
    border: 1px solid var(--line-heavy);
    border-radius: var(--radius-pill);
    background: var(--surface-sunk);
    cursor: pointer;
    transition:
      background-color var(--dur-2) var(--ease),
      border-color var(--dur-2) var(--ease);
  }
  .root[data-state="checked"] {
    border-color: var(--fill);
    background: var(--fill);
  }
  .root:focus-visible {
    outline: 2px solid var(--accent);
    outline-offset: 2px;
  }
  .root:disabled {
    opacity: 0.45;
    cursor: not-allowed;
  }
  .thumb {
    display: block;
    width: 12px;
    height: 12px;
    border-radius: var(--radius-pill);
    background: var(--ink-3);
    transform: translateX(2px);
    transition:
      transform var(--dur-2) var(--ease),
      background-color var(--dur-2) var(--ease);
  }
  .root[data-state="checked"] .thumb {
    background: var(--fill-ink);
    transform: translateX(16px);
  }
}

Select

size · placeholder · invalid · disabled

states
lined up

A select holds a value and shows it. A menu runs an action and forgets it. They look alike and are announced differently, so a list of actions belongs in a menu.

Sourcecomponents/forms/select/doc.ts · components/forms/select/select.tsx · components/forms/select/select.variants.ts · components/forms/select/select.module.css

components/forms/select/doc.ts

/**
 * Select — one value from a list, bound to a form.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     Select          value?, defaultValue?, onValueChange?, name?,
 *                     required?, disabled?
 *     SelectTrigger   size? ("sm" | "md" | "lg"), placeholder?, and the
 *                     attributes a Field hands down (id, aria-*)
 *     SelectContent   the list
 *     SelectItem      value, REQUIRED; disabled?; children are its label
 *
 * # Behaviour
 *
 * R1  The trigger is exactly the size's control token tall and padded like an
 *     Input (forms R1), so a select and an input line up.
 * R2  The trigger always reads back the chosen item's label, or the
 *     placeholder in `--ink-3` when nothing is chosen.
 * R3  The list floats on the shared elevated surface, at least as wide as the
 *     trigger, and is never clipped by a scrolling or overflow-hidden parent.
 * R4  The highlighted item follows the pointer AND the keyboard. The selected
 *     item shows a tick.
 * R5  Keyboard: arrows move, typing jumps to a matching item, Enter or Space
 *     selects, Escape closes without changing the value.
 * R6  `aria-invalid="true"` on the trigger turns its border and focus ring
 *     `--crit`.
 *
 * # Select or menu?
 *
 * A select holds a value and shows it. A menu runs an action and forgets it.
 * They look alike and are announced differently; a menu of actions built from
 * a Select tells a screen reader there is a value that does not exist.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § MECHANICS — NOT the oracle.                                             │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * The content is portalled (R3) and positioned as a popper below the
 * trigger. `max-height` follows the space the primitive reports as available,
 * capped at 320px. The stylesheet differs between frameworks only in those
 * variable names: `--radix-select-*` in React, `--bits-select-*` in Svelte.
 *
 * R2 in Svelte: bits-ui mounts the items only while the list is open, so a
 * closed trigger can find the chosen label only in the `items` passed to
 * `Select` (`{ value, label }[]`). Without them it reads back the raw value.
 * Radix collects item labels while closed, so React needs no `items`.
 *
 * The trigger is announced as a combobox (the select-only combobox pattern) in
 * both frameworks. Radix sets the role; bits-ui does not, so the Svelte
 * trigger sets it.
 */
export {};

components/forms/select/select.tsx

"use client";

import * as Primitive from "@radix-ui/react-select";
import type { ComponentPropsWithRef } from "react";

import { Check, ChevronDown } from "@/components/utility/icon";
import { cn } from "@/lib/utils/cn";
import surface from "../../surface.module.css";
import styles from "./select.module.css";
import {
  selectTriggerVariants,
  type SelectTriggerVariants,
} from "./select.variants";

/* A value bound to a form, not a menu of actions. The two look alike and are
   announced differently; see doc.ts. */

export type SelectProps = ComponentPropsWithRef<typeof Primitive.Root>;
export type SelectTriggerProps = Omit<
  ComponentPropsWithRef<typeof Primitive.Trigger>,
  "children"
> &
  SelectTriggerVariants & {
    /** Shown until a value is chosen. */
    placeholder?: string;
  };
export type SelectContentProps = ComponentPropsWithRef<
  typeof Primitive.Content
>;
export type SelectItemProps = ComponentPropsWithRef<typeof Primitive.Item>;

export function Select(props: SelectProps) {
  return <Primitive.Root {...props} />;
}

/** The closed control. It always reads back the chosen value, which is what
 *  distinguishes a select from a menu. */
export function SelectTrigger({
  size,
  placeholder,
  className,
  ...props
}: SelectTriggerProps) {
  return (
    <Primitive.Trigger
      {...props}
      className={cn(selectTriggerVariants({ size }), className)}
    >
      <span className={styles.value}>
        <Primitive.Value placeholder={placeholder} />
      </span>
      <Primitive.Icon asChild>
        <ChevronDown className={styles.chevron} aria-hidden="true" />
      </Primitive.Icon>
    </Primitive.Trigger>
  );
}

export function SelectContent({
  className,
  position = "popper",
  sideOffset = 6,
  children,
  ...props
}: SelectContentProps) {
  return (
    // Portalled, so an overflow-hidden ancestor cannot clip the list.
    <Primitive.Portal>
      <Primitive.Content
        {...props}
        position={position}
        sideOffset={sideOffset}
        className={cn(surface.elevated, styles.content, className)}
      >
        <Primitive.Viewport className={styles.viewport}>
          {children}
        </Primitive.Viewport>
      </Primitive.Content>
    </Primitive.Portal>
  );
}

export function SelectItem({ className, children, ...props }: SelectItemProps) {
  return (
    <Primitive.Item {...props} className={cn(styles.item, className)}>
      <Primitive.ItemText>{children}</Primitive.ItemText>
      <Primitive.ItemIndicator>
        <Check className={styles.tick} strokeWidth={2.4} aria-hidden="true" />
      </Primitive.ItemIndicator>
    </Primitive.Item>
  );
}

components/forms/select/select.variants.ts

import { cva, type VariantProps } from "class-variance-authority";

import styles from "./select.module.css";

export const selectTriggerVariants = cva(styles.trigger, {
  variants: {
    size: { sm: styles.sm, md: styles.md, lg: styles.lg },
  },
  defaultVariants: { size: "md" },
});

export type SelectTriggerVariants = VariantProps<typeof selectTriggerVariants>;

components/forms/select/select.module.css

@layer primitive {
  .trigger {
    display: inline-flex;
    box-sizing: border-box;
    width: 100%;
    align-items: center;
    justify-content: space-between;
    gap: var(--space-4);
    padding-block: 0;
    border: 1px solid var(--line-strong);
    border-radius: var(--radius-2);
    background: var(--surface-panel);
    color: var(--ink);
    font: inherit;
    font-size: var(--text-control, var(--text-13));
    line-height: normal;
    text-align: start;
    cursor: pointer;
    transition: border-color var(--dur-2) var(--ease);
  }
  /* Same heights and padding as Input, so a select and an input of one size
     line up (forms R1). */
  .sm {
    height: var(--control-sm);
    padding-inline: var(--space-4);
  }
  .md {
    height: var(--control-md);
    padding-inline: var(--space-5);
  }
  .lg {
    height: var(--control-lg);
    padding-inline: var(--space-6);
  }
  .trigger:where(:hover:not(:disabled, :focus-visible)) {
    border-color: var(--line-heavy);
  }
  .trigger[data-placeholder] {
    color: var(--ink-3);
  }
  .trigger:focus-visible {
    outline: 2px solid var(--accent);
    outline-offset: 2px;
  }
  .trigger[aria-invalid="true"] {
    border-color: var(--crit);
  }
  .trigger[aria-invalid="true"]:focus-visible {
    outline-color: var(--crit);
  }
  .trigger:disabled {
    opacity: 0.45;
    cursor: not-allowed;
  }
  .value {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }
  .chevron {
    width: 14px;
    height: 14px;
    flex: none;
    color: var(--ink-3);
  }

  .content {
    z-index: var(--z-palette);
    min-width: var(--radix-select-trigger-width);
    max-height: min(320px, var(--radix-select-content-available-height));
  }
  .viewport {
    padding: var(--space-2);
  }
  .item {
    display: flex;
    min-height: var(--control-md);
    align-items: center;
    justify-content: space-between;
    gap: var(--space-5);
    padding-inline: var(--space-5);
    border-radius: var(--radius-1);
    color: var(--ink-2);
    font-size: var(--text-control, var(--text-13));
    cursor: pointer;
    outline: none;
    user-select: none;
  }
  /* The primitive sets data-highlighted for pointer AND keyboard; a :hover
     rule alone would leave keyboard users with no position. */
  .item[data-highlighted] {
    background: var(--surface-hover-2);
    color: var(--ink);
  }
  .item[data-state="checked"] {
    color: var(--ink);
    font-weight: var(--weight-medium);
  }
  .item[data-disabled] {
    opacity: 0.45;
    cursor: not-allowed;
  }
  .tick {
    width: 14px;
    height: 14px;
    flex: none;
    color: var(--accent);
  }
}

SegmentedControl

a radio group as segments · icons · sizes · full width

View: board

For choices that apply at once. It is a radio group: one tab stop, and the arrows move and select. A choice that waits for Save belongs in a RadioGroup or a Select.

Sourcecomponents/forms/segmented-control/doc.ts · components/forms/segmented-control/segmented-control.tsx · components/forms/segmented-control/segmented-control.module.css

components/forms/segmented-control/doc.ts

/**
 * SegmentedControl — one of a few options, all visible, taking effect now.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     label         REQUIRED, names the choice
 *     segments      { value, label, icon?, iconOnly?, disabled? }[]
 *     value?, defaultValue?, onValueChange?, size?, full?, disabled?
 *
 * # Behaviour
 *
 * R1  A radio group: one tab stop, arrow keys move AND select. It is for
 *     choices that apply at once (a view, a range); a choice saved later
 *     belongs in a RadioGroup or Select.
 * R2  An icon-only segment is named by its label.
 * R3  It is exactly its control size tall, like every sized control.
 */
export {};

components/forms/segmented-control/segmented-control.tsx

"use client";

import * as Primitive from "@radix-ui/react-radio-group";

import type { LucideIcon } from "@/components/utility/icon";
import { cn } from "@/lib/utils/cn";
import styles from "./segmented-control.module.css";

export type Segment = {
  value: string;
  label: string;
  icon?: LucideIcon;
  /** Show only the icon; the label becomes its name. */
  iconOnly?: boolean;
  disabled?: boolean;
};

export type SegmentedControlProps = {
  /** Names the choice. */
  label: string;
  segments: readonly Segment[];
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  size?: "sm" | "md" | "lg";
  /** Stretch to the container, segments sharing the width. */
  full?: boolean;
  disabled?: boolean;
  className?: string;
};

/** One of a few options, all visible, taking effect at once: a view, a
 *  range, a mode. It is a radio group — one tab stop, arrows move and
 *  select — styled as segments. */
export function SegmentedControl({
  label,
  segments,
  size = "md",
  full = false,
  className,
  ...props
}: SegmentedControlProps) {
  return (
    <Primitive.Root
      {...props}
      aria-label={label}
      orientation="horizontal"
      data-size={size}
      data-full={full || undefined}
      className={cn(styles.root, className)}
    >
      {segments.map((segment) => {
        const Icon = segment.icon;
        return (
          <Primitive.Item
            key={segment.value}
            value={segment.value}
            disabled={segment.disabled}
            aria-label={segment.iconOnly ? segment.label : undefined}
            title={segment.iconOnly ? segment.label : undefined}
            className={styles.item}
          >
            {Icon ? <Icon aria-hidden="true" /> : null}
            {segment.iconOnly ? null : segment.label}
          </Primitive.Item>
        );
      })}
    </Primitive.Root>
  );
}

components/forms/segmented-control/segmented-control.module.css

@layer primitive {
  .root {
    display: inline-flex;
    height: var(--control-md);
    box-sizing: border-box;
    align-items: stretch;
    gap: 2px;
    padding: 2px;
    border: 1px solid var(--line);
    border-radius: var(--radius-2);
    background: var(--surface-sunk);
  }
  .root[data-size="sm"] {
    height: var(--control-sm);
  }
  .root[data-size="lg"] {
    height: var(--control-lg);
  }
  .root[data-full] {
    display: flex;
    width: 100%;
  }
  .item {
    display: inline-flex;
    flex: 1 0 auto;
    align-items: center;
    justify-content: center;
    gap: var(--space-3);
    padding: 0 var(--space-5);
    border: 0;
    border-radius: calc(var(--radius-2) - 2px);
    background: transparent;
    color: var(--ink-2);
    cursor: pointer;
    font: inherit;
    font-size: var(--text-control, var(--text-13));
    white-space: nowrap;
    transition:
      background-color var(--dur-2) var(--ease),
      color var(--dur-2) var(--ease);
  }
  .item svg {
    width: 15px;
    height: 15px;
  }
  .item:hover:not([data-disabled]) {
    color: var(--ink);
  }
  .item[data-state="checked"] {
    background: var(--surface-panel);
    box-shadow: var(--shadow);
    color: var(--ink);
    font-weight: var(--weight-medium);
  }
  .item:focus-visible {
    outline: 2px solid var(--accent);
    outline-offset: 0;
  }
  .item[data-disabled] {
    cursor: not-allowed;
    opacity: 0.45;
  }
}

Slider

single · range · formatted · disabled

Volume
Price
Disabled
Sourcecomponents/forms/slider/doc.ts · components/forms/slider/slider.tsx · components/forms/slider/hold.ts · components/forms/slider/slider.module.css

components/forms/slider/doc.ts

/**
 * Slider — a value, or a range, on a continuous scale.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     label         REQUIRED; shown above unless hideLabel
 *     value?, defaultValue?   a number, or [low, high] for a range
 *     onValueChange?, onValueCommit? (on release: save here)
 *     min?, max?, step?, formatValue?, disabled?
 *
 * # Behaviour
 *
 * R1  Each thumb is named (a range's "Minimum …" and "Maximum …") and
 *     announces its value as formatted.
 * R2  Arrows step, Page keys step by ten, Home and End jump to the ends.
 * R3  A range's thumbs cannot cross.
 * R4  For a value whose exact number matters, pair it with — or use — a
 *     NumberInput.
 */
export {};

components/forms/slider/slider.tsx

"use client";

import * as Primitive from "@radix-ui/react-slider";
import { useId, useState, type KeyboardEvent } from "react";

import { cn } from "@/lib/utils/cn";
import { holdThumb, pageBy } from "./hold";
import styles from "./slider.module.css";

type Value = number | readonly [number, number];

export type SliderProps<V extends Value> = {
  /** Names the slider; shown above it unless hideLabel. */
  label: string;
  hideLabel?: boolean;
  /** One number, or a pair for a range. */
  value?: V;
  defaultValue?: V;
  onValueChange?: (value: V) => void;
  /** Fires when the pointer lets go or a key is released: save here. */
  onValueCommit?: (value: V) => void;
  min?: number;
  max?: number;
  step?: number;
  /** How a value reads, printed and announced: "40%", "$250". */
  formatValue?: (value: number) => string;
  disabled?: boolean;
  className?: string;
};

const asArray = (v: Value | undefined) =>
  v === undefined ? undefined : typeof v === "number" ? [v] : [...v];

/** A value — or a range — on a continuous scale, where the exact number
 *  matters less than where it sits. Each thumb announces its value as
 *  formatted; arrows step, Page keys step by ten, Home and End jump. */
export function Slider<V extends Value>({
  label,
  hideLabel = false,
  value,
  defaultValue,
  onValueChange,
  onValueCommit,
  min = 0,
  max = 100,
  step = 1,
  formatValue = String,
  disabled,
  className,
}: SliderProps<V>) {
  const id = useId();
  const range = Array.isArray(value ?? defaultValue);
  const [local, setLocal] = useState<number[]>(
    asArray(value ?? defaultValue) ?? [min],
  );
  const current = asArray(value) ?? local;
  const out = (values: number[]) =>
    (range ? [values[0], values[1]] : values[0]) as unknown as V;
  const names = range ? [`Minimum ${label}`, `Maximum ${label}`] : [label];
  const set = (values: number[]) => {
    setLocal(values);
    onValueChange?.(out(values));
  };
  /* The primitive lets a Page key jump one thumb over the other (it sorts
     and swaps). Page keys are handled here, held a step short instead. */
  const page = (event: KeyboardEvent<HTMLSpanElement>, index: number) => {
    const by = pageBy(event.key, step);
    if (by === null) return;
    event.preventDefault();
    const next = holdThumb(current, index, current[index] + by, {
      min,
      max,
      step,
    });
    set(next);
    onValueCommit?.(out(next));
  };

  return (
    <div className={cn(styles.root, className)}>
      {hideLabel ? null : (
        <div className={styles.head}>
          <span id={`${id}-label`} className={styles.label}>
            {label}
          </span>
          <output className={styles.output} aria-hidden="true">
            {current.map(formatValue).join(" – ")}
          </output>
        </div>
      )}
      <Primitive.Root
        className={styles.slider}
        value={current}
        onValueChange={set}
        onValueCommit={(values) => onValueCommit?.(out(values))}
        min={min}
        max={max}
        step={step}
        minStepsBetweenThumbs={range ? 1 : undefined}
        disabled={disabled}
        aria-labelledby={hideLabel ? undefined : `${id}-label`}
      >
        <Primitive.Track className={styles.track}>
          <Primitive.Range className={styles.range} />
        </Primitive.Track>
        {current.map((v, index) => (
          <Primitive.Thumb
            key={index}
            className={styles.thumb}
            aria-label={names[index]}
            aria-valuetext={formatValue(v)}
            onKeyDown={(event) => page(event, index)}
          />
        ))}
      </Primitive.Root>
    </div>
  );
}

components/forms/slider/hold.ts

/* A slider thumb's next value. Pure, and shared by both apps. */

/** `values` with thumb `index` moved to `next`: inside the bounds, and — for
 *  a range — a step short of its neighbour, so thumbs never cross or swap. */
export function holdThumb(
  values: readonly number[],
  index: number,
  next: number,
  { min, max, step }: { min: number; max: number; step: number },
) {
  const low = index > 0 ? values[index - 1] + step : min;
  const high = index < values.length - 1 ? values[index + 1] - step : max;
  const held = [...values];
  held[index] = Math.min(high, Math.max(low, next));
  return held;
}

/** How far a Page key moves a thumb: ten steps, or null for other keys. */
export const pageBy = (key: string, step: number) =>
  key === "PageUp" ? 10 * step : key === "PageDown" ? -10 * step : null;

components/forms/slider/slider.module.css

@layer primitive {
  .root {
    display: grid;
    gap: var(--space-3);
  }
  .head {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: var(--space-4);
    font-size: var(--text-13);
  }
  .label {
    color: var(--ink);
    font-weight: var(--weight-medium);
  }
  .output {
    color: var(--ink-2);
    font-variant-numeric: tabular-nums;
  }
  .slider {
    position: relative;
    display: flex;
    height: var(--control-sm);
    align-items: center;
    touch-action: none;
    user-select: none;
  }
  .track {
    position: relative;
    height: 4px;
    flex: 1;
    overflow: hidden;
    border-radius: var(--radius-pill);
    background: var(--line-strong);
  }
  .range {
    position: absolute;
    height: 100%;
    background: var(--accent);
  }
  .thumb {
    display: block;
    width: 18px;
    height: 18px;
    border: 2px solid var(--accent);
    border-radius: 50%;
    background: var(--surface-panel);
    box-shadow: var(--shadow);
    cursor: grab;
    transition: transform var(--dur-1) var(--ease);
  }
  .thumb:hover {
    transform: scale(1.08);
  }
  .thumb:focus-visible {
    outline: 2px solid var(--accent);
    outline-offset: 2px;
  }
  .slider[data-disabled] {
    opacity: 0.45;
  }
  .slider[data-disabled] .thumb {
    cursor: not-allowed;
  }
}

NumberInput

a spin button · bounds · steps · units

1 to 500.

Size sm.

Type freely; it settles when you leave. Arrows step (Shift for ten), Page keys step by ten, Home and End jump to the bounds. On Enter or blur the value is clamped and rounded to the step; unreadable text reverts.

Sourcecomponents/forms/number-input/doc.ts · components/forms/number-input/number-input.tsx · components/forms/number-input/number.ts · components/forms/number-input/number-input.module.css

components/forms/number-input/doc.ts

/**
 * NumberInput — a number to type or step.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     value?, defaultValue?   number | null (empty)
 *     onValueChange?, min?, max?, step?, unit?, size?, disabled?
 *     and an input's attributes, so a Field can wire it
 *
 * # Behaviour
 *
 * R1  A spin button: announced with its value (and unit) and its bounds.
 * R2  Arrow keys step (Shift for ten); Page keys step by ten; Home and End
 *     jump to the bounds when there are bounds.
 * R3  Typing is free; the value settles on Enter or leaving the field —
 *     clamped to the bounds and rounded to the step. Unreadable text reverts.
 * R4  The steppers are pointer conveniences, out of the tab order, and are
 *     disabled at the bounds.
 * R5  It is exactly its control size tall.
 */
export {};

components/forms/number-input/number-input.tsx

"use client";

import {
  useState,
  type ComponentPropsWithRef,
  type KeyboardEvent,
} from "react";

import { Minus, Plus } from "@/components/utility/icon";
import { cn } from "@/lib/utils/cn";
import styles from "./number-input.module.css";
import { clampStep, keyStep, parseNumber } from "./number";

export type NumberInputProps = Omit<
  ComponentPropsWithRef<"input">,
  | "value"
  | "defaultValue"
  | "onChange"
  | "size"
  | "min"
  | "max"
  | "step"
  | "type"
> & {
  /** Null is empty. */
  value?: number | null;
  defaultValue?: number | null;
  onValueChange?: (value: number | null) => void;
  min?: number;
  max?: number;
  step?: number;
  /** Appended when the value is announced: "seats", "GB". */
  unit?: string;
  size?: "sm" | "md" | "lg";
};

/** A number to type or step: seats, a limit, a quantity. It is a spin
 *  button — arrows step (Shift for ten), Page keys step by ten, Home and End
 *  jump to the bounds — and it settles to a valid value when you leave it. */
export function NumberInput({
  value,
  defaultValue = null,
  onValueChange,
  min,
  max,
  step = 1,
  unit,
  size = "md",
  disabled,
  className,
  onBlur,
  onKeyDown,
  "aria-invalid": invalid,
  ...props
}: NumberInputProps) {
  const [local, setLocal] = useState<number | null>(defaultValue);
  const current = value !== undefined ? value : local;
  const [draft, setDraft] = useState<string | null>(null);
  const bounds = { min, max, step };

  const commit = (next: number | null) => {
    const settled = next === null ? null : clampStep(next, bounds);
    setDraft(null);
    if (value === undefined) setLocal(settled);
    if (settled !== current) onValueChange?.(settled);
  };
  const stepBy = (by: number) => commit((current ?? min ?? 0) + by);
  const settleDraft = () => {
    if (draft === null) return;
    const parsed = parseNumber(draft);
    if (parsed === "invalid") setDraft(null);
    else commit(parsed);
  };

  return (
    <div
      data-size={size}
      data-invalid={invalid === true || invalid === "true" ? "" : undefined}
      data-disabled={disabled ? "" : undefined}
      className={cn(styles.root, className)}
    >
      <button
        type="button"
        tabIndex={-1}
        className={styles.step}
        aria-label="Decrease"
        disabled={
          disabled || (min !== undefined && current !== null && current <= min)
        }
        onClick={() => stepBy(-step)}
      >
        <Minus aria-hidden="true" />
      </button>
      <input
        {...props}
        type="text"
        inputMode="decimal"
        role="spinbutton"
        autoComplete="off"
        disabled={disabled}
        aria-invalid={invalid}
        aria-valuenow={current ?? undefined}
        aria-valuemin={min}
        aria-valuemax={max}
        aria-valuetext={
          current === null ? "Empty" : `${current}${unit ? ` ${unit}` : ""}`
        }
        className={styles.input}
        value={draft ?? (current === null ? "" : String(current))}
        onChange={(event) => setDraft(event.target.value)}
        onBlur={(event) => {
          settleDraft();
          onBlur?.(event);
        }}
        onKeyDown={(event: KeyboardEvent<HTMLInputElement>) => {
          onKeyDown?.(event);
          if (event.key === "Enter") {
            settleDraft();
            return;
          }
          const change = keyStep(event.key, event.shiftKey, bounds);
          if (!change) return;
          event.preventDefault();
          if ("to" in change) commit(change.to);
          else stepBy(change.by);
        }}
      />
      <button
        type="button"
        tabIndex={-1}
        className={styles.step}
        aria-label="Increase"
        disabled={
          disabled || (max !== undefined && current !== null && current >= max)
        }
        onClick={() => stepBy(step)}
      >
        <Plus aria-hidden="true" />
      </button>
    </div>
  );
}

components/forms/number-input/number.ts

/* NumberInput's maths, shared by both apps. */

/** Decimals in a step, so 0.1 + 0.2 lands on 0.3, not 0.30000000000000004. */
const decimals = (step: number) => {
  const text = String(step);
  return text.includes(".") ? text.length - text.indexOf(".") - 1 : 0;
};

export function clampStep(
  value: number,
  { min, max, step }: { min?: number; max?: number; step: number },
) {
  let v = value;
  if (min !== undefined) v = Math.max(min, v);
  if (max !== undefined) v = Math.min(max, v);
  return Number(v.toFixed(decimals(step)));
}

/** A typed string as a number: blank is null, and so is anything unreadable
 *  (the field then reverts to its last good value). Commas are ignored. */
export function parseNumber(text: string): number | null | "invalid" {
  const trimmed = text.replace(/,/g, "").trim();
  if (!trimmed) return null;
  const n = Number(trimmed);
  return Number.isFinite(n) ? n : "invalid";
}

/** The change a key makes, or null for keys that are not steps. */
export function keyStep(
  key: string,
  shift: boolean,
  { min, max, step }: { min?: number; max?: number; step: number },
): { by: number } | { to: number } | null {
  const big = step * 10;
  switch (key) {
    case "ArrowUp":
      return { by: shift ? big : step };
    case "ArrowDown":
      return { by: -(shift ? big : step) };
    case "PageUp":
      return { by: big };
    case "PageDown":
      return { by: -big };
    case "Home":
      return min !== undefined ? { to: min } : null;
    case "End":
      return max !== undefined ? { to: max } : null;
    default:
      return null;
  }
}

components/forms/number-input/number-input.module.css

@layer primitive {
  /* A text field between two steppers, drawn as one control. */
  .root {
    display: inline-grid;
    width: 100%;
    box-sizing: border-box;
    grid-template-columns: auto minmax(0, 1fr) auto;
    align-items: stretch;
    overflow: hidden;
    border: 1px solid var(--line-strong);
    border-radius: var(--radius-2);
    background: var(--surface-panel);
  }
  .root[data-size="sm"] {
    height: var(--control-sm);
  }
  .root[data-size="md"] {
    height: var(--control-md);
  }
  .root[data-size="lg"] {
    height: var(--control-lg);
  }
  .root:focus-within {
    border-color: var(--accent);
    box-shadow: 0 0 0 1px var(--accent);
  }
  .root[data-invalid] {
    border-color: var(--crit);
  }
  .root[data-disabled] {
    opacity: 0.55;
  }
  .input {
    min-width: 0;
    padding: 0 var(--space-3);
    border: 0;
    background: transparent;
    color: var(--ink);
    font: inherit;
    font-size: var(--text-control, var(--text-13));
    font-variant-numeric: tabular-nums;
    text-align: center;
  }
  .input:focus {
    outline: none;
  }
  .step {
    display: grid;
    width: calc(var(--control-sm) - 2px);
    place-items: center;
    padding: 0;
    border: 0;
    background: transparent;
    color: var(--ink-2);
    cursor: pointer;
  }
  .step:first-child {
    border-right: 1px solid var(--line);
  }
  .step:last-child {
    border-left: 1px solid var(--line);
  }
  .step:hover:not(:disabled) {
    background: var(--surface-hover-2);
    color: var(--ink);
  }
  .step:disabled {
    color: var(--ink-4, var(--ink-3));
    cursor: not-allowed;
    opacity: 0.5;
  }
  .step svg {
    width: 14px;
    height: 14px;
  }
}

CopyButton

icon-only · labelled · announced

bnt_live_8f2a41c9e7
Sourcecomponents/forms/copy-button/doc.ts · components/forms/copy-button/copy-button.tsx

components/forms/copy-button/doc.ts

/**
 * CopyButton — copies text, and says so.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     value      text, or a function returning it at the moment of copying
 *     label?     the text, or the name when iconOnly; default "Copy"
 *     iconOnly?, size?, variant?, and a Button's other props
 *
 * # Behaviour
 *
 * R1  Copying shows a check (and "Copied") for two seconds, and announces
 *     "Copied" — or "Copy failed" — to assistive technology.
 * R2  An icon-only copy button is named by its label.
 */
export {};

components/forms/copy-button/copy-button.tsx

"use client";

import { useEffect, useRef, useState } from "react";

import { Button, type ButtonProps } from "@/components/forms/button";
import { Check, Copy } from "@/components/utility/icon";
import { VisuallyHidden } from "@/components/utility/visually-hidden";

export type CopyButtonProps = Omit<
  ButtonProps,
  "onClick" | "children" | "size" | "value"
> & {
  /** What to copy: text, or a function that returns it at the time. */
  value: string | (() => string);
  /** The button's text, or its name when icon-only. */
  label?: string;
  /** Just the icon; the label becomes its accessible name. */
  iconOnly?: boolean;
  size?: "sm" | "md" | "lg";
};

/** Copies text to the clipboard and says so — a check in place of the icon
 *  for two seconds, and an announcement for screen readers. */
export function CopyButton({
  value,
  label = "Copy",
  iconOnly = false,
  size = "sm",
  variant,
  ...props
}: CopyButtonProps) {
  const [state, setState] = useState<"idle" | "copied" | "failed">("idle");
  const timer = useRef<ReturnType<typeof setTimeout>>(undefined);
  useEffect(() => () => clearTimeout(timer.current), []);

  const copy = async () => {
    clearTimeout(timer.current);
    try {
      await navigator.clipboard.writeText(
        typeof value === "function" ? value() : value,
      );
      setState("copied");
    } catch {
      setState("failed");
    }
    timer.current = setTimeout(() => setState("idle"), 2000);
  };

  const Icon = state === "copied" ? Check : Copy;
  const message =
    state === "copied" ? "Copied" : state === "failed" ? "Copy failed" : "";
  return (
    <>
      {iconOnly ? (
        <Button
          {...props}
          variant={variant ?? "quiet"}
          size="icon"
          aria-label={label}
          onClick={copy}
        >
          <Icon aria-hidden="true" />
        </Button>
      ) : (
        <Button {...props} variant={variant} size={size} onClick={copy}>
          <Icon aria-hidden="true" />
          {state === "copied" ? "Copied" : label}
        </Button>
      )}
      <VisuallyHidden role="status">{message}</VisuallyHidden>
    </>
  );
}