Digital Design, September 2026
Towards Easel 3.0
How the next version of our design system can save design and engineering time, with fewer decisions made twice.
Kurt, 30 September 2026. A working draft for the design team.
Part 1
Where Easel is today
What has drifted between Figma, Storybook and code, and why it keeps happening.
Easel has drifted, in Figma and in code
In Figma
- Buttons of the same size come in several different heights.
- The page gutter changes from one file to the next.
In Storybook
- The Button component is well built and consistent.
- It has no icon-only button either, so squads improvise one.
In code
- The carousel needed an icon-only button, so a squad bent the tertiary Button into shape with Tailwind overrides.
- The global header’s gutter reached production with values that differ from Figma’s.
Nobody is doing a bad job. Keeping Figma and code in step is nobody’s job, so every team makes a sensible local call and the system slowly frays.
Every inconsistency is a decision someone has to make again
First by a designer, then by an engineer, and now by an AI agent that copies whatever it finds.
Part 2
Semantic colour
Tokens, which Figma calls variables, named for their job rather than their colour, so one component works in any section.
Today a colour variable names a colour. A semantic one names its job
Today
| Layer in Figma | Fill |
|---|---|
| Heading | black |
| Caption | black-300 |
<h2 class="text-black">…</h2>
<p class="text-black-300">…</p>Says what it looks like. Right in a light section, and wrong the moment the section turns dark.
Easel 3.0
| Layer in Figma | Fill |
|---|---|
| Heading | foreground-primary |
| Caption | foreground-secondary |
<h2 class="text-foreground-primary">…</h2>
<p class="text-foreground-secondary">…</p>Says what it’s for. The section decides what it looks like.
Tokens come in three layers
Component tokens
One component’s own colours, used only inside that component.
- button-primary-background
- button-primary-foreground
- button-primary-border
- button-secondary-background
- button-secondary-foreground
- button-secondary-border
Semantic tokens
A job anywhere on the page. Designers and engineers use these every day.
- background-primary
- foreground-primary
- foreground-secondary
- border-primary
Palette
The values. Only other tokens use these.
- black
- black-300
- white
- white-alpha-60
- grey-400
- brand-c-red
In Figma, each layer is a variable collection, and each variable is an alias of one in the layer below. With its own tokens, a button can change without moving every link too. The names are placeholders until we agree them as a team.
One card, three sections
Live auction, London
Post-War and Contemporary Art
- Figma mode
- Light
- Code
data-color-scheme="light"
Live auction, London
Post-War and Contemporary Art
- Figma mode
- Dark
- Code
data-color-scheme="dark"
Live auction, London
Post-War and Contemporary Art
- Figma mode
- Red
- Code
data-color-scheme="red"
In Figma, you select the section and switch its mode. In code, the section sets data-color-scheme. The card inside is the same component each time.
On red, 60% white measures 3.9:1, under the 4.5:1 body text needs, so the Red mode uses 80% (6.1:1).
In Figma, each scheme is a mode
| Colour | Light | Dark | Red |
|---|---|---|---|
| Semantic | |||
| background-primary | white | black | brand-c-red |
| foreground-primary | black | white | white |
| foreground-secondary | black-300 | white-alpha-60 | white-alpha-80 |
| border-primary | grey-400 | white-alpha-40 | white-alpha-40 |
| Component: button | |||
| button-primary-background | brand-c-red | white | white |
| button-primary-foreground | white | black | brand-c-red |
| button-primary-border | brand-c-red | white | white |
| button-secondary-background | transparent | transparent | transparent |
| button-secondary-foreground | black | white | white |
| button-secondary-border | black | white-alpha-60 | white-alpha-80 |
In code, each mode is one block of variables
[data-color-scheme="light"] {
--background-primary: var(--color-white);
--foreground-primary: var(--color-black);
--button-primary-background: var(--color-brand-c-red);
--button-primary-foreground: var(--color-white);
--button-primary-border: var(--color-brand-c-red);
}
[data-color-scheme="red"] {
--background-primary: var(--color-brand-c-red);
--foreground-primary: var(--color-white);
--button-primary-background: var(--color-white);
--button-primary-foreground: var(--color-brand-c-red);
--button-primary-border: var(--color-white);
}The classes stay the same
<section data-color-scheme="red" class="bg-background-primary">
<h2 class="text-foreground-primary">Post-War and Contemporary Art</h2>
<Button variant="primary">View lots</Button>
</section>// Inside Button, written once
<a class="bg-button-primary-background text-button-primary-foreground border-button-primary-border" />No dark: variants and no second version of the component. A red section is one more block of variables, and it changes the button tokens too, so a red button on white becomes a white button on red.
In Tailwind 3, replacing the default palette leaves semantic names as the only colours on offer
// tailwind.config.js, inside theme
// colors, not extend.colors
colors: {
"background-primary": "var(--background-primary)",
"foreground-primary": "var(--foreground-primary)",
"border-primary": "var(--border-primary)",
// …and one line for each of the other tokens
},text-black-400 and bg-gray-900 no longer produce any CSS, so nobody reaches for a raw colour by accident, person or agent. A lint rule can flag them before review.
To check: whether the codebase stores colours as hex or RGB channels. Opacity modifiers such as bg-background-primary/50 only work with channels in Tailwind 3.
Designers choose a section’s scheme, not the colour of everything inside it
Part 3
Spacing tokens
Starting with the page gutter: one value everyone uses, instead of four that everyone has to remember.
The page gutter is four values to remember, in Figma and in code
| Screen width | Gutter | Class |
|---|---|---|
| Under 390px | 8px | px-2 |
| 390–1023px | 16px | sm:px-4 |
| 1024–1279px | 40px | md:px-10 |
| 1280px and up | 48px | lg:px-12 |
<header class="px-2 sm:px-4 md:px-10 lg:px-12"></header>These are the global header’s values, in production today. Newer React pages such as Calendar don’t match them, and Figma isn’t consistent either, so design is as much to blame as engineering.
In Figma, the gutter becomes one variable with a mode for each breakpoint
| Spacing | Under 390px | 390px | 1024px | 1280px |
|---|---|---|---|---|
| page-gutter | 8 | 16 | 40 | 48 |
Every section’s padding uses the variable. Set a frame’s mode to its breakpoint and the gutter follows, so nobody types a number and no two files disagree.
In code, the same token makes the gutter a single decision
:root {
--page-gutter: var(--spacing-2);
@media (width >= 390px) {
--page-gutter: var(--spacing-4);
}
@media (width >= 1024px) {
--page-gutter: var(--spacing-10);
}
@media (width >= 1280px) {
--page-gutter: var(--spacing-12);
}
}// tailwind.config.js
extend: {
spacing: { gutter: "var(--page-gutter)" },
}<header class="px-gutter"></header>We propose the global header’s values as the starting point. They’re already in production, and they look right.
Change the token once, and every section moves with it
Part 4
Fluid type and spacing
Sizes that grow steadily with the screen instead of jumping at breakpoints, what that means for designers and engineers, and how it works in Figma.
Type that jumps at breakpoints can only be right for one screen size at a time
Type was made bigger for people viewing on large screens. Now others find it too big.
Fluid type grows with the screen, instead of jumping at four breakpoints
390Desktop
1024Display
1920Display plus
2560Fluid: one
clamp()Today: four stepsclamp() sets a smallest size, a largest size and a steady rate between them
--text-5xl: clamp(3rem, 2.596rem + 1.659vw, 5.25rem);3rem
Never smaller than 48px, today’s Mobile size.
2.596rem + 1.659vw
Grows with the screen, so it reaches 84px at 2560px. A tool works this out from two sizes and two widths.
5.25rem
Never larger than 84px, today’s Display plus size.
Today’s 56 type values become 14 lines
A straight line from each step’s Mobile size to its Display plus size lands within 3px of today’s values at every breakpoint, for 12 of the 14 steps.
The other two: 4xl comes out 5px smaller at 1920px, and 3xl 4px larger at 1024px. xxs and xxxs become the same line, a sign the scale has a step too many.
--text-5xl: clamp(3rem, 2.596rem + 1.659vw, 5.25rem);
--text-4xl: clamp(2.25rem, 1.891rem + 1.475vw, 4.25rem);
--text-3xl: clamp(1.75rem, 1.435rem + 1.29vw, 3.5rem);
--text-2xl-serif: clamp(1.5rem, 1.298rem + 0.829vw, 2.625rem);
--text-2xl-sans: clamp(1.5rem, 1.365rem + 0.553vw, 2.25rem);
--text-xl-serif: clamp(1.25rem, 1.07rem + 0.737vw, 2.25rem);
--text-xl-sans: clamp(1.125rem, 1.058rem + 0.276vw, 1.5rem);
--text-lg-serif: clamp(1rem, 0.865rem + 0.553vw, 1.75rem);
--text-lg-sans: clamp(0.875rem, 0.763rem + 0.461vw, 1.5rem);
--text-md: clamp(1.125rem, 1.103rem + 0.092vw, 1.25rem);
--text-sm: clamp(1rem, 0.91rem + 0.369vw, 1.5rem);
--text-xs: clamp(0.875rem, 0.808rem + 0.276vw, 1.25rem);
--text-xxs: clamp(0.75rem, 0.728rem + 0.092vw, 0.875rem);
--text-xxxs: clamp(0.75rem, 0.728rem + 0.092vw, 0.875rem);Fluid spacing grows with the type, so a page keeps its proportions
/* postcss-ruler, for microsites */
@ruler scale({
pairs: [
["sm", 12, 16],
["md", 16, 24],
["lg", 24, 32],
["xl", 32, 48],
["2xl", 48, 64],
["3xl", 64, 96],
],
prefix: "space",
});Type and spacing grow between the same two screen widths, so a heading and the gap below it grow together, instead of one growing and the other jumping at a breakpoint.
Each pair is a size on the smallest screen and a size on the largest. postcss-ruler also makes cross pairs, such as xl on a phone to 3xl on a large screen, for section gaps that need to grow faster. christies.com would get the plain clamp() values, with no new dependency.
Keeping each largest size within 2.5 times its smallest keeps browser zoom working
Accessibility guidelines (WCAG) ask that text can be zoomed to 200%. The vw part of a clamp() doesn’t grow when someone zooms, so a scale that stretches too far can stop zoom from enlarging the text.
Within 2.5 times, 200% zoom always works. Every line in the fitted scale stays at twice its smallest size or less. This deck’s own type follows the same rule.
In Figma, each token has two values: its smallest and its largest
| Type | Smallest screen | Largest screen |
|---|---|---|
| text-5xl | 48 | 84 |
| text-4xl | 36 | 68 |
| text-3xl | 28 | 56 |
Two frames, not four
Designers work at the smallest and largest screens we design for. Each Figma variable has two modes, one for each.
The browser does the rest
Every width in between comes from the same clamp(), so there’s nothing to design, build or check at each breakpoint.
Still to decide
Which two widths. Viewport data from christies.com analytics should set them, rather than habit.
Part 5
Figma and components
Guidance where designers pick their tokens, and components built from slots rather than variants.
Each token says when to use it, inside Figma where designers pick it
Draft: add a screenshot of a variable with its description, such as ‘foreground-secondary: dates, captions and supporting text. Not for anything a reader must see to act.’
Slots replace a variant for every combination
Today
Card with image, card without image, card with button, card with image and button… Each new need adds variants, and each variant can drift.
Easel 3.0
One card with named slots. Any content goes in a slot, and the card only owns its layout and spacing.
<Card
media={<Image … />}
actions={<Button>View lots</Button>}
>
Post-War and Contemporary Art
</Card>Guidance sits next to the component, where people and agents read it
Draft: usage notes beside each component in Figma and in Storybook, so a designer, an engineer and an agent all read the same guidance at the moment they need it. Add an example.
Part 6
With the grain of the web
Let the browser decide what it’s better placed to decide. It knows the screen, the font and the reader’s settings, and we only guess at them.
ch sets a line length in characters, at any type size
.article p {
max-inline-size: 65ch;
}A ch is the width of a zero in the current font. A paragraph stays at a readable length whatever size its type becomes, so fluid type needs no matching fluid widths.
Scroll snap gives a carousel its stops, with no script
.carousel {
display: flex;
overflow-x: auto;
scroll-snap-type: x mandatory;
}
.carousel > * {
scroll-snap-align: start;
}Touch, trackpad and keyboard scrolling all work, because it’s the browser’s own scrolling. This deck snaps between slides the same way.
A RAM grid lets the browser choose the number of columns
grid-template-columns: repeat(auto-fit, minmax(min(100%, 10rem), 1fr));- Lot 1
- Lot 2
- Lot 3
- Lot 4
- Lot 5
- Lot 6
- Lot 7
- Lot 8
Part 7
One language, one step at a time
Shared names for designers, engineers and agents, and the order to bring them in.
Designers, engineers and agents share one set of names
Figma variable
foreground-primary
CSS
--foreground-primary
Tailwind
text-foreground-primary
An agent's brief
‘Use foreground-primary.’
An agent reads the Figma file and the codebase. When both use the same names, it copies the system instead of guessing at hex values.
Semantic colour first, then spacing, then fluid type
- 01
Semantic colour
Points at today's colours, so nothing changes on screen and there's nothing to check.
- 02
Spacing
Starting with the page gutter.
- 03
Fluid type
Once we know which two screen widths to design for.
One step at a time, alongside the work squads are already doing, rather than one big change that slows every team down.
Part 8
Still to decide
How we name tokens, how we agree those names as a team, and how we bring all this to engineering.
How four other design systems name their tokens
| System | How a name is built | Quieter text | A primary button’s background |
|---|---|---|---|
| Atlassian | Property, role, emphasis, state | color.text.subtle |
color.background.brand.bold |
| GitHub Primer | Property, role, emphasis | fgColor-muted |
button-primary-bgColor-rest |
| Nord Health | Property, then a word from weakest to strong | --n-color-text-weak |
--n-color-accent |
| Reshaped | Property, role, then faded or highlighted | --rs-color-foreground-neutral-faded |
--rs-color-background-primary |
All four put the property first: text, background or border. Only Primer names the button in the token itself.
What we would take from each
Atlassian
One order for every name: property, role, emphasis, then state. A hover colour is the same name with hovered on the end.
GitHub Primer
A third layer of component tokens, used only inside the component. Each strong background comes with a named foreground, fgColor-onEmphasis.
Nord Health
A small set: about 60 colour tokens. Light, dark and high contrast are each one short file that gives the same names new values.
Reshaped
Names that come in pairs: background-primary and on-background-primary. Its Tailwind classes drop the repeat, so the class is bg-primary.
To decide: all four use a word such as muted, subtle, weak or faded for quieter text. None uses ‘secondary’, which we also want for the secondary button.
How we agree the names as a team
Draft: who decides, how a new token is proposed, and where the decision is written down.
How we bring this to engineering
Draft: what we ask of engineering, what design does first, and how we show the change is safe to make.