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

  1. 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
  2. Semantic tokens

    A job anywhere on the page. Designers and engineers use these every day.

    • background-primary
    • foreground-primary
    • foreground-secondary
    • border-primary
  3. 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

View lotsRegister to bid
Figma mode
Light
Code
data-color-scheme="light"

Live auction, London

Post-War and Contemporary Art

View lotsRegister to bid
Figma mode
Dark
Code
data-color-scheme="dark"

Live auction, London

Post-War and Contemporary Art

View lotsRegister to bid
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

ColourLightDarkRed
Semantic
background-primarywhiteblackbrand-c-red
foreground-primaryblackwhitewhite
foreground-secondaryblack-300white-alpha-60white-alpha-80
border-primarygrey-400white-alpha-40white-alpha-40
Component: button
button-primary-backgroundbrand-c-redwhitewhite
button-primary-foregroundwhiteblackbrand-c-red
button-primary-borderbrand-c-redwhitewhite
button-secondary-backgroundtransparenttransparenttransparent
button-secondary-foregroundblackwhitewhite
button-secondary-borderblackwhite-alpha-60white-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

SpacingUnder 390px390px1024px1280px
page-gutter8164048

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

How the gutter is set
1280px wide: --page-gutter is 48px, the same as lg:px-12

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

At 1280px, today’s 5xl is 56px and the fluid line gives 62.8px.
The 5xl type size against screen width. Today it is 48px up to 1023px, then jumps to 56px at 1024px, 72px at 1920px and 84px at 2560px. The fluid line grows steadily from 48px at 390px to 84px at 2560px.

clamp() 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

TypeSmallest screenLargest screen
text-5xl4884
text-4xl3668
text-3xl2856

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.

.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
The browser fits as many 10rem columns as the space allows.

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

  1. Figma variable

    foreground-primary

  2. CSS

    --foreground-primary

  3. Tailwind

    text-foreground-primary

  4. 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

  1. Semantic colour

    Points at today's colours, so nothing changes on screen and there's nothing to check.

  2. Spacing

    Starting with the page gutter.

  3. 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.