Beamline

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-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, light is opt-in: 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) · .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 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--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.
  • 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

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.