How Linkette's Atelier design system works — cream paper that isn't white, candlelit dark mode, terracotta in OKLCh, Fraunces optical sizing, and 10 themes from one variable swap.
Linkette has a design system called Atelier. It looks like nothing else in the link-in-bio space, and people keep asking how it's built. This is the answer.
The goal was a product that felt like a small, calm European print magazine — Aesop, Hay, Muji, the COS of 2014, the Apartamento back catalogue. Not a developer-tools-blue dashboard, and absolutely not a TikTok-purple growth-hack landing page.
The 10-theme palette
Atelier ships with 10 curated palettes, each with a French name and a short description:
| ID | Name | Mode | Vibe |
|---|---|---|---|
atelier |
Atelier | light | Warm European editorial — cream + terracotta |
atelier-nocturne |
Atelier Nocturne | dark | Candlelit Parisian bookshop |
monochrome |
Monochrome | light | Pure black-on-white, type-forward |
argile |
Argile | light | Hand-thrown pottery, natural dyers |
brume |
Brume | light | Nordic skincare, quiet wellness |
lavande |
Lavande | light | Provence stationery, quiet perfumer |
riviera |
Riviera | light | Côte d'Azur swimwear, summer zines |
foret |
Forêt | dark | Mossy woodworker, dark herbarium |
vinyl |
Vinyl | dark | Late-night DJ, indie record press |
cendre |
Cendre | light | Smoked oak, leather atelier |
I picked them by writing down ten imagined customers — a French ceramicist, a Berlin DJ, a Stockholm skincare brand, a Marseille zine — and reverse-engineering the palette each would set their studio in if they had infinite taste and no marketing team. No theme is named for a colour. A name is a vibe.
The palette data lives in two places (intentionally): metadata in Postgres (so they're queryable, RLS-controllable, and we can add Pro-tier themes later), and the rendered token values in CSS:
-- supabase/migrations/20260517000005_theme_library_expansion.sql
insert into public.themes (id, display_name, description, mode, is_builtin, tier, token_overrides) values
('argile', 'Argile',
'Hand-thrown pottery, natural dyers, raw clay',
'light', true, 'free',
jsonb_build_object(
'bg', 'rgb(236 224 210)',
'text', 'rgb(50 38 30)',
'text-muted', 'rgb(118 98 82)',
'accent', 'rgb(150 82 52)',
'card-bg', 'rgb(246 236 222)',
'card-border', 'rgb(220 202 180)'
)),
-- ... 6 more
on conflict (id) do nothing;
The OKLCh foundation (even though we ship in RGB)
I designed every palette in OKLCh — the perceptually-uniform color space — and then converted to sRGB triplets at the end. Two reasons:
- Tints stay sensible. A terracotta at L=0.62, C=0.13, H=42° gives a soft 30%-opacity tint that doesn't muddy. The same color picked by eye in HSL frequently goes grey when lightened.
- Cross-palette comparison is honest. Two accents at the same OKLCh lightness look equally bright. Two accents at the same HSL lightness can be wildly different perceptually.
We render in sRGB because (a) rgb(r g b) is universally supported, and (b) it composes cleanly with Tailwind v4's color-mix(in oklab, ...) for opacity utilities — see the Tailwind v4 gotchas piece.
The cream paper background that isn't white
Atelier's body color is #FAF7F2 (rgb(250 247 242)). It is not #FFFFFF. There are two reasons.
First, pure white on a phone OLED screen feels like a clinical fluorescent overhead light. A cream-tinted off-white feels like paper, and your eye visibly relaxes. The shift is small — 5 units of warmth in sRGB — and the perceived softness is huge.
Second, when you put a card on top, you want it to lift off the page. A pure-white background means cards either have to be off-white (which looks dingy) or carry shadows heavy enough to compensate. Cream as ground means cards can be pure white and look crisp:
/* packages/tokens/src/tokens.css */
:root, [data-theme="light"] {
/* Surfaces */
--bg: 250 247 242; /* #FAF7F2 cream paper */
--bg-elevated: 255 255 255;
--bg-muted: 240 235 226;
/* Text */
--text: 26 24 21; /* #1A1815 deep ink */
--text-muted: 107 102 96;
--text-subtle: 155 149 141;
/* Borders */
--border: 232 226 214;
--border-strong: 26 24 21;
/* Accent — terracotta */
--accent: 194 86 46; /* #C2562E */
--accent-hover: 166 72 31;
--accent-soft: 244 220 203;
/* ... */
}
The text color is also not pure black — #1A1815, "deep ink", a dark warm brown. Pure black on cream looks harsh and aged; a slightly warmed near-black reads as a high-quality book printed in 2016.
Warm dark: the candlelit Parisian bookshop
Most dark modes look like Discord. Atelier Nocturne deliberately doesn't. The background is #16140F — a warm near-black with a hint of olive. The accent terracotta shifts to a brighter #E07A4D to maintain contrast against the warm dark; pure terracotta would muddy:
[data-theme="dark"] {
/* Surfaces — warm dark, never pure black */
--bg: 22 20 15; /* #16140F */
--bg-elevated: 31 28 22;
--bg-muted: 42 38 30;
/* Text */
--text: 244 239 227; /* #F4EFE3 */
--text-muted: 184 177 161;
--text-subtle: 122 116 104;
/* Borders */
--border: 47 43 34;
--border-strong: 244 239 227;
/* Accent — terracotta brightened */
--accent: 224 122 77; /* #E07A4D */
--accent-hover: 237 142 99;
--accent-soft: 58 36 25;
/* ... */
}
The shadow tokens swap, too — warm-tinted shadows in light mode (so they feel like ink on paper), pure-black shadows in dark mode (so they feel like real shadow on a dim surface):
:root, [data-theme="light"] {
--shadow-md: 0 1px 2px rgba(26, 24, 21, 0.04), 0 8px 24px rgba(26, 24, 21, 0.06);
--shadow-glow: 0 0 0 1px rgb(var(--accent-soft) / 1), 0 8px 32px rgba(194, 86, 46, 0.18);
}
[data-theme="dark"] {
--shadow-md: 0 1px 2px rgba(0, 0, 0, 0.3), 0 8px 24px rgba(0, 0, 0, 0.45);
--shadow-glow: 0 0 0 1px rgb(var(--accent-soft) / 1), 0 8px 32px rgba(224, 122, 77, 0.25);
}
Tester reaction to the dark mode: "this is the first dark mode that doesn't feel like I lost." That's the entire goal.
Token architecture: three layers
Tokens are organized in three layers, each strictly more specific than the last:
- Primitives — raw
--bg,--text,--accentRGB triplets. Live inpackages/tokens/src/tokens.css. Themeable via[data-theme="..."]. - Semantic —
--color-bg,--color-text,--color-accentwrapped as fullrgb(...)colors via@theme inline. Live inapps/web/app/globals.css. Consumed by Tailwind utilities. - Component — only where genuinely necessary (right now: none). Most components consume semantic tokens directly via
bg-bg-elevated text-text border-borderutilities.
The mapping looks like this:
/* apps/web/app/globals.css */
@theme inline {
--color-bg: rgb(var(--bg));
--color-bg-elevated: rgb(var(--bg-elevated));
--color-bg-muted: rgb(var(--bg-muted));
--color-text: rgb(var(--text));
--color-text-muted: rgb(var(--text-muted));
--color-text-subtle: rgb(var(--text-subtle));
--color-border: rgb(var(--border));
--color-border-strong: rgb(var(--border-strong));
--color-accent: rgb(var(--accent));
--color-accent-hover: rgb(var(--accent-hover));
--color-accent-soft: rgb(var(--accent-soft));
--color-secondary: rgb(var(--secondary));
--color-success: rgb(var(--success));
--color-warning: rgb(var(--warning));
--color-danger: rgb(var(--danger));
--font-display: "Fraunces", Georgia, serif;
--font-sans: "Geist", system-ui, sans-serif;
--font-mono: "Geist Mono", ui-monospace, SFMono-Regular, monospace;
--radius-sm: var(--radius-sm);
--radius-md: var(--radius-md);
--radius-lg: var(--radius-lg);
--radius-xl: var(--radius-xl);
--radius-2xl: var(--radius-2xl);
--shadow-sm: var(--shadow-sm);
--shadow-md: var(--shadow-md);
--shadow-lg: var(--shadow-lg);
--shadow-glow: var(--shadow-glow);
--ease-out: var(--ease-out);
--ease-spring: var(--ease-spring);
}
This is using @theme inline (not @theme) deliberately — it preserves the var(--bg) indirection so the live cascade re-resolves on theme swap. Use @theme (without inline) and your dark mode breaks because the values bake at build time. I covered this in detail in the Tailwind v4 gotchas piece.
A token in spirit but encoded differently: the typography scale, which lives in TypeScript because it's referenced by both CSS-in-JS components and email templates that don't share a CSS context:
// packages/tokens/src/index.ts
export const TYPOGRAPHY = {
fonts: {
display: "'Fraunces', Georgia, serif",
sans: "'Geist', system-ui, sans-serif",
mono: "'Geist Mono', ui-monospace, SFMono-Regular, monospace",
},
scale: {
xs: ["0.75rem", "1.4"],
sm: ["0.875rem", "1.5"],
base: ["1rem", "1.6"],
lg: ["1.125rem", "1.5"],
xl: ["1.25rem", "1.4"],
"2xl": ["1.5rem", "1.3"],
"3xl": ["1.875rem", "1.25"],
"4xl": ["2.25rem", "1.15"],
"5xl": ["3rem", "1.1"],
display: ["4.5rem", "1"],
},
} as const;
Fraunces: opsz, ss01, ss02
The display face is Fraunces — a contemporary serif with an opsz (optical size) axis, four stylistic sets, and an unusual ability to feel both literary and confident at the same time.
Three Fraunces details that make Atelier look right:
Optical size matters. Fraunces' opsz axis adjusts proportions for the rendered point size. At 14px display, you want lower contrast and tighter spacing; at 60px hero text you want full contrast and more relaxed spacing. Modern browsers do this automatically via font-optical-sizing: auto; (default). The result: small headings stay readable, hero text stays elegant.
ss01 is the alternate g. Fraunces default g has a tight closed lower bowl. ss01 swaps for a more open, friendlier double-storey form. We use it everywhere display type appears:
// apps/web/components/page-preview.tsx (excerpt)
<span
style={{
color: m.accent,
fontFamily: "var(--font-display)",
fontFeatureSettings: "'ss01'",
}}
>
✦
</span>
ss02 is the curlier r. A small detail that lifts headings from "serif" into "serif with a point of view." We combine ss01 and ss02 on the landing hero:
// apps/web/components/landing/speed-section.tsx (excerpt)
<h2
className="font-display text-[1.85rem] sm:text-4xl md:text-5xl text-text leading-[1.1] tracking-[-0.02em] mb-4 sm:mb-5"
style={{ fontFeatureSettings: "'ss01', 'ss02'" }}
>
Sub-300ms first token
</h2>
The body font is Geist. We turn on ss01 and cv11 globally for the friendlier alternates:
@layer base {
body {
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
font-feature-settings: "ss01", "cv11";
}
}
One CSS variable swap = 10 instant rebrandings
This is the payoff. Because every theme is just a different definition of the same primitive variables, the entire app re-skins on a single attribute toggle:
<html data-theme="vinyl">
<!-- → --bg: 20 20 24; --accent: 220 76 76; --text: 232 228 224 -->
<!-- Every Tailwind utility, every component, every shadow, instant. -->
</html>
No re-render, no JS recompute, no flash. next-themes flips the attribute, the browser re-resolves the cascade, you're done. Even the AI brief in the analytics page changes accent color because it consumes text-accent — and the email template carries the same hex values inlined manually so the digest matches.
Things I tried that didn't work
One token-per-component (Material-style)
I started with a giant component-level token table — --button-primary-bg, --card-default-border, etc. Ended up with 220 tokens, half of them mapping to the same primitive value, and a refactor was painful because changing the accent meant touching 30 lines. Collapsed to ~15 semantic tokens plus a handful of component-specific overrides only where the semantic value genuinely wasn't right. The total token count went from 220 to 24 and the design became more consistent because there's no longer a "buttons can have their own borders" option.
Tinting accents algorithmically with color-mix()
I had --accent-soft: color-mix(in oklab, var(--accent), white 75%); in tokens for a while. Theoretically lovely — change one value, all tints update. In practice the tints I wanted weren't always 75% mix-to-white; some palettes needed mix-to-cream, some needed a shifted hue (Brume's accent-soft is bluer than its accent because that's what looks right). Hand-tuned tint values won. Algorithmic tinting is a smell-test fix, not a final answer.
Animating theme transitions
I tried transition: background-color 0.3s ease; on html. Looked great toggling between Atelier and Atelier Nocturne. Looked awful on first page load because every element animated from no-color to its theme color. Removed transitions entirely; theme swap is instant. Lesson: transitions on cascaded properties affect way more than the elements you're picturing.
Resources
- OKLCh & a color picker — the easiest way to design accessible color systems
- Fraunces specimen
- Geist by Vercel
- next-themes
- Tailwind v4
@themedirective - Linkette tokens —
packages/tokens - Tailwind v4: the gotchas — the silent
<alpha-value>issue you'll hit immediately