02 / Tokens
The contract
Two tiers. Primitives are raw values with no opinion. Semantic tokens are the only thing components are allowed to reference, and they resolve to primitives. Swapping a skin means reassigning the semantic tier.
Colour — grouped by what it paints
Seventeen values, and the grouping is the specification: a token belongs to exactly one job. A surface never doubles as a border, a text tone never fills a shape. Where a softer tone is needed it is derived with color-mix rather than added here.
Surfaces — what sits behind everything
Borders — every edge in the system
Text on a light surface — two tiers, and that is the ceiling
Text on a dark or coloured surface
Colour — accent and status
Five colours that carry meaning rather than structure. Each is a single value used as text or fill — which is why none of them is named --se-text-* any more. Badges, soft buttons and alert backgrounds derive from these with color-mix; there are no separate surface tokens for status.
In use
Published·maya.studio — live since 12 March
Failed·DNS record could not be verified
Unsaved·3 sections edited since last publish
Add a short line about this project
Connect a domain — available on Pro
Every status tone clears 4.5:1 on the canvas, so status is read as text rather than inferred from a coloured dot. Colour never carries the meaning alone — each pairs with a word, because roughly one man in twelve cannot separate the positive and negative hues.
Derived tones — mixed, never authored
Hover, press and the soft tints are not colours in the contract. A component declares one intent colour and every other state is mixed from it in oklch, so the darkening is perceptually even across a blue, a green and a red — and a skin overriding an intent gets correct states for free. The swatches below are live color-mix, not pasted values.
[data-se="button"][data-intent="negative"] { --se-intent: var(--se-negative); }
[data-se="button"][data-emphasis="solid"]:hover {
background: color-mix(in oklch, var(--se-intent) var(--se-mix-hover), var(--se-mix-shade));
}
[data-se="button"][data-emphasis="outline"]:hover {
background: color-mix(in oklch, var(--se-intent) var(--se-mix-soft), var(--se-bg));
}
Two text tiers, not three
There was briefly a third grey between --se-text-muted and the AA floor, and a fourth for placeholders. Measured, they sat at 5.3:1, 5.0:1 and 4.8:1 — three tokens spanning less than half a step of contrast, which no reader could tell apart. They are now one.
The constraint is arithmetic, not taste. Muted sits at 5.3:1 and the floor is 4.5:1, so there is roughly one usable step beneath the primary tone and no more. On a light canvas, colour carries two levels of de-emphasis; everything below that comes from size, tracking and caps. --se-text-placeholder survives as a name because it says where it belongs, but it resolves to muted rather than to a colour of its own.
Space — a 0.25rem root, non-linear
In rem, never px. If type scales with the reader's browser setting and spacing does not, the layout gets tighter the more someone needs it not to be. Steps 1–4 are linear for component internals; 5–8 open up for layout, where 4px is invisible anyway.
/* layout rhythm is the only space that scales */ --se-section-y: clamp(3.5rem, 8vi, 5.5rem); --se-page-gutter: clamp(1.25rem, 4vi, 2rem); --se-stack-gap: clamp(1rem, 2.5vi, 1.5rem);
Steps 1–4 are never clamped. A 4px gap that becomes 3px saves nothing, breaks alignment with the px borders and radii, and leaves you unable to tell whether two things sit on the same step. It would also fight the density dial for the same number.
Type — fluid, one family
Archivo at 400, 500 and 600, with --se-font-code for anything whose characters must align and --se-font-meta for labels. Small sizes are fixed and large sizes are fluid: body text that reads 14px on a phone and 22px on a desktop is not better typography. Every fluid step carries a rem term alongside the vi term — a preferred value of pure vi ignores browser zoom and fails WCAG 1.4.4.
/* minSize at minWidth → maxSize at maxWidth, all rem */ slope = (maxSize - minSize) / (maxWidth - minWidth) intercept = minSize - slope * minWidth preferred = intercept rem + (slope * 100) vi
Swap vi for cqi inside components and a project title sizes itself to the card it sits in rather than to the window. Page chrome uses vi; components use cqi.
Weight — roles, not numbers
Portfolio
--se-weight-regular
400
Body copy and long-form. Anything a reader spends more than a sentence on.
Portfolio
--se-weight-medium
500
UI labels, table keys, buttons, navigation. Short strings that need to hold their own.
Portfolio
--se-weight-semibold
600
Headings at every level. The only weight above medium the system uses.
Three is deliberate. Two forces you to fake emphasis with colour or size; four invites 600 and 700 to sit next to each other, which nobody can distinguish at 14px and which makes a system look undisciplined.
Weights are tokens here for one reason only: the family is skin-replaceable, so anything that depends on the family has to be replaceable too. A skin loading a serif with no medium maps the role to the nearest real weight rather than letting the browser synthesise one.
:root { font-synthesis: none; } /* fail visibly, never fake a weight */
[data-se-skin="studio-edit"] {
--se-weight-regular: 400;
--se-weight-medium: 500;
--se-weight-semibold: 600;
}
[data-se-skin="editorial"] { /* family ships no medium */
--se-weight-regular: 400;
--se-weight-medium: 400;
--se-weight-semibold: 700;
}
Tracking — a token for the same reason
Negative tracking that looks correct on a grotesk looks broken on a serif, so a skin that changes --se-font-sans must be able to change these with it. Values stay in em so they scale with whatever size the clamp resolves to.
What is not tokenised: font-style, text-transform, text-decoration. A skin never needs to decide whether a label is uppercase — that belongs to the component.
Line height — unitless, always
The one token where rem is not just unnecessary but wrong. A line height carrying a unit computes to a fixed length and inherits that length — so a container set to 1.5rem passes 24px of leading down to a 32px heading and the lines overlap. Unitless inherits the ratio instead, and every descendant recomputes its own leading from its own size.
--se-leading-body · 1.6
A portfolio is read at arm's length on a phone, usually once, usually quickly. Generous leading is what lets someone skim it without losing their place between lines.
1.25 · too tight for body
A portfolio is read at arm's length on a phone, usually once, usually quickly. Generous leading is what lets someone skim it without losing their place between lines.
/* wrong — the child inherits 24px of leading for 32px type */
.card { line-height: 1.5rem; }
.card h2 { font-size: 2rem; }
/* right — the child inherits the ratio and recomputes */
.card { line-height: var(--se-leading-body); }
.card h2 { line-height: var(--se-leading-heading); }
Leading tightens as size grows, which is why there are six values rather than one. It also has to move with --se-font-sans: a family with a taller x-height needs more leading at the same ratio, so a skin changing the face should expect to adjust these. There is one legitimate use for a length here — snapping a component to a fixed baseline grid — and it is set per element, never inherited.
Corners — five values, no more
--se-radius-none
0
Tables, full-bleed media, brutalist skins
--se-radius-sm
0.3125rem
Chips, swatches, small cells
--se-radius-md
0.4375rem
Buttons, inputs, list rows
--se-radius-lg
0.75rem
Cards, panels, dialogs
--se-radius-pill
999px
Tags, avatars, toggles
Corners are in rem, not px. Border width has to stay in px because fractional pixels render as a grey smear, but a radius antialiases cleanly — so the argument that pins borders does not transfer to corners. If type and spacing both grow with the reader's root font size and corners do not, a reader at a 24px base gets a UI where everything scaled but the corners, which then read tight and hard.
Controls that ship in several sizes are the exception and use em instead, so a button at 13px and the same button at 20px keep the same shape rather than the same absolute corner. Cards and panels have no size variants, so rem is right for those. pill stays a constant because it is not a measurement — it just has to exceed half the height.
/* nested corners: outer minus the padding between them,
or the two curves sit at visibly different centres */
--se-radius-nested: calc(var(--se-radius-lg) - var(--se-space-3));
[data-se="button"] { border-radius: 0.45em; } /* has size variants */
[data-se="card"] { border-radius: var(--se-radius-lg); }
Five values, and the scale stays short deliberately. Radius is the most brand-expressive token in the contract — a skin setting everything to none reads brutalist, one setting everything to pill reads consumer-friendly. It is the token most likely to be overridden wholesale, so there should be as little of it as possible to override.
Elevation — distance means dismissibility
Four levels, named for what they are rather than how deep they sit. The scale is not decorative: how far something floats above the page tells the reader how to get rid of it, so each level is bound to a dismissal behaviour and the two must never disagree.
--se-shadow-raised
Cards, project tiles, list rows
Not dismissible
--se-shadow-floating
Popovers, menus, dropdowns, tooltips
Click outside
--se-shadow-overlay
Dialogs, sheets, publish confirmation
Esc · traps focus
--se-shadow-dragging
Section being reordered in the builder
Release
--se-shadow-none: none; --se-shadow-raised: 0 1px 2px rgba(38, 41, 52, 0.06), 0 1px 1px rgba(38, 41, 52, 0.04); --se-shadow-floating: 0 4px 12px -2px rgba(38, 41, 52, 0.10), 0 2px 4px -1px rgba(38, 41, 52, 0.06); --se-shadow-overlay: 0 16px 40px -8px rgba(38, 41, 52, 0.18), 0 4px 12px -4px rgba(38, 41, 52, 0.10); --se-shadow-dragging: 0 24px 48px -12px rgba(38, 41, 52, 0.24), 0 8px 16px -8px rgba(38, 41, 52, 0.12);
Every level is two shadows: a tight contact shadow for the edge and a wide diffuse one for the ambient cast. A single-layer shadow reads as a sticker rather than as height. The tint is drawn from --se-text rather than pure black, because black over a warm canvas goes flat grey.
Shadow never carries a boundary on its own. A skin is free to set all four to none — a flat or brutalist identity will — so components always draw a border and add elevation on top of it. If removing the shadow makes a dialog ambiguous, the dialog was relying on the wrong thing.
Dark skins get one exception. Shadow barely reads on a dark surface, so elevation there comes from surface lightness instead: --se-surface lifts to --se-surface-raised as things rise, and the shadow tokens drop to a plain ring. The component reads both and does not need to know which strategy the skin chose.
Focus — the one token a skin may not remove
A creative's skin can flatten every shadow, square every corner and replace the whole palette. It cannot delete the focus ring, because for a keyboard or switch user that is the cursor. The ring is therefore owned by the anatomy layer and exposed to skins only as tokens — a skin restyles it, and has no selector with which to remove it.
Tab through these
--se-focus-width: 2px;
--se-focus-offset: 2px;
--se-focus-color: var(--se-accent);
/* owned by the anatomy, not the skin */
[data-se] :focus-visible {
outline: var(--se-focus-width) solid var(--se-focus-color);
outline-offset: var(--se-focus-offset);
}
One case the offset does not solve: a skin whose accent is close in lightness to its own surface. There the ring needs two tones — an inner band in --se-surface and an outer band in the accent — so one of the two always contrasts. Worth building into the anatomy from the start rather than discovering it when a creative picks a pale yellow.
Icons — bring your own set
The system does not own the icon set. If a component imports a specific glyph then that library is part of the contract and a skin can never swap it — the same failure as a hardcoded hex. Components define the box an icon sits in; the app fills it. Studio Edit fills it with Phosphor.
Phosphor earns the pick on its weight axis. Six weights of the same drawing let a skin move the whole icon set from thin to bold and stay coherent with its type — the icon equivalent of --se-weight-*.
thin
100
light
300
regular
400
bold
700
fill
—
duotone
—
In context — regular, inheriting currentColor
--se-icon-sm: 1rem; /* beside --se-text-sm */
--se-icon-md: 1.25rem; /* beside body */
--se-icon-lg: 1.5rem;
--se-icon-weight: regular; /* thin | light | regular | bold | fill | duotone */
[data-se] [data-part="icon"] {
inline-size: var(--se-icon-md);
block-size: var(--se-icon-md);
color: currentColor;
flex: none;
}
Phosphor is a dependency of Studio Edit, not of the design system. Install it in the app and let tree-shaking take only what is used — do not vendor the SVGs into the system, or you inherit the maintenance and lose upstream fixes. The one exception is a build-time sprite, generated from the package rather than copied by hand. The specimens above are illustration for this document; the contract only specifies the box.
Motion — duration follows distance
Four durations, bound to the same ladder as elevation. How far something travels and how long it takes are one decision: a hover state that takes 240ms feels broken, and a dialog that arrives in 80ms was never anywhere. Hover any tile below to see the step.
--se-duration-instant
80ms
Hover, press, checkbox, focus ring
No travel
--se-duration-fast
160ms
Popover, tooltip, menu, tab switch
Short travel
--se-duration-medium
240ms
Dialog, sheet, panel, publish confirm
Enters the page
--se-duration-slow
400ms
Route change, builder entering preview
Whole view
--se-ease-standard
cubic-bezier(0.2, 0, 0, 1)
The default. Anything moving within the page.
--se-ease-decelerate
cubic-bezier(0, 0, 0, 1)
Entering. Arrives fast, settles gently.
--se-ease-accelerate
cubic-bezier(0.3, 0, 1, 1)
Leaving. Starts slow, exits off-screen.
--se-duration-instant: 80ms;
--se-duration-fast: 160ms;
--se-duration-medium: 240ms;
--se-duration-slow: 400ms;
--se-ease-standard: cubic-bezier(0.2, 0, 0, 1);
--se-ease-decelerate: cubic-bezier(0, 0, 0, 1);
--se-ease-accelerate: cubic-bezier(0.3, 0, 1, 1);
/* exit is quicker than entry — nobody waits to dismiss something */
[data-se="dialog"][data-state="open"] [data-part="content"] {
transition: opacity var(--se-duration-medium) var(--se-ease-decelerate),
transform var(--se-duration-medium) var(--se-ease-decelerate);
}
[data-se="dialog"][data-state="closed"] [data-part="content"] {
transition-duration: var(--se-duration-fast);
transition-timing-function: var(--se-ease-accelerate);
}
Motion (motion.dev) is the implementation, not the contract. Its core is framework-agnostic, so the same call works in React, Vue and plain JavaScript, and it runs on the Web Animations API rather than a render loop. It reads these tokens; it does not replace them. A component that configures its own timing is the motion equivalent of a hardcoded hex.
Breakpoints — there are two
This is the complete list, and it exists for one reason: column count is a discrete decision. There is no 7.4 columns, so nothing can interpolate between four and eight. Every other responsive decision in the system is continuous and needs no breakpoint at all.
/* a media query cannot read a custom property, so the source
of truth is a build-time definition and the CSS variable
exists only for JavaScript to read */
@custom-media --se-bp-md (min-width: 40rem);
@custom-media --se-bp-lg (min-width: 64rem);
:root { --se-bp-md: 40rem; --se-bp-lg: 64rem; } /* for JS only */
@media (--se-bp-md) { :root { --se-grid-columns: 8; } }
@media (--se-bp-lg) { :root { --se-grid-columns: 12; } }
One case sits outside the two: the builder collapsing its side panels into tabs. That is a genuine change of shape rather than a reflow, so it earns a media query — but it belongs to that component, not to the system, and it is declared where the builder is declared.
What changes at each threshold — and nothing else
This table is the contract; the rem values are only how it is implemented. A breakpoint with no stated effect becomes an open invitation — one team reaches for it to move the nav, another to change a card, and the layout stops being predictable. If a change is not in this table it does not belong at a breakpoint, which in practice means it belongs in a container query or a clamp.
Not width at all
--se-container-max: 72.5rem; /* 1160px — a cap, not a breakpoint */
@media (pointer: coarse) { :root { --se-density: 1; } }
@media (hover: hover) { /* every hover rule in the system lives here */ }
@media (prefers-contrast: more) { /* high-contrast skin */ }
@media (prefers-color-scheme: dark) { /* dark skin as initial state */ }
The content cap is worth naming even though it is not a breakpoint. Layout stops growing at --se-container-max and the gutters absorb the rest, which is a real decision about measure — text past roughly 75 characters a line gets harder to track back from. Left unnamed it gets re-invented as a literal in every new view.
Layout floors — what the primitives read
The layout primitives each read one floor. Naming them is what stops the same 14rem appearing as a literal in four different grids and then drifting apart.
Density — three steps
Compact
--se-density: 0.75
Dense lists, asset tables, the inspector.
Default
--se-density: 1
Everything else. The value a skin inherits.
Spacious
--se-density: 1.25
Published portfolios, touch, marketing pages.
The label is 14px in all three. Density moves space, never type — someone switching a project list to compact wants more rows at the same readable size, and a compact mode that shrinks the text is a compact mode people turn off. That is why there are two dials rather than one.
--se-density: 1; /* space only */
--se-font-scale: 1; /* text only, rarely touched */
[data-se="button"] {
font-size: calc(var(--se-text-sm) * var(--se-font-scale));
padding: calc(0.7em * var(--se-density)) calc(1.25em * var(--se-density));
gap: calc(0.5em * var(--se-density));
border: var(--se-border-width) solid var(--se-border);
min-block-size: max(var(--se-target-min), calc(2.5em * var(--se-density)));
}
Control height — one source, three steps
Set as block-size rather than a minimum, so no state can change it: a focus border is absorbed inward by border-box and an outline never enters layout. Buttons and fields read the same token, which is what lets them sit level in a row.
That last max() is the floor. Padding may shrink freely; a touch target may not go below 44px, so compact tightens the visual density without producing an interface nobody can tap.
Which unit, where
And the functions that make them safe
The functions matter as much as the units. Almost every rule in section 06 is really a unit paired with the function that bounds it.
One rule keeps em safe: it compounds, so a button inside a card inside a compact panel can end up three multiplications deep. Every component resets its own font-size from a rem token at its root. em is then free to work inside a component and never crosses a component boundary.