02 / Tokens
The contract
Every name is a Scale, a Role or a Mix, and a component may reference any of them. There is no raw layer underneath: a second name for the same value buys nothing. A skin reassigns the values. The names never move.
Every token, by category
This is every token in the contract, ordered by what a name is, then what it paints, then the set it belongs to. Every example is drawn with the tokens themselves, so the page moves when the contract does. The sections after it do not repeat any of this — each one argues why its part of the contract is the way it is, and what to check before adding to it.
Scale
65 tokensA series of values for one property, with the step in the name: --se-space-1 through --se-space-8, --se-text-xs through --se-text-4xl. Nothing lands between two steps. To add one, say why the two either side do not cover the case.
Type · 25
Space · 8
Form and focus · 15
Icons · 3
Motion · 7
Layout · 2
Dials · 5
Role
54 tokensOne name for one job. --se-text-muted says what the color is for, not how much of it there is. To add one, name the job and check that nothing already names it.
Color · 23
Surfaces — what sits behind everything
--se-bg--se-surface-*Borders — every edge in the system
--se-border-*--se-accent-lineText on a light surface — two tiers, and that is the ceiling
--se-text-*Text on a dark or colored surface
--se-text-*Accent and status
--se-accent-*--se-positive--se-negative--se-warningType · 5
Space · 3
Form and focus · 9
Icons · 2
Layout · 8
Dials · 4
Mix
6 tokensA percentage or a color that exists only to derive another value. --se-mix-hover is 88%, and a component passes it to color-mix(). None of these is used on its own.
Dials · 6
Color — grouped by what it paints
The grouping above 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.
Color — accent and status
Five colors 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 colored dot. Color 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 colors in the contract. A component declares one intent color 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, color 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 color 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.
/* every step carries the space dial */ --se-space-4: calc(1rem * var(--se-density)); /* layout rhythm is the only space that is fluid */ --se-section-y: clamp(3.5rem, 8cqi, 5.5rem); --se-page-gutter: clamp(1.25rem, 4cqi, 2rem); --se-stack-gap: clamp(1rem, 2.5cqi, 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 cqi term — a preferred value of pure cqi 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) cqi
The ramp measures cqi, so a project title sizes itself to the card it sits in rather than to the window. A region opts in with data-se-container; outside any container cqi falls back to the viewport, so a page that never opts in reads as it always did.
Weight — roles, not numbers
Three is deliberate. Two forces you to fake emphasis with color 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="portfolio"] {
--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
/* 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
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 behavior and the two must never disagree.
The binding holds for surfaces, not for controls. A slider thumb takes --se-shadow-raised at rest and --se-shadow-floating on hover — literal lift under a pointer, saying nothing about dismissal. Read a level as an obligation only where something opens over the page.
--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.
Two deliveries, and one test tells you which. Does the mark sit in the type stream? A glyph beside a label takes its size and color from the text next to it, so a font renders it and --se-icon-* becomes its font-size. An illustration has its own dimensions and its own colors, so it stays an SVG. The test is not decorative — every icon here is aria-hidden, so that word is spent. It is whether the mark takes anything from the text beside it. A two-color status mark at 16px is an SVG: a font cannot hold two colors.
The box is ours; the glyph is never ours. The anatomy owns the box, the alignment and the color: --se-icon-* as font-size for a glyph, as drawn size for an SVG, and currentColor for both. What fills it is yours — an inline <svg>, or an element carrying whatever classes your icon font wants. A vendor class in your markup is your business. One in ours would be the contract taking a dependency.
The box is one step above the text it sits beside. A glyph’s drawn mass sits inside its box with air around it. An icon matching its text size exactly reads light next to the letters; one step up makes them look the same size. --se-icon-sm sits beside --se-text-sm and below, --se-icon-md beside --se-text-md, --se-icon-lg beside --se-text-lg.
A face token is a slot, not a switch. --se-font-icon and --se-font-icon-strong name the two faces for a font whose glyphs are addressable as text. They cannot override a font that ships its own stylesheet — checked against Phosphor 2.1.2, whose ligatures are disabled, whose glyph rules are scoped per weight class, and whose base class sets font-family with !important. With that font the app selects the weight in markup and the contract sizes the result.
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 */ --se-icon-weight-strong: bold; /* beside a medium-weight label */ --se-font-icon: "Phosphor", sans-serif; --se-font-icon-strong: "Phosphor-Bold", sans-serif; /* One box, whatever fills it. A glyph reads font-size; a drawing reads the box. --se-icon-sm is the default because it pairs with --se-text-sm, which is every text role but one. `color` needs no declaration: it inherits, which is how an icon follows the Button's text color or the Alert's intent. `fill` does need one, and it belongs on the part — fill inherits too, so a bare
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;
--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 layout roles read
The layout roles 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
data-se-density="compact"
Dense lists, asset tables, the inspector.
Default
--se-density: 1
Everything else. The value a skin inherits.
Spacious
data-se-density="spacious"
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 */
/* consumed at the token layer — every reader of a token inherits the dial */
--se-space-4: calc(1rem * var(--se-density));
--se-text-md: calc(clamp(1rem, 0.97rem + 0.13cqi, 1.0625rem) * var(--se-font-scale));
--se-control-h: calc(var(--se-text-md) * 2.5); /* the box tracks its text */
/* a consumer turns the dial at the root, or in scope with the attribute */
:root { --se-density: 0.9; }
<aside data-se-density="compact">…</aside>
Wired at the token layer. The dials were declared before anything read them, and a token that looks live but is inert gets set and trusted. Now the space steps carry --se-density and the type steps carry --se-font-scale inside the generated CSS, so a component consumes both by reading the tokens it already reads. At the root the dial is set as a property; in scope it is the data-se-density or data-se-font-scale attribute, because a custom property resolves where it is declared and the attribute is what re-declares the riding tokens at the boundary. DataTable’s compact mode is still its own data-density attribute — a per-table shape, not the app dial.
Choice controls — a box, not a control height
A checkbox is a small box inside a taller row, so it does not take --se-control-h. Applying that token here would make every checkbox 40px. The row meets the touch floor under (pointer: coarse); the box inside it is a different measurement.
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. Each step is a multiple of --se-text-md, the type root, so the box tracks the text it wraps — through --se-font-scale too, which is what stops a smaller type scale leaving a full-height control around tiny text.
The floor holds whatever the dials say: --se-target-min is a fixed 44px — an accessibility floor, not a proportion — and the (pointer: coarse) rules read it directly, 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.