Docs · Updated
design language
Part of the documentation that comes with Beamline's complete system (docs/DESIGN-LANGUAGE.md in the kit). Your coding agent can do all of this for you through Beamline's MCP server: connect it.
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) |
| 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-accentpresets (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, light is opt-in:
class="light"ordata-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;
hoverandhover-2are one rung up;insetsits below (wells, code).fieldis the input fill. - Ink:
inkfor primary text,ink-2for body and secondary,ink-3for 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 usesaccent-foreground, which picks near-white or near-black from the accent's lightness, so any brand colour stays readable. - Lines:
line-soft,line,line-strongare decorative hairlines.edge-controlis 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.mjschecks 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-tintand-edge. The token is the triple: colour + shape + word ("BLOCKING"). Using the colour alone is not using the token. Check withfilter: 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 areheat(t),cool(t),delta(t)inui/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-hatchover--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-displayGeist 40 (one per screen) ·.text-titleGeist 30 (page titles) ·.text-headingGeist 20 (sections) ·.text-subheadingInter 16 (cards, dialogs) ·.text-bodyInter 14 ·.text-body-smInter 13 ·.text-labelInter 13/500 ·.text-captionInter 12 ·.text-overlineInter 11 caps ·.text-numericGeist Mono 13 tabular ·.text-codeGeist 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:
balanceon headings,prettyon body. Neverbalanceon 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.
chis the width of "0", about 1.4× an average letter in Inter and Geist, so "66ch" renders ~95 characters.--pui-measureis 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, sop-4andvar(--pui-space-4)are the same 16px. Semantic names for the values containers already use:--pui-gap-inline8,--pui-gap-stack12,--pui-pad-popover16,--pui-pad-control16,--pui-pad-card20,--pui-pad-panel24,--pui-gap-section40,--pui-gutter-page40. Controls: sm 32, md 36, lg 40px. Icons:--pui-icon-sm14 in dense rows,--pui-icon-md16 by default,--pui-icon-lg20 in large controls. Tables default to 36px rows;densitycompact = 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 short flare, 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— a liquid lens. Clear glass is seen by its edges and by what it bends, not by a frosted fill: a surface is almost clear, with a thick rim (a bright line where light enters, top and left; a caustic glow along the bottom inner edge; a soft inner glow all round) and a light blur over what lies behind. The page behind is a faint drafting grid under two wide, heavily diffused, low-saturation pools of light in the brand's hue. Controls are small lenses of the same glass; overlays stay a denser pane so a menu reads over anything; corners generous, 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 their palette and geometry, never through an effect on the marks: a mark is an
encoding, not a finish. A look may set its series palette after the brand (glass luminous, metal anodised, glow
emitted, pop sticker colours, ink tints of the ink), the line weight (--pui-chart-stroke), the fill strength
(--pui-chart-fill), a meter cut into segments (--pui-meter-mask on .meter tracks), a donut's arcs cut into
radial ticks (--pui-arc-mask on data-marks-arc, mono), and two finishes that are light or pattern rather than
paint (ui/lib/look-filters.ts): glow's tight halo of the mark's own colour in dark, and mono's dot grid on soft
area fills. A mark not in focus fades toward the card colour, never through transparency, which turns muddy on a
dark page. Bevels, speckle, outlines and stripes on marks were tried and rejected: they are stamped effects, and
stripes 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), data marks (bars, cells, lines: an encoding, never a finish), 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--localin a.grain-host). Never animated, never a hit target, gone in print..glass: overlays only, static blur only (--pui-blur-glass); underprefers-reduced-transparencyit becomes the opaquesurface-2twin.
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-exceptionis situational: a reveal that should feel thrown into place, named as the exception where it is used.--pui-ease-spring-bounceand--pui-ease-overshootovershoot and are not defaults. - Utilities (
ui/styles/utilities.css):.disclosure-panelanimates to the content's height with no measuring;.overlay-animgives native popovers and dialogs an enter and exit with no JavaScript;.sr-fade/.sr-wipereveal 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-ringoutline 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-ringis 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 setsdata-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-outequal the arrival and departure and keep their duration underreduced(and the OS preference underauto);--pui-motion-travel(1, 0 under reduced) scales the small offsets in the shared entrance keyframes, soanimate-enter-rise/scalebecome fades. Dialogs, popovers, menus, the scrim, Button's label and loader and Skeleton's swap fade this way; nothing that moves starts moving.noneremoves the fades too. - Price flashes: a 90ms tint in, 600ms fade out, direction also shown by glyph.
- 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)..hitgrows the real box;.hit-bleedextends 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-tintplus a 2px accent edge or check. Never colour alone. - A selection that moves (segmented thumb, tab line or pill, radio-card ring) glides:
useIndicator+glidePropsand theglideclass 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 callsstartEdgeLight()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
Everything works at 390px. Data tables scroll horizontally with a pinned first column rather than collapsing into
cards. 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.