The whole
system, on
one page
Every token, every component and every motion curve available in Pure Design, rendered live in all four modes. Click any colour to copy its token name.
- Identity
- madsondeluna.github.io
- Architecture
- devouringdetails.com
- Motion
- motion.dev
- Verified by
- tools/check.mjs
The slate ramp
Fifteen steps, and every semantic token in the light, paper-like and deep-blue modes resolves to one of them. The five anchors of the original palette are still here: 950, 850, 700, 500 and 50.
| Pair | Ratio | WCAG verdict | Role |
|---|
Eight slots,
fixed order
The interface palette cannot carry series identity. The brand slate has an OKLCH chroma of 0.055, below the 0.10 floor where a hue stops doing identity work: used as a series it reads as grey. The data family is derived, with the same blue saturated into slot one.
Order is the safety mechanism, so slots are assigned in sequence and never cycled. A ninth group folds into Other, becomes facets, or gains a second encoding.
The worst performer in your creamery is Rocky Road, down −6% or −$2,453.44.
This is where the two colour families meet and where it is easiest to get wrong. The change is state: --status-good and --status-critical. The dot beside each name is series: --chart-1 and --chart-2, assigned in slot order. Neither ever borrows the other's job.
Three families,
three jobs
Radius follows
the role
Four textures,
one material
Glass is the material of every control in this system. It has five layers and all of them matter: a vertical gradient fill, a backdrop blur, saturation, a specular highlight on the top edge and refraction on the bottom. Remove any one and the result reads as a plain translucent panel.
The four textures differ in how much of the background survives. Thin lets almost everything through, deep almost nothing. Frost is the only one that carries grain: a desaturated turbulence over the fill, which is what separates frosted from merely translucent.
| Texture | Blur | Use it for |
|---|---|---|
| .glass-thin | 4px | Overlay where the content behind must stay readable: a legend over a chart, a caption over an image |
| .glass | 16px | The default control: pill, chip, tooltip, small floating panel |
| .glass-frost | 30px | Anything that carries text of its own: toolbar, command palette, sheet, modal |
| .glass-deep | 56px | Full-width chrome that must separate itself from the page: navigation bar, docked footer |
| .glass-accent | inherits | The one selected or primary control in a group, never more than one at a time |
The fifth material is not a fifth texture
Glass filters itself, so the text it carries goes through the blur with it. That is why glass never merges: fusing two neighbouring controls would melt both labels.
Liquid splits the material from the content into two layers stacked in one grid cell. The silhouette sheet, behind, is the only filtered thing on the page and carries the goo filter and the shadow. The real DOM rides on top, unfiltered, crisp, with its focus ring, hit area and ARIA intact.
The two layers are mirrors: same display, same gap, same box per unit. That is how the drop sits exactly under the control without a line of JavaScript measuring anything, and it is also the price. A cluster unit has a fixed size, because a width that depends on the text can only be mirrored by measuring the text.
| Filter | Deviation | Slope | Intercept | Bridges up to |
|---|---|---|---|---|
| #pure-goo-tight | 4 | 20 | -7.83 | 4px |
| #pure-goo | 6 | 18 | -7 | 6px |
| #pure-goo-wide | 12 | 16 | -6.17 | 10px |
Five reads, and none of them writes a transform
Every liquid animation writes the registered tokens and the composition stays the one transform declaration in the chapter. That is what lets the material move without reopening the defect of two rules fighting over the same element.
The tokens are registered with @property, which is what makes them interpolate. An unregistered custom property is text: it jumps from one frame to the next, so a keyframe writing --liquid-swell would step instead of flow.
What the library does with a spring, this does with a curve, and the difference is declared rather than hidden. A spring overshoots on its own and keeps going past the target; reproducing it means integrating every frame, which is JavaScript. What crosses over is the shape of the motion, and the shape is what you recognise: the bridge stretching, the neck thinning, the drop letting go.
| Class | Read | Mechanism | Needs state |
|---|---|---|---|
| .liquid-trail | The mass stretches behind a moving unit | Phase difference from transition-delay | Yes |
| .liquid-drip | A drop stretches, necks and lets go | Keyframes on shift and stretch | No |
| .liquid-morph | A round trigger becomes the surface it opens | Per-axis stretch of the silhouette | Yes |
| .liquid-pulse | The cluster breathes while something is pending | Out-of-phase swell loop | No |
| .is-flux | Content cross-blurs while the mass flows | --motion-blur-1 on the content layer | Yes |
Glass stands still. The light does not.
The glass in this language is a still material: it blurs what is behind it, lights the top edge and pools a shadow at the base, and it does all three the same way wherever the pointer happens to be. That is what an honest material does, and it is why glass never needed a script.
The light layer does not change the material. It adds the source. A real glass surface has a highlight that travels when the light travels, a rim that lights on the side the light comes from, and a depth that only shows when the observer moves. The pointer is the observer, and it is the one thing on this page a stylesheet cannot read. That, and only that, is why this layer ships with JavaScript.
The contract is three numbers. light.js measures a box and writes --light-x, --light-y and --light-near. It knows nothing about highlights, rims, tilt or magnets: no effect name appears anywhere in that file. Every appearance decision lives in light.css, which is why a new effect in this layer touches no JavaScript at all.
The surface turns toward the pointer and settles back on its own. No second state, no hover rule.
| Class | Read | Writes | Under reduced motion |
|---|---|---|---|
| .lit | A specular spot travels with the pointer | Opacity of a negative-index plane | Stays |
| .lit-edge | The rim lights on the side the light comes from | Offset of an inset shadow | Stays |
| .lit-tilt | The surface turns toward the pointer | --light-turn-x/y | Rests |
| .lit-pull | The control drifts toward the pointer before it arrives | --light-shift-x/y | Rests |
| .lit-swell | The control grows as the pointer nears | --light-grow | Rests |
| .lit-field | The section lights behind the glass that sits on it | Opacity of a negative-index plane | Stays |
| .lit-cursor | One thin drop of glass rides with the cursor | --light-shift-x/y | Hidden |
| .lit-stage | Gives the tilt a vanishing point | perspective | Unaffected |
Five curves,
each with a job
| Pattern | Curve | Duration |
|---|---|---|
| Colour, background and border on hover | ease-standard | 200ms |
| Content and section entrance | ease-out-soft | 500ms |
| Slide change, scale change | ease-out-expo | 300ms |
| Hover displacement, the pill nudge | ease-swift | 200ms |
| Chart line drawing itself in | ease-out-soft | 500ms |
| Chart bar growing from its base | ease-out-expo | 350ms |
Three shapes, one job. The elapsed reading is mono with tabular figures: a proportional face would make the whole line twitch every tenth of a second. The sweep across the label is a pseudo-element moved by transform, not by background-position, so it composites off the main thread.
Opening does not animate height. The body leaves the flow with [hidden] and each step settles with a staggered fade on the way back. Animating grid-template-rows would buy a continuous transition at the cost of layout on every frame, and the language does not pay it.
Each chunk enters on opacity plus a 2px nudge, staggered by --stream-step. The original blurred every chunk on entry; filter: blur repaints the box each frame and never composites, so the entrance was rewritten. It reads the same, it does not cost the same.
What did not port verbatim
- Trace opening
- grid-template-rows became hidden plus staggered fade
- Streamed chunk
- filter: blur became opacity plus a 2px nudge
- Confidence
- width growth became scaleX from the left edge
- Chip reveal
- max-width and margin became transform and opacity
- Prompt radius
- the rounded-to-pill morph was dropped
- Group headings
- all caps became sentence case
Twenty-one recipes, remapped onto the scale that already existed
The recipes come from the transitions.dev catalogue by Jakub Antalik, and they were not copied: they were remapped. The catalogue carries its own scale, seven durations and six curves, and this language has six durations and five curves and forbids a new one.
The remap follows the rule the catalogue itself states, match by use and not by number. A 300ms modal close becomes --duration-2 at 150ms because both are "modal close", even though the numbers are far apart. The dominant catalogue curve, cubic-bezier(0.22, 1, 0.36, 1), is --ease-out-expo here to three decimal places of intent.
Three px values were born with the layer, and they are blur radii, not a scale: --motion-blur-1, -2 and -3. They blur the element itself while it moves and clear on settle, which is the opposite of glass, where the blur is on the backdrop and stands still. Mixing the two families is how a card ends up blurred at rest.
| Use | Class | Duration | Curve |
|---|---|---|---|
| Anchored surface opens | .motion-dropdown | 200 / 150 | --ease-out-expo |
| Centred surface opens | .motion-modal | 300 / 150 | --ease-out-expo |
| Notice rises from below | .motion-toast | 350 / 150 | --ease-out-expo |
| Hint over an icon | .motion-tip | 150 | --ease-out |
| Two icons, one slot | .motion-icon-swap | 200 | --ease-standard |
| Label changes in place | .motion-text-swap | 150 | --ease-standard |
| Stacked lines enter | .motion-lines | 500 | --ease-standard |
| Badge appears | .motion-badge | 350 | --ease-out-expo |
| A number updates | .motion-digits | 500 | --ease-out-expo |
| Validation failed | .motion-shake | 350 | --ease-standard |
| Placeholder becomes content | .motion-reveal | 350 | --ease-standard |
| Progress text stays alive | .motion-shimmer | 2000 | linear |
| Highlight follows the tab | .motion-tabs | 200 | --ease-out-expo |
| Action confirmed | .motion-check | 500 | --ease-out-expo |
| List becomes detail | .motion-pages | 200 | --ease-out-expo |
| Notices pile up | .motion-stack | 300 | --ease-out-expo |
| A counter runs to its value | .motion-counter | 500 | --ease-out-expo |
| A wait closes into a ring | .motion-spin | 350 | --ease-out-expo |
| Long text stays alive | .motion-wave | 500 | linear |
| A link opens one step | .motion-learn | 200 | --ease-out-expo |
| Portraits take turns | .motion-cycle | 500 × 16 | --ease-standard |
Rules the build
has to pass
Everything before this section is material: colour, type, space, glass, motion. This one is behaviour. It is the part no screenshot shows and every user meets in the first ten seconds: whether the keyboard reaches the control, whether the tap lands, whether the form hands the error back before the round trip or after it.
Three levels. Must fails the build or the review. Should is the default, and leaving it needs a written reason. Never has no exception.
| Rule | Level | Bound to |
|---|---|---|
| Every interactive element is reachable and operable from the keyboard, following the authoring practice for its pattern | Must | native semantics |
| The focus ring is visible on :focus-visible, and the group lights up on :focus-within | Must | --focus-ring |
| Focus is managed on open, close and delete: trapped inside the panel, returned to the control that opened it | Must | .modal .overlay |
| The outline is removed without a visible replacement | Never | check.mjs |
| Over glass the ring switches to the ink colour, for the same reason the text does: the backdrop is unpredictable | Should | .glass:focus-visible |
| Rule | Level | Bound to |
|---|---|---|
| The hit area clears 24px, and 44px under a coarse pointer | Must | --hit-min |
| The visual size may go under that floor. The hit area may not | Must | .hit |
| A text field is 16px, the single exception to one size per control class: below it iOS zooms the page on focus | Must | --text-field |
| Browser zoom is disabled through user-scalable or maximum-scale | Never | viewport |
| The double tap delay is removed, so the first tap acts immediately | Must | touch-action |
| The grey native tap flash is replaced by the language’s own pressed state | Should | --tap-highlight |
Press Tab. The ring appears from the keyboard and stays hidden from the pointer, and it turns to ink over glass. The small round control is 16px of drawing and 24px of target: switch the outline on and its hit area shows up well outside the shape, while the pills already clear the floor on their own. It carries no label, so it carries an accessible name instead.
| Rule | Level | Bound to |
|---|---|---|
| Paste is blocked in a field | Never | any field |
| Free text is accepted and validated after, never blocked while typing | Must | .input |
| An incomplete form can be submitted, so validation surfaces instead of hiding | Must | novalidate |
| The error sits next to its field, and focus moves to the first one | Must | .field-error |
| Submit stays enabled until the request starts, then shows the ring and keeps its label | Must | [data-loading] |
| Enter submits from a single line field; Cmd or Ctrl with Enter submits from a textarea | Must | .textarea |
| Every field carries autocomplete, a meaningful name, and the right type and inputmode | Must | .field |
| Label and control share one hit target, with no dead zone between the box and the text | Must | .check |
| The checkbox rounds at the mark step as a field. Only the radio is a circle | Must | .check input |
| Password managers and one time codes work, pasted codes included | Must | autocomplete |
| Values are trimmed before they are read | Must | submit handler |
| Unsaved changes warn before navigation | Must | beforeunload |
| Spellcheck is off for email, code and username | Should | spellcheck |
| The placeholder shows the shape of the answer, and ends in an ellipsis when it is an instruction | Should | placeholder |
| Autofocus on desktop when there is a single primary field, rarely on mobile | Should | autofocus |
Send it empty. Nothing was disabled, the browser did not take over, the two errors land under their own fields and the caret goes to the first one. Fix them and send again: the button keeps its label and grows a ring, and the result is announced to a screen reader through a polite live region rather than only drawn.
The textarea takes Cmd or Ctrl with Enter. The email field has its own keyboard on a phone, its own autofill entry, and spellcheck off. Every value is trimmed before it is read, which is what stops a pasted address with a trailing space from failing validation for a reason nobody can see.
| Rule | Level | Bound to |
|---|---|---|
| The URL carries the state: filter, tab, page, open panel, colour mode | Must | history |
| Back and forward restore the scroll position | Must | scrollRestoration |
| Navigation is an anchor, so modifier click and middle click still work | Must | a href |
| A div with a click handler is used to navigate | Never | a href |
| A destructive action is confirmed, or reversible inside a stated window | Must | .modal |
| A toast or an inline validation is announced through a polite live region | Must | aria-live |
| Every state offers the next step. A dead end is a defect | Must | .empty |
| An optimistic update reconciles on response, and rolls back or offers undo on failure | Should | app layer |
| An option that opens a follow up ends in an ellipsis, as does a loading label | Should | copy |
| Rule | Level | Bound to |
|---|---|---|
| Reduced motion is honoured with a reduced variant, or with nothing at all | Must | patterns.css |
| Only transform and opacity are animated, since only those are composited | Must | check.mjs |
| A layout property is animated: top, left, width, height | Never | check.mjs |
| A transition covers every property at once instead of naming the ones it animates | Never | check.mjs |
| Motion is interruptible and driven by input. Nothing plays on its own | Must | .viz |
| The transform origin sits where the movement physically starts | Must | .viz-grow |
| An SVG transform goes on a group wrapper with transform-box: fill-box | Must | svg g |
| A sheet or a drawer contains its own overscroll, so the page behind stays put | Must | .drawer |
| The first tooltip in a group waits. Its neighbours open at once | Must | .tip-group |
| During a drag, text selection is off and the dragged element leaves the event tree | Must | [data-dragging] |
| CSS first, then the animation API, then a library | Should | five curves |
| The curve matches the size and distance of the change, and controls settle rather than jump | Should | --ease-out-soft |
| Rule | Level | Bound to |
|---|---|---|
| Checked on mobile, on a laptop and on an ultra wide screen, the last one simulated at half zoom | Must | two axes |
| Safe areas are respected on a notched screen | Must | env(safe-area-inset) |
| No unwanted scrollbar. Overflow is fixed where it appears, not hidden | Must | overflow |
| A text container survives short, average and far too long content | Must | .truncate .clamp-2 |
| A flex child gets min-width zero before it can truncate at all | Must | .min-w-0 |
| An empty string and an empty array render a state, not a broken box | Must | .empty |
| Empty, sparse, dense and error are four designs | Must | .empty .skeleton |
| The skeleton copies the box of the final content, so nothing shifts when it arrives | Must | .skeleton-line |
| Contrast rises on hover, active and focus, never falls | Must | --border-hover |
| Alignment is deliberate, to the grid, the baseline or an edge | Must | .grid |
| Optical alignment wins over geometry when perception disagrees, by a pixel | Should | .optical |
| Grid and flex do the layout, not measurement in script | Should | .grid |
| A nested radius never exceeds its parent, and the two stay concentric | Should | --radius-8 |
Three cells, one rule. The long one truncates instead of pushing its neighbours out of the row, which only works because the flex child carries min-width zero.
| Rule | Level | Bound to |
|---|---|---|
| The document title says where the reader is | Must | title |
| Heading levels run in order, and the first stop of the keyboard skips to the content | Must | .skip-link |
| Anything an anchor can land on clears the fixed bar | Must | --scroll-offset |
| Numbers that will be compared use tabular figures | Must | .num |
| Status carries a second cue beyond colour, and an icon carries a text label | Must | .status |
| An icon only button carries a descriptive accessible name | Must | .sr-only |
| A decorative element is hidden from the accessibility tree | Must | aria-hidden |
| The native element comes before the ARIA role: button, a, label, table | Must | semantics |
| The ellipsis is one character, and a non breaking space holds 10 MB and Cmd K together | Must | copy |
| Dates, times and numbers are formatted for the reader’s locale | Must | Intl |
| Curly quotes, and a short heading balanced across its lines | Should | .balance |
| Brand names, tokens and identifiers are marked so machine translation leaves them alone | Should | translate="no" |
| Inline help comes first. The tooltip is the last resort | Should | .tip |
| Rule | Level | Bound to |
|---|---|---|
| The mode declares itself to the browser, so scrollbar, caret and native control follow the page | Must | color-scheme |
| A native select carries an explicit background colour and text colour | Must | .select |
| A mutation answers in under 500ms | Must | app layer |
| A list past fifty items is virtualised | Must | app layer |
| Images above the fold are preloaded, the rest are lazy, and all of them declare their size | Must | img |
| Layout reads and writes are batched, so nothing reflows twice per frame | Must | app layer |
| Profiling runs with CPU and network throttled, and with extensions off | Must | devtools |
| Contrast is measured, not judged by eye | Must | check.mjs |
| Chart colour survives protanopia and deuteranopia at full severity | Must | check.mjs |
| The browser frame colour matches the page background | Should | theme-color |
| A CDN gets a preconnect, a critical font gets a preload with swap | Should | link rel |
| Tested in low power mode and in Safari, where the frame budget is smallest | Should | device |
| Shadows are layered, ambient under direct, and borders take the hue of the background | Should | --shadow-glass-rest |
| Rule | Level | Bound to |
|---|---|---|
| A field keeps its focus and its value across hydration | Must | app layer |
| A field with a value needs a change handler, otherwise it takes a default value | Must | app layer |
| Re-renders are counted and cut down | Must | app layer |
| Date and time rendering is guarded against a mismatch between server and client | Should | app layer |
| Uncontrolled fields are the default; a controlled one has to be cheap per keystroke | Should | app layer |
How many flavours should we launch?
The question the agent asks before it acts. Options are real radios inside a fieldset: one choice, an accessible name each, arrow-key navigation, and a legend that a screen reader can announce even though the design shows no label.
The field is --text-field, 16px, not --text-12: below that iOS zooms the page on focus. The bar never blocks typing or paste. @ opens sources, / opens commands, and the key you type is mono while its description is language.
- Forecast summer demand
- Find waffle cone suppliers
- Compare seasonal flavours
- Draft flavour launch plan
- Check cold-chain status
Type to filter. Empty, sparse, dense and error are four designs, not one, and the empty state always offers the next step: a page with no way out is a defect. The list keeps overscroll-behavior: contain so the finger stops at its edge instead of scrolling the page behind.
Every field here is a number, so every field is mono with tabular figures; a proportional face moves the caret between columns as the digits change. The unit lives inside the field, not beside it, so the target stays one thing.
Pistachio holds the top slot all weekend. Churn it first thing Saturday so the batch has time to firm up before the afternoon rush.
The mark is --accent mixed down to a tint, so it stays legible in all four modes without a colour of its own. The bar arrives with fade-scale: it settles into place and never jumps.
What is
already built
Glass is the default material: gradient fill, backdrop blur, saturation, a specular highlight on the top edge and refraction on the bottom. The solid variant is for dense tables and for surfaces that are already translucent.
Three levels: underlined action, muted inline, and fade for anything already carrying its own affordance.
Three reserved hues with their own tint and border, all clearing 4.5:1 in both modes.
Section title
Number in mono with eyebrow tracking, title in the display face, a rule below, and nothing else.
Nineteen more components for interfaces where a model writes to the screen. They import nothing of their own: every colour, radius, size and curve comes from the same tokens as everything above.
The verb is language, the argument is data, so only the argument is mono. The line balance is always a pair even when one side is zero, or the column dances from row to row.
State is --status-*, never a chart slot. The glyph in the rail repeats the state in shape, because colour on its own is not an accessible signal.
Real tabs: role="tablist", aria-selected, left and right arrows moving focus. The user turn is a glass bubble on the right; the reply is plain prose on the left, because it can run long and a long bubble reads badly.
Want me to place this restock order?
Reorder waffle cones from cone_king with lead time
7_days.
Confidence is three discrete steps, not a continuous percentage: a model does not know the difference between 71% and 74%. Where a quantity really is continuous, .meter grows by scaleX from the left edge, never by width.
What the model read before it answered, with the source of each piece. The chunk is nested inside a card, so it rounds at --radius-field, never harder than its parent.
The group heading reads Workspace, not WORKSPACE. All caps is banned across the language and a group heading is not an exception; the mono face and the eyebrow tracking carry the same separation without shouting.
Code is typography, not data, so the highlight uses ink levels rather than hues. Identifiers and literals sit at --text, reserved words and calls at --muted in medium weight, comments and line numbers at --secondary. None of the eight chart slots appear here: those carry series identity, and a keyword is not a series.
| Task | Date | Status | Advisor |
|---|---|---|---|
| Restock mango sorbet | Dec 03 | To do | Mango Moon Gelato |
| Churn black sesame | Sep 22 | In progress | Kumo Creamery |
| Print summer menu | Jan 02 | To do | Coral Coast Sorbet |
| Taste-test batch 42 | Nov 08 | In progress | Maple Orbit |
| Order waffle cones | Apr 14 | Completed | Aurora Scoops |
| Flavour | Category | Supplier | Last interaction |
|---|---|---|---|
| Rocky Road | Classic | aurora-scoops | almost 2 years ago |
| Bubblegum | Retro | kumo-creamery | over 1 year ago |
| Mint Chip | Classic | maple-orbit | 9 days ago |
| Pistachio | Seasonal | maple-orbit | 15 days ago |
One table base carries all three: the filtered list, the record grid and the proposed edit. The row that leaves and the row that enters take state ink, tinted by color-mix against transparent, so no new colour enters the language to describe a diff. Dates and counts use .num because they get compared down the column.
Sixty-five, drawn to the same geometry as everything else
Until 1.6.0 the language had no icons, and the absence showed on every screen built with it: each project brought a set in from outside, with a different box, a different weight and a different cap, so the one drawing on the page that did not belong to the page was the one that repeated most often.
The weight is the whole point, and it does not live in the sprite file. It lives in .icon as var(--stroke), the same token as the spinner ring and the checkbox mark. non-scaling-stroke keeps it equal across all three sizes: without it the stroke shrinks with the box and a 16px icon reads lighter than a 24px one in the same bar.
Three sizes and no more: 20 for a control, 16 for metadata, 24 for a heading. The whole family is currentColor, so an icon inside a control takes the control's ink with no rule at all.
- search
- filter
- sort
- plus
- check
- close
- chevron-right
- arrow-right
- external
- copy
- edit
- trash
- download
- upload
- refresh
- settings
- link
- share
- send
- file
- folder
- database
- code
- terminal
- grid
- list
- table
- chart-bar
- chart-line
- image
- user
- users
- calendar
- book
- tag
- bookmark
- sparkle
- tool
- branch
- play
- pause
- stop
- molecule
- helix
- sun
- moon
- contrast
- alert
- info
- clock
- dot
- shield
- check-circle
- menu
- more
- expand
- collapse
- minus
- chevron-down
- chevron-up
- chevron-left
- arrow-left
- arrow-up
- arrow-down
The twentieth component: a flow that branches
How to
pull it in
- Web, tokens
- web/tokens.css
- Web, Tailwind
- web/theme.css
- Web, components
- web/patterns.css
- Python, plots
- pure.mpl.use("light")
- Python, plotly
- pure.plotly.use("light")
- Streamlit
- python/streamlit/config.toml
- Verify
- node tools/check.mjs