The design language
Precise and quiet, with palpable quality — the guiding idea of this
workspace. It holds for @umriss-ui/core, @umriss-ui/charts and
@umriss-ui/table alike: the charts draw their axes and labels from the same
tokens the components use, and the table inherits both. What follows describes
the language; the words it is spoken in are fixed in CONTEXT.md.
Design guidelines · the "Ink & Paper" concept
Light as the default. The language combines the typographic rigour and shadow architecture of Vercel's Geist with the paper materiality of Attio.
Type. Geist Sans (400/500/600) as the UI typeface, Geist Mono for numbers, logs and identifiers – both open source. Large titles run slightly compressed (negative tracking); micro labels (eyebrows, table heads) in letter-spaced capitals.
Numbers in monospace. Every numeric column and numeric input uses Geist Mono with tabular figures. Sharp, exactly stacked digits are the system's signature.
Colour. Almost monochrome: ink (#171717) on paper (#fafafa/#ffffff). The primary button is ink, not colour. A single accent (petrol) marks interactive things and nothing else – focus, selection, links. Semantic colours are muted, with pale surfaces for badges. Colour that appears always carries meaning.
Edges and depth. Borders are 1 px shadows without offset ("edge") rather than borders; cards are borderless and carry a multi-step shadow stack in which every step has a job (edge · contact with the ground · soft depth). Cards lie like paper on a table. Interactive elements stay flat. The one edge drawn as a border is the file input's drop zone: dashed, because a dash is what says "put it here" on every desktop, and a shadow cannot be dashed.
Focus. A crisp accent edge of two pixels (--u-focus-ring), the same on
every component – no glow, and no browser outline beside it. On a field it lies
where the field's edge lies, so the edge seems to grow; an invalid field keeps
its danger colour (--u-focus-ring-danger). Unmistakable, never soft. Beside
every ring stands one outline, 2px solid transparent: it paints nothing, but
forced colours (the Windows contrast mode) drop every box-shadow and paint the
outline in a system colour, so the ring stays where it was. That outline is the
one thing a focus rule may draw besides the ring token, and every rule that
draws the ring has to draw it - the stylesheet check holds both
(forced-colors 01).
States. One logic by means, not by values (the interaction-state
canon): hover changes the surface - a quiet key sinks to
--u-color-surface-sunken, a field's edge darkens; a press changes it more
strongly (--u-color-surface-pressed, or the -pressed step of a tinted
ground) and a button or a glyph may settle besides; focus is the ring and only
the ring, never a surface, and no hover edge outweighs it; disabled dims to
half and reacts to nothing - no hover, no press, the not-allowed cursor. What
has no surface of its own - a tab, a link, a header's sort label, a segment -
darkens its type instead, and says so where it does. The stylesheet check
holds the three means that can be read off a rule; the exceptions stand there
with their reasons.
Forced colours. The Windows contrast mode repaints every colour with a
system colour and drops every box-shadow - and edges, depth and rings are all
box-shadows here. So every edge and every card's or overlay's depth carries
outline: 1px solid transparent where the edge lies (the stylesheet check
holds it), and a state that was a ground alone takes a system colour inside
@media (forced-colors: active): a chosen or active item an outline in
Highlight (inside it, two pixels; a band one), a fill that is the value -
progress, a meter, a slider's track, a switch that is on, a tab's underline -
Highlight, a mark that was ink - a checkbox's dash, a radio's dot, a divider,
a group's share bar, a pinned block's edge - CanvasText. A system colour an
author names survives forced colours; nothing else does, and a gradient not at
all - the slider's track takes forced-color-adjust: none to keep its
gradient, in system colours. A verdict keeps its word and its glyph. A tone
told by colour alone - the meter's - is not kept: FC3 keeps a colour only
where a word stands beside it, and no such chip needed it yet.
The canvases paint themselves in the system colours (the charts' theme, the
schedule's FORCED). The finer settling is the polish round's
(forced-colors 04).
Motion. Motion explains a change of state; it does not decorate, and
every motion has a name in tokens.css - a motion that fits none of them wants
a new token, not a number in its module. Three distinctions carry the names.
Micro or path: a colour, an edge or an opacity changes in place in 120 ms
(--u-transition); something that turns or slides takes
--u-transition-path (140 ms), a longer way --u-duration-medium (240 ms); a
press point is shorter than both (--u-transition-press, 80 ms, scale 0.98 on
a button, 0.8 on a glyph). All of them run on one decisive, softly landing
curve, --u-ease-out. Entry or exit: overlays unfold from their motion
origin - the edge of the panel facing its trigger, computed from the same
side and alignment that position it (motionOrigin in position.ts): a menu
below its button grows from its top left, a flipped one from its bottom, a
tooltip from the edge towards its trigger, a toast out of the corner it
stands in, a modal - which hangs from no trigger - from its centre, a drawer
from the edge it stands at, sliding the whole way in on
--u-duration-medium. Panels,
tooltips and the modal enter by scale (0.96) and opacity, a toast rises 8 px
from its edge as it grows; they leave faster (--u-duration-exit-fast
100 ms for panels, --u-duration-exit 160 ms for a sheet) on --u-ease-exit,
a curve that starts at once, and fade where they stand instead of retracing
the way they came - a symmetrical exit makes a surface feel sluggish. The
native <dialog> is choreographed for it instead of being closed hard,
Escape included. Continuous process: the spinner turns
(--u-duration-spin, --u-ease-steady), skeletons shimmer instead of pulsing
(--u-duration-shimmer, --u-ease-swell), and an indeterminate progress bar
sweeps its track (--u-duration-sweep) - under reduced motion slower, never
still, since a bar at rest would say the task has stopped. And the details: the tab
underline glides to the active tab, cards collapse with an animated height
(the content stays in the DOM and is made inert), the checkbox draws its tick
(--u-duration-draw, 320 ms - slow enough to watch the line), and toasts
glide out towards their side on leaving while the others close up over the
place - a deck that fans out under the pointer or the focus, and holds every
countdown while it is open. prefers-reduced-motion drops the path, never the state change: the
duration tokens fall to 0 ms, a panel appears and goes at once - but it
appears - and no focus style hangs on an animation.
Squircles. Where the browser supports corner-shape, every rounding of the
library's own elements is drawn as a superellipse ("squircle", as Apple does it)
rather than as a circular arc – a progressive refinement with no risk for older
browsers. The build adds it after every radius (scripts/styles/ownBox.ts); an
application's own rounded elements stay as the application drew them. One
exception, by rule: a mark that shows a state is round, a key is not. The
stepper's markers are circles (corner-shape: round): they say where a
procedure stands and are pressed by nobody, and a squircle that small is a
rounded square - ticked, it would read as a checkbox to press. Whatever can be
pressed keeps the squircle (core-layout-extras 04).
Details for the connoisseur. The text cursor in the library's input fields is
petrol (caret-color); the scrollbars of its scroll containers are slim and
follow the theme; browser autofill keeps the paper look including its edge. The
text selection and the page's scrollbars are the application's: the library
styles nothing it did not render (ADR-0021).
Every value lives as a CSS custom property (--u-*) in src/styles/tokens.css, in
the cascade layer umriss.tokens; an application overrides it outside a layer.
Components reach for tokens only – never for raw values.
Colour for the abnormal (ISA-101)
ISA-101, the standard for high-performance operator screens, asks for what
"Ink & Paper" already does: a grey base, and colour reserved for the abnormal,
so that the one red thing on a screen is found in the corner of an eye. umriss
names the rule here so that it stays deliberate (the norm text is paywalled;
what umriss takes from it, and from which secondary sources, stands in
standards.md).
Grey is the normal state. Ink on paper, the accent only for what can be operated. A value inside its limits, a fresh reading - none of them needs a colour, and none gets one beyond the muted "ok" of a verdict word.
Colour is for the abnormal. Danger, warning and success come from the verdict or the freshness - never from a component's wish to look lively.
Never colour alone. Every verdict colour stands beside a word or a glyph that says the same thing - the verdict glyph with a shape per verdict, the toast's tick, ring and triangle. A reader who cannot tell red from green, a grey-scale print and a screen reader lose nothing.
Where umriss departs, and why. The danger button and a menu's destructive item are red for a consequence, not for an abnormal state; their label names the action. The "ok" verdict is tinted green on its word, where a strict ISA-101 screen would leave normal grey - it is a verdict the caller asked for, not the resting state of the screen.
The check. packages/core/tests-unit/verdictColour.test.ts keeps a
register of every stylesheet of the library that reaches for a verdict colour,
each with what carries its meaning without the colour; a new one fails until
that is written down. It is per stylesheet, not per rule, and it cannot see
the caller's content: a badge of tone "danger" with no children is colour
alone, and only the caller can prevent it. The verdict
column holds its promise beyond the register, in its component test.
Dark theme
The dark theme is the standard color-scheme, not an attribute of the library.
Every token with two values is written light-dark(<light>, <dark>), and every
component follows the scheme its element inherits:
html.dark { color-scheme: dark; } /* a switch of the application's own */
:root { color-scheme: light dark; } /* or: follow the system */
Theme libraries (next-themes, Bootstrap's data-bs-theme, Mantine, MUI,
DaisyUI) set color-scheme themselves, so the components follow them with no
line at all. An application that sets nothing stays light, and a part of a page
can be dark on its own (style="color-scheme: dark"). The library sets
color-scheme nowhere (ADR-0021).
The decision whether to follow the system setting or to store a user's wish lies deliberately with the application – the library only provides the token values. Principles of the dark theme: no pure black, "sunken" surfaces are lighter than the surface (the layer logic is preserved), accent and semantic colours are lightened but stay muted. The primary button reverses its polarity: light surface, ink as the text.
Sizes: how wide and how tall a control is
Decided in control-sizes (ADR-0041, core 0.21.0, table 0.10.0) and accepted by
the user on the rendered pages. The demo page Sizes shows every rule below.
A field's width never follows what it shows. Not its value, its options, its chips, its files, a clearing cross that comes and goes, nor a message under it. A row of filters holds still while a person works in it - that is the one promise everything else serves. A multiselect that grew with its chips, or a select as wide as its longest option, moved its neighbours with every choice.
Fill where the place gives a width; a natural width where the place asks.
A field's root is a block with width: auto, as a div is: in a form's
column, a grid's cell, a dialog or a table's cell it fills its place. In a row,
a toolbar, a column lined up at the start or a cell sized by its content, it
is its natural width. No prop says which: the place decides, as it does for
every block on the web. A field does not grow to fill a row on its own; the
row's layout gives it room: a bar of one's own puts it in a Grid with
columns={["fill", …]}, where a field fills its cell, and the table's toolbar
lets its search take the rest with <Search grow />.
The natural width is counted in characters of the field's own type. A count
of ch - the width of a figure - plus the field's chrome (padding, chevron,
cross, steppers), which the field adds itself. Characters, not pixels: the width
grows with the type, under a finger too, where a text field writes at 16 px
and its root with it. The defaults:
| Field | Characters | Why |
|---|---|---|
| Input, Select, Combobox, MultiSelect | 20 | One count for every field that shows text. A ch is wider than the average letter a browser counts for size; a chip costs more than its letters, and twenty hold two short chips and the counter |
| NumberInput | 10 | A figure; a prefix and suffix given as text are counted on top |
| Textarea | 40 | A line of prose |
| The date pickers | their longest value | The length of the formats in use, so a date never truncates, in English or German |
| Slider, FileInput | 20, 32 | They show no text to count, and take no chars; the slider follows the text fields |
chars says the width when the value has a known length - a postcode
chars={5}, an IBAN chars={27}. A field's width tells what goes into it.
Given, the field is that wide in every place.
Never wider than its place. A field shrinks with a narrow place and ends
what does not fit in an ellipsis; a page never scrolls sideways because of a
field. A FormField holds to its place as well, and its message wraps inside
the field's width. Panels have a floor of their own (combobox 200 px,
multiselect 260 px), so a narrow field still opens a readable list.
Two heights, one name, one scope. Every control with two heights takes
size, sm or md - never another name (selectSize was the last). md is
the default; sm is for toolbars and dense forms. A place that is dense says
it once with ControlSizeProvider - the table's toolbar is one - and every
control inside follows unless it says its own. A surface of its own (popover,
dialog, drawer, tooltip) starts without the place's size: a dialog opened from a
small toolbar keeps a dialog's controls.
Presentation on the root, behaviour on the control. A wrapped field puts
className and style on the wrapper a place lays out and sizes, and ref,
name, value and the handlers on the native control a form library reaches
for. <Input style={{ width: 120 }} /> sizes the field.
Building a new field follows from this:
- A root that is not the native element (Firefox applies no size containment
to a bare
<input>), withclassNameandstyleon it. - The root composes
extentfrom#own-styles(andfieldRootif it holds a text field), sets--_chrometo its padding and glyphs, and--_charsif 20 is not its count. charsas a prop, handed toextentStyle()(lib/extent.ts);sizethroughuseControlSize()(lib/controlSize.tsx).- Add it to
controlSize.test.tsxandfieldWidth.test.tsx, and to the Sizes page if it brings a case the page does not show yet.
Open - nothing the acceptance left for later is open now. Answered: the default counts (07),
the room a multiselect leaves (08, kept), the slider's readout (09, the room of
the longer of min and max as formatted), a field that fills a row (10, no
prop - Grid) and #own-styles copied into the bundle once per module (06,
the build folds the copies into the last one; core's stylesheet 228 kB to
158 kB).
One count is the rule; a full row is the exception. Decided 2026-10-10
(control-sizes 07): every field that shows text is 20 characters wide where the
place asks. A row too full for it says chars on the field that can be
narrower.
Rows: a table holds still
Decided in row-pitch (ADR-0042). The promise is the one the widths gave a
row of filters: a table holds still while a person reads it - while it
pages, filters, groups and reloads.
Every line is one row pitch. The head, a row, a group header, a placeholder and the footer are one whole-pixel line and the density's padding tall, and never less than a small control with 4 px above and below: regular 36 px, compact 27 under a mouse and 35 under a finger. A whole pixel is the point: a row of 36.5 px was rounded three ways by three engines.
One line per value. Nothing in a cell wraps; text ends in an ellipsis, and
a tip shows a cut value whole. A column without a width grows with its values
up to min(20rem, 60vw); a column with a width is exactly that wide. A whole
record is the row detail's.
Small controls in the table. The table sets ControlSizeProvider size="sm", so a button, select or badge in a cell fits the pitch.
A page keeps its height. It keeps the most rows it has shown: a short last
page, a search or a filter that leaves a few rows fill up, a grouped page counts
the headers it repeats, the first load shows a page of placeholders, an empty
result keeps the page's height with its message at the top, and loading over
rows dims them after --u-delay-stale instead of replacing them.
The words for all this
This document describes the language. CONTEXT.md fixes the vocabulary, and the
two do not repeat each other — where a word here has a definition, it stands
there: Token, Vocabulary, Edge, Shadow step, Tone,
Glyph, Motion origin, the Interaction-state canon, Natural width,
Control size and Row pitch.