> For the complete documentation index, see [llms.txt](https://beamline.gitbook.io/beamline-docs/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://beamline.gitbook.io/beamline-docs/reference/design-language.md).

# Design language

The visual and motion rules behind every Beamline component: colour, type, density, radii, elevation, looks, focus, data states and motion.

> The original of this page is on beamline.io: [beamline.io/docs/design-language](https://beamline.io/docs/design-language).

> Beamline code and your custom style files stay in your repo; your app has no Beamline runtime dependency. This documentation ships with the complete system (docs/DESIGN-LANGUAGE.md in the kit). Use [local kit tools](https://beamline.io/docs/install) or [connect an agent](https://beamline.io/connect) to install parts.

The visual and behavioural rules every component follows. All values live in `ui/styles/tokens.css`. Components read tokens and never hard-code a colour, radius, shadow or duration. Rulings marked **(owner)** are fixed; everything else can be tuned in the token file.

## Character

A dense, quiet instrument panel for people who look at numbers all day: trading desks, analytics, CRMs, agent consoles. Chrome recedes and data leads. One accent colour marks what can be acted on. Motion confirms that something happened and never performs for its own sake.

## Roots

The system lives inside a `.pui` root: the whole document (`<html class="pui dark">`) for an app built on it, or a `UIRoot` region inside any other app. Tokens, base styles and utilities apply only inside a root, and every name is `pui`-prefixed, so a host app's own variables, Tailwind theme, resets and animations never change. Dark is the default theme of a root; a nested root may switch theme or brand for its subtree.

## Foundation parameters (one place, everywhere)

The system is three layers (O1 §14): foundations → style (look) → component. A component reads every duration, radius, font size and spacing step from tokens, and those tokens are **derived** from a handful of scope parameters, so one value reaches every element, every overlay and JS animation tokens:

| parameter                          | token on the scope                                                       | UIRoot prop                            | derives                                                                                                                                                                 |
| ---------------------------------- | ------------------------------------------------------------------------ | -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Animation speed                    | `--pui-motion-scale` (1)                                                 | `motionScale`                          | `--pui-motion-in/out`, `--pui-duration-*`, `--pui-spring-*`, `--pui-stagger-*`, loop durations; JS motion (`useMotionTokens(hostRef)`) resolves each timing at its host |
| Roundness                          | `--pui-radius-scale` (1)                                                 | `radiusScale`                          | `--pui-radius-*`, `--pui-bezel-radius` (a style's pinned corners excepted)                                                                                              |
| Type size                          | `--pui-type-scale` (1)                                                   | `typeScale`                            | `--pui-text-2xs … -5xl`, `--pui-text-reading`, `--pui-text-code` and every role class                                                                                   |
| Type contrast                      | `--pui-type-ratio` (1)                                                   | `typeRatio`                            | each step's distance from body size (step = base × (step/base)^ratio)                                                                                                   |
| Hero size                          | `--pui-display-scale` (1)                                                | `displayScale`                         | `--pui-text-display`, the size of `.text-display` (the one headline a screen leads with), after type size and contrast                                                  |
| Spacing                            | `--pui-space-scale` (1)                                                  | `spaceScale`                           | `--pui-space-*` and the named paddings and gaps; control heights and row density stay                                                                                   |
| Motion mode, density, brand, focus | `data-motion`, `data-density`, `--pui-brand`, `--pui-focus-width/-style` | `motion`, `density`, `brand`, `tokens` | as before                                                                                                                                                               |

Precedence for every derived value: foundation default → the style's modifier (its radii are values the roundness scales; `--pui-motion-look` is its share of the foundation speed) or a declared **pin** (an identity value the parameter leaves alone, e.g. Blueprint's 2px corners, marked `--pui-pinned-<token>: 1`) → the customer's parameter → an explicit token override (beats a pin). Reduced and none motion beat any speed (1ms; reduced keeps the fade tokens). The approved looks render pixel-identical at the defaults. Text size utilities (`text-xs` …) carry no leading of their own; leading comes from the role or the container. `.text-reading` (15px, 1.6) is for long-form surfaces only. The gallery's Foundations pages and System panel edit these live; a crafted style is a `beamline-style/1` file (`ui/runtime/style-file.ts`) exported as a complete preset.

## Colour

* **One input.** `--pui-brand` (default oklch 0.58 0.19 258, a cool blue). Accent, accent-ink, accent-strong and accent-tint derive from it with relative colour syntax. `data-accent` presets (neutral, violet, green, amber, orange, rose) swap only `--pui-brand`.
* **The brand engine.** `--pui-brand-l-14 … -86` (seven lightness stops, chroma and hue kept) and `--pui-brand-c-0 … -22` (six chroma stops, lightness and hue kept) all derive from `--pui-brand`, so a rebrand is one token. Use them for brand-tinted fills and illustrations instead of inventing a colour. The 0.22 chroma stop is gamut-mapped at some hues; check it at yours.
* **Dark is the default, and light is just as complete** in every look: `class="light"` or `data-theme="light"` on `<html>`. Both themes define every token; components never branch on the theme.
* **Neutrals carry a trace of hue 260** so greys sit with the blue accent instead of looking dead.
* **Surfaces step by lightness:** page → canvas → surface → surface-2; `hover` and `hover-2` are one rung up; `inset` sits below (wells, code). `field` is the input fill.
* **Ink:** `ink` for primary text, `ink-2` for body and secondary, `ink-3` for meta (timestamps, captions, counts, placeholders). All three pass 4.5:1 on every surface and on hover, in both themes; the hierarchy comes from the steps between them, never from text too faint to read. Text on an accent fill uses `accent-foreground`, which picks near-white or near-black from the accent's lightness, so any brand colour stays readable.
* **Lines:** `line-soft`, `line`, `line-strong` are decorative hairlines. `edge-control` is the boundary of any input and passes 3:1 contrast against its background.
* **Semantic:** green, orange, red, blue, violet, each with a `-tint`. **(owner) Colour is never the only signal:** a status always has a glyph or a word as well (▲ +2.4%, "Failed", a check).
* **Trading:** `up` / `down` (+ tints) are separate from green/red so a product can flip the convention (red-up markets) without touching semantics.
* **Data series:** `series-1…8`, ordered for maximum contrast between neighbours; series-1 follows the accent and series 2–7 keep their hue distance from it (with the default brand they are the approved hues; a warm brand turns the palette with it instead of landing on amber). Series 8 is the neutral. With the look's default brand every series 1–7 keeps **3:1 against the card** in light and dark (WCAG non-text contrast; `scripts/e2e/series-hues-d5.mjs` checks it in every look). Pale colours go deeper on light paper instead: amber and green a step darker, Pop's lemon prints as gold, Ink alternates tints of 64 % or more with deep ink. A custom brand's own colour is the app owner's choice and is reported, not adjusted. Charts use series tokens, never semantic colours, unless the data is literally good/bad.
* **Consequence triples.** `--pui-consequence-blocking` ▲, `--pui-consequence-degrading` ■ (rounded), `--pui-consequence-hygiene` ○ (stroked), each with `-tint` and `-edge`. The token is the triple: colour + shape + word ("BLOCKING"). Using the colour alone is not using the token. Check with `filter: grayscale(1)`: if the meaning survives, it was used right.
* **Data ramps.** `--pui-ramp-heat-0…8` (sequential cold → hot), `--pui-ramp-cool-0…8` (one cool family) and `--pui-ramp-delta-0…8` (diverging, neutral pivot at stop 4); the continuous forms are `heat(t)`, `cool(t)`, `delta(t)` in `ui/lib/ramp.ts`. They are OKLCH so equal steps look equal, and they are encodings, not brand: a rebrand does not move them. Never build a ramp in HSL (its lightness is not perceived lightness).
* **(owner) Unmeasured = hatched.** Data that was never collected renders with `--pui-unmeasured-hatch` over `--pui-unmeasured`: never a zero, never the cold end of a colour ramp, never blank.

## Type

* **Reach for a role before a size.** Eleven role classes (plain CSS in `utilities.css`, so they work with or without Tailwind) bundle size, weight, leading and tracking; colour stays with the ink tokens: `.text-display` Geist 40 (one per screen; `--pui-text-display`, scaled alone by Hero size) · `.text-title` Geist 30 (page titles) · `.text-heading` Geist 20 (sections) · `.text-subheading` Inter 16 (cards, dialogs) · `.text-body` Inter 14 · `.text-body-sm` Inter 13 · `.text-label` Inter 13/500 · `.text-caption` Inter 12 · `.text-overline` Inter 11 caps · `.text-numeric` Geist Mono 13 tabular · `.text-code` Geist Mono 12.5.
* Inter for interface text, Geist for display numbers and headings, Geist Mono for code and aligned figures.
* Scale (rem): 2xs .6875 · xs .75 · sm .8125 · base .875 · lg 1 · xl 1.25 · 2xl 1.5 · 3xl 1.875 · 4xl 2.5 · 5xl 3.5. Tailwind's `text-*` utilities resolve to these same tokens. **Product body text is 13–14px**; 15px only in reading surfaces (answers, docs).
* Every number that can change or sits in a column uses tabular figures (the base layer applies this to `[data-numeric]`, tables, and mono).
* Headings and display figures: weight 500, tracking −0.01 to −0.02em. No bold 700 in chrome. Weights are `--pui-weight-regular/medium/semibold` (400/500/600).
* Uppercase labels (`.eyebrow`) open up to +0.06em; mono caps +0.04em.
* **Wrapping:** `balance` on headings, `pretty` on body. Never `balance` on prose (it knows nothing about sentences and is capped at six lines). Dense table cells are fragments: ellipsis plus the full value in a tooltip.
* **Reading measure:** 45–78 real characters. `ch` is the width of "0", about 1.4× an average letter in Inter and Geist, so "66ch" renders \~95 characters. `--pui-measure` is 48ch (≈ 68 characters); re-measure if the face changes.

## Density and spacing

* 4px base: `--space-N` = N × 4px, from `--pui-space-0-5` (2px) to `--pui-space-32` (128px), identical to Tailwind's spacing, so `p-4` and `var(--pui-space-4)` are the same 16px. Semantic names for the values containers already use: `--pui-gap-inline` 8, `--pui-gap-stack` 12, `--pui-pad-popover` 16, `--pui-pad-control` 16, `--pui-pad-card` 20, `--pui-pad-panel` 24, `--pui-gap-section` 40, `--pui-gutter-page` 40. Controls: sm 32, md 36, lg 40px. Icons: `--pui-icon-sm` 14 in dense rows, `--pui-icon-md` 16 by default, `--pui-icon-lg` 20 in large controls. Tables default to 36px rows; `density` compact = 28, comfortable = 44.
* Gaps inside a control 6–8px, between controls 8–12px, between groups 16–24px, between page sections 32–48px.
* Panels in a dashboard sit on 12–16px gutters. Trading surfaces go to 8px gutters and 28px rows.

## Radii

chip 6 · control 9 · card 12 · panel 14 · window 16 · surface 18 · pill. Nested radius = outer − padding. Data cells, chart plots and table frames stay square or 6px or less; round shapes are for things you press.

## Elevation

Lightness first, shadow second. Every shadow stack begins with a 1px ring so edges read on any ground: `shadow-hairline` (flat rows), `shadow-btn` (controls), `shadow-card` (resting cards), `shadow-raised` (hover or lifted), `shadow-overlay` (menus, popovers, dialogs). Dark theme shadows are heavier and the ring is white at 8–12%. Overlays also get a scrim (`--pui-scrim`) and, for modal layers, a 2px backdrop blur (`--pui-blur-overlay`). The double bezel (`--bezel-*`) is for the one or two frames a screen leads with.

## Looks

Every element has a **core** and a **look**. The core is the element itself: size, layout, states, keyboard, motion, data. The look is the whole style on top of it: the fonts and their weights, icon stroke, corner radii, the page behind everything (its colour and pattern), the highlighted word, and the paint of every surface. It is chosen like the theme: once for the app (`data-look` on the `.pui` root, or `UIRoot look`), for any part of it (`data-look` on a wrapper), or for one element (its `look` prop where it has one). The nearest choice wins, and switching the root's look restyles the whole product at once. Theme and look are independent: every look exists in dark and light.

A look is one block of custom properties in `ui/styles/looks.css`: type tokens (`--pui-font-heading`, `--pui-weight-title`, `--pui-heading-stretch`, `--pui-icon-stroke`), the radius scale, page and surface tokens (`--pui-page`, `--pui-page-pattern`), the mark (`--pui-mark-*`) and the paint of each role (`--m-*`). Components never test which look is on; they read the tokens.

Surfaces come in nine **roles**, and every painted part uses one of them and nothing else:

| role    | class                    | used by                                                                                                                                                                                   |
| ------- | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| control | `m-control`              | secondary buttons, triggers, chips, keycaps, the raised thumb of a segmented control or tabs                                                                                              |
| solid   | `m-solid` + `--m-color`  | primary and accent buttons, a selected day, anything filled with one colour                                                                                                               |
| field   | `m-field`                | inputs, text areas, search, the select and date triggers; owns hover and the focus ring, and its 1px ring keeps the 3:1 boundary in every look                                            |
| inset   | `m-inset`                | the well a thumb or selection sits in: segmented, tabs and toggle-group tracks, slider and progress tracks                                                                                |
| toggle  | `m-toggle` + `--m-color` | a well that fills as a solid when on (`data-state="checked"`): switch track, checkbox, radio; the element may set its off fill (`--m-toggle-off`) and a boundary ring (`--m-toggle-ring`) |
| knob    | `m-knob`                 | the round thumb of a switch, slider or scrubber: always light, finished by the look (machined in metal)                                                                                   |
| tag     | `m-tag`                  | small labels that sit on content: badges, category tiles, the highlighted word; a hairline by default, an ink outline in pop                                                              |
| surface | `m-surface`              | cards, panels, metric cards, table frames                                                                                                                                                 |
| overlay | `m-overlay`              | menus, popovers, select lists, dialogs, sheets, toasts                                                                                                                                    |

A role class owns paint and paint states only (background, edge, inner light, shadow, hover, press, focus, disabled, and the small hover and press offset a look may ask for); the element keeps layout. Colour comes from tokens; a solid takes its fill from `--m-color` (accent, ink, red…), so one recipe lights any colour. To add a mark to a painted part (a severity stripe, a selection ring), compose it with the role's own value, never replace it: `shadow-[var(--m-surface-edge),inset_3px_0_0_var(--pui-red)]`.

A toggle that stays on in a toolbar (bold, alignment) sits pressed in: it takes the inset well's paint (`--m-inset-shade`, `--m-inset-edge`) rather than a raised one, so on reads as held down in every look.

**The nine looks**

* `prism` (default) — cut crystal in a dark room (owner, 7 Oct 2026: "dark black … prismatic aurora at proper places … not uniform, not super prominent, still more into dark"; "light, not glow"; "make this prism style best out of all"). Clear crystal is seen only where light meets it, so a part is drawn by its cut edges, not by a fill: light from the top left catches a control's top and left edges as fine lines that fade along them, the outline of the cut is a faint hairline, the lower face sits in shade; fields are channels cut in, their lower lip catching the light; cards are slabs lit at the top-left edge over a deep shadow. Faces are nearly black and still; nothing is milky and nothing glows. The main action is white light itself (ink-black in light). Light splits only at the place of attention and inside the glass, and what it shows depends on the angle, like a real prism: in long runs of colour with gaps (never one even gradient) along the main action's bottom edge while pointed at (each button its own angle, turned by the pointer across the face, a new one at each press; a change of word or colour sweeps a band of it across once), along the bottom of the field being written in (lighting from the centre, drifting while focus stays), under the chosen tab (sweeping as it travels), and along a card's edge as the pointer arrives (a brief, bold flare in near-full colour, the cut line thickening while it lasts, each its own shape; edge light). Otherwise colour lives in data (a natural spectrum whatever the brand) and in two far-off corners of the page; the bands of split light at chosen places are a part (LightBand). Cut, not moulded: crisp radii, a rigid quick motion, 1.5px icons, Geist throughout set tight, Geist Mono labels, the highlighted word in an Instrument Serif italic of white light. A brand or accent replaces the white main action. Light is a white room with the same cut glass.
* `soft` — the clean modern standard in the brand's air: the page and every surface carry a trace of the brand's hue (a share of its own chroma, so a neutral brand stays neutral), a soft light falls from the top of the page and a card's top edge catches it; hairline rims, short layered shadows, a faint sheen on solids, Geist over Inter.
* `blueprint` — the drafting table, for dense tools where depth is noise, with one set of rules everywhere: one 2px corner; one 1px line weight; a light rule for containers and a 3:1 rule for everything operable; registration ticks at the corners of surfaces and overlays only; no shading at rest (an overlay floats on one soft shadow); Geist with monospaced micro-labels, fine icons, a dashed focus ring, the highlighted word as a selected frame. Drawn as CAD: cyan wireframe lines and ticks on near-black (teal on white in light) over a quiet grid with points at its crossings.
* `glass` — Liquid Glass as iOS 27 draws it. Glass is seen by what it bends and where light meets its edge. The floating layer (controls, menus, popovers, dialogs) bends what lies behind it at the rim like a thick convex edge, the middle staying true (the lens: `ui/runtime/presets/glass.ts`, Chromium only; elsewhere the frost alone); an overlay is thicker glass than a control, so it bends more, frosts more and casts a deeper shadow. Cards and sidebars are the content layer: frosted, lit, never bent. One frost setting, `--pui-glass-frost` (0 clear, 1 heavy, 0.5 by default, like iOS 27's slider), moves every part; frost is mostly a tint (dark in dark, white in light) and a little blur, so text on glass stays readable over anything. Every rim is lit by two crescents (bright where the light enters, top left; fainter where it leaves, bottom right) and the pointer stands in for the phone's tilt. Controls are capsules; a press lights the glass from the point pressed, spreading through it, and a button swells toward the finger on the snappy spring (no overshoot); a held slider or switch knob lifts into a clear lens over its track. The main action is tinted glass. Reduced transparency and increased contrast turn the frost up (and drop the lens; contrast also draws plain strong edges). The page behind is a faint drafting grid under two wide, heavily diffused, low-saturation pools of the brand's hue, so the glass has something to bend; headings light.
* `metal` — one machined plate: the page is the metal (black anodised with diamond-cut edges in dark, anodised aluminium in light) with a visible bead-blasted grain and one broad reflection across it. Parts are milled into it, not laid on it: cards are pockets (a shadow under the top wall, the bottom wall catching light, the plate's grain running through), fields and wells are shallow pockets, and only actuators stand up like keys (buttons, thumbs, knobs) with a polished chamfer. Machined radii (no soft pills), anodised solids, a lathe-turned knob, segmented meters, wide Archivo headings, engraved mono labels, monospaced figures. Never chrome bands, brushed stripes or floating plates.
* `glow` — light in one place. A calm neutral night (clean paper in light) with hairline surfaces like soft; the light gathers where the eye should land and nowhere else: a faint haze of spectrum across the top of the page, the highlighted word written in that spectrum, and the main actions (primary and accent buttons) standing on a soft spectrum of light that spreads when pointed at. Cards, fields, charts, sliders and selected chips stay unlit.
* `pop` — a sticker sheet: a warm page with a dot grid (deep petrol in dark), ink outlines, a hard lip under every control that the element rides up on hover and sinks into on press, Gabarito headings over Figtree, bold icons, a tilted lemon label for the highlighted word, data in sticker colours, a warm default brand.
* `mono` — the printed instrument: a monochrome interface where only data carries colour, drawn as a dot matrix (solid marks as rows of short segments, soft fills as a grid of dots, meters as segments, lines solid); regions are cells framed by dashed rules with every inner divider dashed; the highlighted word underlined in dots. Actions, selection, links and focus are ink; the brand colour stays only on the data series it leads.
* `ink` — a pen-and-ink drawing. Lines are straight ink, never wobbled (a hand-drawn wobble read as a cheap sketch): a frame over each part's edge (`--m-pen*`) has a soft ink-on-paper edge, and on cards the ink varies along the line like pen pressure. Chart areas and meters are shaded by proper hatching, crisp 45-degree strokes from a seamless tile at full ink, never fading (`--pui-fill-mask`, `--pui-meter-mask`, `--pui-chart-fill-top/-bottom`). Solids are flat ink with a grain. On light paper one ink in the brand's hue (the sidebar printed solid, a faint off-register second impression); on black stock two inks, cream for type and lines and the brand as a spot colour. Instrument Serif for display sizes over a typewriter mono; the highlighted word a rubber stamp.

**Charts follow the look** through one grammar of tokens (`tokens.css`, "the chart grammar"), never through an effect stamped on the marks: a mark's colour and size stay the encoding. A look reaches a chart in four ways, and only these:

* **The frame.** Axis labels in the look's label face, case and tracking (`--pui-chart-font`, `-label-case`, `-label-tracking`; the axis is measured in that face, so it stays as wide as its labels); the grid's colour, weight, dash and cap (`--pui-chart-grid*`); a zero line marks stand on (`--pui-chart-baseline*`); tick marks beside the labels (`--pui-chart-tick`); and the reader's place: the band behind a bar's category (`--pui-chart-cursor`), the line through a point's (`--pui-chart-cursor-line`, `-dash`) and the point on each line (`--pui-chart-cursor-point`).
* **Shape.** Bar corners (`--pui-bar-radius`; a stack is one bar, cut square inside, rounded as a whole), cell corners and how much of its slot a cell fills (`--pui-cell-radius`, `--pui-cell-scale`: 50% and 0.82 make mono's dots), points on every value or only the last (`--pui-chart-point`, `--pui-chart-end`, round dots or square nodes by `--pui-chart-point-cap`), arc gaps, corners and ends (`--pui-arc-gap`, `-round`, `-cap`).
* **Geometry along the mark's own axis.** Segments across a bar's length from its base (`--pui-bar-mask-y/-x`: metal's lamp segments in an unlit column, mono's matrix of small blocks, ink's hatching under a pen line at the value), ticks around an arc's own centre (`--pui-arc-mask`: mono's fine ticks, metal's lamps), segmented meters (`--pui-meter-mask` on `.meter`), mono's dot grid on soft fills.
* **The light the material predicts.** A bar keeps a share of its colour at its value end and at its base, the rest being the card laid over it (`--pui-bar-top`, `--pui-bar-base`: prism's columns of light, glass's clearing rods), a line of its own colour along the value end (`--pui-bar-cap`: prism's lit edge, blueprint's ruled value line), the column a bar stands in (`--pui-bar-track`: soft's air, metal's unlit lamps), a lit cell's top edge (`--pui-cell-light`), and the palette, line weight and fill strength (`--pui-series-*`, `--pui-chart-stroke`, `--pui-chart-fill`). A look may set its series palette after the brand (glass luminous, metal anodised, glow emitted, pop sticker colours, ink tints of the ink) or keep its own whatever the brand (prism: the light band's hues — blue, teal, red, amber, then deep blue, coral, sky).

A mark not in focus fades toward the card colour, never through transparency, which turns muddy on a dark page. Still rejected, because they are stamped effects rather than a material: bevels and light-dark pairs, outlines drawn by a filter (an outlined selected slice inverted the emphasis), a lip under marks, speckle, and stripes at a fixed angle (they read as the hatch that means "not measured"). The app shell's content area wears the look's page (`look-page`), so the grid, aurora or dots reach real screens. Every pill reads the look's `--pui-radius-pill` (`rounded-pill`; `rounded-full` is only for true circles: avatars, dots, spinners), so a machined or drafted look is never undone by a soft pill, and an example's stage (`look-stage`) wears the look's page too.

Every look starts from a model of its material (how it meets light) and paints only what that model predicts; an effect without a physical reason is not added. Shared means: a fine grain (`--m-grain`, `--m-grain-faint`) and a static angled rim drawn with the edge light (`--m-rim`).

The references a look answers (a premium kit's volume, a glowing pill, sticker-style sites) give the genre only. A look's colours, fonts and recipes are the system's own; nothing is taken from a reference.

Rules: a look is static at rest and moves only on hover, press or focus; text on every look keeps 4.5:1 and every field and toggle boundary 3:1; disabled is the same in every look (reduced opacity, no light, no blur); a look never changes the size scale (control heights, spacing, density), so switching it never re-flows a layout beyond text widths following the look's fonts. Paint never animates by swapping a gradient (images cannot fade): hover and on/off move two registered values, `--m-hover` and `--m-c`, and an element that wears a role lists `var(--m-transition)` in its transition so hover arrives at once and leaves over `--pui-motion-out`.

**Not painted by a look, on purpose:** tooltips and value bubbles (an inverse label that must read the same over anything), the paint of data marks (bars, cells, lines keep their colour as the encoding; the look reaches them only through the chart grammar above, never through a role), table header bands and sticky cells (they must stay opaque and flat under scrolled rows). Media frames (video, image, a canvas) take only the look's edge, `shadow-[var(--m-surface-edge)]`, never its fill.

## Layers

One stacking ladder, read by every overlay component: `--pui-z-raised` 1 · `--pui-z-sticky` 10 · `--pui-z-overlay` 50 (modal scrim) · `--pui-z-modal` 51 (dialog, sheet, lightbox) · `--pui-z-popover` 60 (menus, select, combobox, popover, hover card) · `--pui-z-toast` 70 · `--pui-z-tooltip` 80. So a menu or tooltip opened inside a dialog is always above it. Stacking inside one component (a pinned column, a chart crosshair) stays local with small numbers.

## Texture

* `.grain`: a static noise tile (`--pui-grain-tile`), soft-light at 18% on dark / multiply at 6% on light, fixed over the page or clipped to one surface (`.grain--local` in a `.grain-host`). Never animated, never a hit target, gone in print.
* `.glass`: overlays only, static blur only (`--pui-blur-glass`); under `prefers-reduced-transparency` it becomes the opaque `surface-2` twin.

## Scrollbars

One scrollbar everywhere, drawn by `ui/styles/base.css` from `--pui-scrollbar-size`, `--pui-scrollbar-thumb`, `--pui-scrollbar-thumb-hover` and `--pui-scrollbar-track`: a thin rounded thumb, no arrow buttons, no track fill, the same in every theme and on every OS. Any app that imports the stylesheet gets it on every scroller, its own included. Components never set `scrollbar-color` or `scrollbar-width: thin` (either one brings the OS bar back in Chromium); a scroller that should show no bar uses `scrollbar-width: none` with `::-webkit-scrollbar { display: none }`, and `ScrollArea` draws its overlay bar from the same tokens. `pnpm e2e` fails a route that draws any other bar.

## Motion (owner)

* **Arrive in 90ms, leave in 180ms, ease-in-out.** `--pui-motion-in`, `--pui-motion-out`, `--pui-ease-default`. Arrival and departure are different events and never share a duration.
* **The frequency law decides speed, not whether.** Hover arrives at 0ms and leaves over `--pui-motion-out`; a press is acknowledged at 0ms; the control's value, focus and aria state always change at once. What is touched a hundred times a day still moves (the owner's direction, 2026-10-05: every element morphs), but short (`--pui-duration-fast`) and never in the way of the next key: a menu or list highlight glides under the arrow keys, a typed character never animates, live market data never moves. `.ix` (utilities.css) encodes hover and press for any clickable thing.
* **Curves:** `--pui-ease-default` (the default), `--pui-ease-standard`, `--pui-ease-enter`, `--pui-ease-exit`, `--pui-ease-in-out`, `--pui-ease-out-strong`, `--pui-ease-drawer`, `--pui-ease-spring` (critically damped). `--pui-ease-exception` is situational: a reveal that should feel thrown into place, named as the exception where it is used. `--pui-ease-spring-bounce` and `--pui-ease-overshoot` overshoot and are not defaults.
* **Utilities** (`ui/styles/utilities.css`): `.disclosure-panel` animates to the content's height with no measuring; `.overlay-anim` gives native popovers and dialogs an enter and exit with no JavaScript; `.sr-fade` / `.sr-wipe` reveal by scroll position (at most one per screenful, never in the first, never fade-and-rise); `animate-enter-fade/-rise/-scale`, `animate-exit-*`, `animate-slide-in-*`, `animate-accordion-*`, `animate-shimmer`, `animate-pulse-soft`, `animate-marquee`. Stagger steps: `--pui-stagger-char/word/item/line`.
* Content changes (label swaps, numbers, morphing sizes) use the critically damped springs in `ui/lib/motion-tokens.ts` (`spring.snappy`, `spring.smooth`, `spring.morph`, all bounce 0).
* **No overshoot except dragged objects.** A sheet or slider thumb released by the finger may use `spring.held` (bounce .14); nothing that moves on its own bounces.
* **Focus is always visible and never animated:** a `--pui-focus-ring` outline at least 2px wide, 2px offset, `transition: none`. A look may draw it its own way (`--pui-focus-style`, `--pui-focus-width`: dashed in blueprint and mono, a 3px ink ring in pop, a double rule in ink), never thinner and never removed. `--pui-focus-ring` is the solid accent on dark and the brand at L 50 on light (one lightness cannot clear 3:1 on both). No component may remove it; components with custom focus styling must still show it on `:focus-visible`. Keyboard mode: an app that sets `data-kbd="on"` on the root after a Tab gets a louder amber ring with a halo (`--pui-focus-ring-kbd`).
* **Reduced motion is not a degraded mode.** Every duration token becomes 1ms (instant, but end events still fire), stagger becomes 0, and decorative loops stop entirely (tickers, marquees, shimmer). Nothing that carries information is removed: live data still updates and progress still shows. Fades stay, restrained (APPEARANCE A06): `--pui-fade-in`/`--pui-fade-out` equal the arrival and departure and keep their duration under `reduced` (and the OS preference under `auto`); `--pui-motion-travel` (1, 0 under reduced) scales the small offsets in the shared entrance keyframes, so `animate-enter-rise/scale` become fades. Dialogs, popovers, menus, the scrim, Button's label and loader and Skeleton's swap fade this way; nothing that moves starts moving. `none` removes the fades too.
* Price flashes: a 90ms tint in, 600ms fade out, direction also shown by glyph.
* **Anything that moves on its own can be held, by anyone (WCAG 2.2.2).** A tape, a turning wall, a scrolling sign or a rotating message takes the shared hold (`hooks/use-hold.ts`): the pointer over it, keyboard focus in it, or on touch a tap, until the next tap; a tap that chooses something inside holds it until a touch elsewhere. A moving part with nothing focusable inside is a tab stop while it moves. Never a private hover/focus pair.
* **Matter (the morph grammar, 2026-10-04).** A selection travels instead of switching off here and on there: the segmented thumb, tab indicator, radio bead, sidebar and pagination plates, chosen calendar day and selected tree row are contours (`ui/lib/contour.ts`), four corners on critically damped springs with uneven response, edges that only bulge outward, a slight neck while stretched. Elasticity is the parts arriving at different moments, never an overshoot, so the no-bounce rule holds. The control commits at 0 ms; only the decorative surface settles, and a reflow places it without travel. At rest it is the plain CSS box with no clip and no frames.
* **Looks have physics.** Each look states how its surfaces move (`--m-morph-spread`, `-front`, `-waist`, `-weight`, `-rim`): soft is viscous, glass a little heavier, pop rubber, ink a brush; metal, blueprint and mono stay rigid.
* **Layers open out of their source.** A popper layer starts as its trigger's box and grows (the near side at once, so rows read in 90 ms; the far side by \~200 ms); a dialog starts as whatever opened it. Both close back into it.
* **Changes keep their size and their path.** A bar's lost part stays as a ghost and drains, a gained part arrives lit (`_shared/delta-ghost`); an action that changes several values sends a trace to each (`ui/lib/trace.ts`). Reduced motion keeps both as still marks. Live market rows never travel.
* **Every element morphs (2026-10-05).** Four shared mechanisms carry it: a **plate** that travels to the active row or cell (`_shared/glide.tsx`: menus, command menu, listboxes, select, time picker, watchlist selection, OTP focus ring, transcript word); **collections** whose items grow in, slide to new places and leave a fading ghost (`hooks/use-flip.ts`: chips, tags, filters, comments, attachments, breadcrumbs, table rows); **values** that roll or reshape (number-field steps through `_shared/roll.ts`, category-bar widths, sparkline paths, SlotText); and **mode swaps** that change size in place (inline edit, command-menu pages, password reveal). Playable on Foundations → Morph.

## Interaction feedback

* Press: 0.96–0.985 scale depending on width (smaller things press deeper), instant. `--pui-press-scale` (0.955) is the default for small controls using `.ix`.
* Hit targets are at least 24px (`--pui-hit-min`). `.hit` grows the real box; `.hit-bleed` extends a control whose drawn size is the design. A bigger painted pseudo-element does not enlarge what the pointer can hit unless it is positioned inside the control.
* Hover: surface steps one rung (`hover`), never a shadow jump on dense rows.
* Selection: filled `accent-tint` plus a 2px accent edge or check. Never colour alone.
* A selection that moves (segmented thumb, tab line or pill, radio-card ring) **glides**: `useIndicator` + `glideProps` and the `glide` class move its two edges separately, the one on the side it is heading first and the other a beat later, so it stretches toward the new choice and gathers there. Critically damped, no overshoot.
* **Edge light**: a surface's rim catches light as the pointer comes near (`ui/lib/edge-light.ts`, started by UIRoot; an app that marks `<html class="pui">` itself calls `startEdgeLight()` once). One passive listener updates only the surfaces within reach, on the frame after a move; nothing runs while the pointer is still, on touch, or with reduced motion. Each look sets its colour and reach (`--m-edge-light*`), or turns it off.
* Loading: controls keep their size and focus; the label morphs to a spinner. Data regions use skeletons of the real shape. Anything over \~2s shows elapsed time.
* Errors: inline, next to the cause, with the fix. Toasts are for background results only.

## Responsiveness: the room, not the window

An element answers to the room it is given — a sidebar, a split pane, a dashboard tile, a phone — not to the size of the window (research and rationale: `research/component-love-2026-10-10/PLAN.md`).

* **Containers.** A block-level element (a card, a chart, a table, a list, a form, a panel) is a size container (`@container` on its root) and lays out by Tailwind's container steps, the shared room steps: `@3xs` 16rem · `@2xs` 18 · `@xs` 20 · `@sm` 24 · `@md` 28 · `@lg` 32 · `@xl` 36 · `@2xl` 42 · `@3xl` 48 · `@4xl` 56rem. Window breakpoints (`sm:` `md:`) belong to pages and a block's page frame, never inside an element. A content-sized part (button, badge, chip, kbd, avatar) never gets inline-size containment (it would be 0 wide); it adapts through its container: a row folds it, a cell truncates it. A container that callers may leave content-sized (a card dropped into a flex row) guards itself with `min-width: min(16rem, 100%)` instead of collapsing; one that callers usually centre (an empty state, an alert) reads its own width (`useWidthAs`) instead of being a container. Where only JavaScript can decide (a chart's ticks, how many items fit), the width is read once per change with `useContentWidth` / `useWidthAs`, never per frame.
* **Fold by rank.** Every part of an element has a rank (its a-priori importance, distance from what is in focus — Furnas's degree of interest). Less room folds the lowest ranks first, never the one in focus: into a tooltip, a "+N" (`useFit`, which keeps order and the items that must show), a shorter format (`$1.2M` for `$1,204,310`), fewer ticks, a narrow mode. Order never changes; a fold that reshuffles breaks the reader's memory of where things are.
* **A stop changes structure, without a jump** (semantic zoom). At a step the element shows a different arrangement — a label moves into the name line, a column detaches, a legend moves into the header — and what moves keeps its path (contours, `useFlip`); between steps it is legible at every width. More room shows more: a wider card adds its comparison and trend, a wider table separates what the narrow one joined (the Operate table's lesson).
* **Charts adapt by targets × actions** (Kim, Moritz, Hullman 2021; Hoffswell, Li, Liu 2020): first fewer ticks and shorter tick labels, then labels inside or into the tooltip, the legend to one line or into the header, axes thinned; the marks keep their share of the room, and the message survives at the smallest size.
* **Any ratio.** An element that fills its parent's height (a chart, a list, a terminal) lays out at any height it is given, short and wide or tall and narrow, without cutting its own controls.
* **Reveal on demand.** Secondary information that would crowd the rest state waits for attention: `.reveal` inside a `.reveal-host` (utilities.css) shows on hover, focus inside, open or selected, takes no room when it appears, and is always shown on touch. An app that offers "always show details" sets `data-reveal="always"` on its root (or a region), and every reveal in it stays shown — one setting for the whole organism.
* **Proved.** The gallery's canvas (drag an example's edges, on an entry's page and on every list row) shows any room and ratio from the element's floor (360 px, or its meta.json `minWidth`) to the whole stage, settling at phone 390, tablet 768, 1024 and full as it passes them; `node scripts/e2e/fit-sweep.mjs` walks every example through 280 · 360 · 480 · 640 · 960 · full and reports spills, cut or crushed text and labels drawn over each other.

Everything works at 390px. Data tables scroll horizontally with a pinned first column rather than collapsing into cards, and at narrow widths fold secondary columns into the name line where the table declares it. Popovers become bottom sheets under 640px where the content is a form or a long list. RTL is supported: components use logical properties (`ms-`, `pe-`, `inset-inline-start`) and directional icons flip.

## Sound

Off by default. When a product opts in, sounds are named by interaction class (tap, select, toggle-on/off, open/close, tick) through one provider, never per component.


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation by asking a question.

Perform an HTTP GET request on the following URL with the `ask` and `goal` query parameters:

```
GET https://beamline.gitbook.io/beamline-docs/reference/design-language.md?ask=<question>&goal=<user_goal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is what the user is ultimately trying to achieve, the reason they need the answer. Sharing it helps GitBook give you a better, more relevant answer. A goal is most helpful when it describes the outcome the user wants rather than restating the question. For example, with `ask=how do I create an API token`, a goal like `automate deployments from our CI pipeline` lets GitBook tailor the answer to that use case.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
