Color System: Implementing the Palette
Self-contained. The skill’s rules, then this topic, then anything it relies on from other skills.
Skill rules
My color library. It comes from the Swiss International Style (1950s-1970s) and the Bauhaus primary palette, the movements that shaped modern graphic design. Their color choices weren’t arbitrary. They were deliberate statements about what color does in service of communication.
This answers which colors to choose. Color mechanics live elsewhere: OKLCH in web-platform, system semantic colors in apple-platform, Material color roles and dynamic color in android-platform.
It’s a foundation, not a prescription. Pull named primitives from it. Don’t invent hex values from nothing. It fills the gaps where I haven’t specified a preference yet.
When several sources apply, this is the order:
- Colors specified in the project’s design files or an existing project palette.
- A
TASTE.md file, when the project has one. I haven’t built one yet.
- Token modifications from an explicitly chosen style in the
visual-styles skill.
- This library.
On Apple platforms and Android, with no style specified, the default is the platform’s own semantic or dynamic color, not this palette. Use this library when a brand or custom palette is actually being chosen.
Quick Reference
| Need |
Where |
| All 73 primitives with hex and OKLCH, grouped by family, plus source notes |
reference/palette.md |
Writing the palette as tokens: CSS (oklch(), light-dark(), relative color, color-mix(), contrast-color()), wide gamut, SwiftUI, Compose, and the state of contrast standards |
reference/implementation.md |
| Semantic mappings, pairing rules, restraint, contrast |
This file |
How to Use
Token naming comes from figma-design-system (reference/token-architecture.md) and is the same in every skill. This skill decides which primitive each name points to.
Primitive color/primitive/swiss-red --color-primitive-swiss-red
Semantic color/semantic/interactive/primary --color-semantic-interactive-primary
Color.Semantic.Interactive.primary
Components never use a primitive directly. They go through semantic tokens, so a theme or mode change is a remap, not a find-and-replace. If the codebase already names a token differently, the codebase’s name wins.
The Palette at a Glance
Four families, 73 primitives. Full tables are in reference/palette.md.
Swiss International Style
swiss-red #D52B1E swiss-blue #1B4499 swiss-yellow #F5C900
swiss-green #1B6B3A swiss-orange #E05C1A swiss-cyan #0097A7
swiss-ochre #865D07 swiss-ink #1A1A1A swiss-pure-white #FFFFFF
+ warm/deep/bright variants of each hue and a 9-step achromatic set
Bauhaus (Itten)
bauhaus-red #C8302A bauhaus-yellow #E8C018 bauhaus-blue #1E3878
+ black, gray, and orange/green/violet secondaries
Mid-century modernist
modernist-red #E0272A modernist-blue #1C5CB2 modernist-yellow #F2C200
+ green, orange, teal, brown, cream
Contemporary (interface ramps)
warm-100 .. warm-950 #FAFAF7 .. #1A1614
cool-100 .. cool-950 #F8F9FA .. #0E1014
accent-{red,blue,yellow,green,orange,cyan}-light for dark surfaces,
plus accent-red-lighter and accent-red-lightest
One name per value. warm-200 is the Swiss paper tone, warm-100 is the near-white, and Bauhaus white is swiss-pure-white.
Semantic Mappings
Which primitive each color/semantic/<group>/<role> token aliases when no style is active. The groups are fixed. A state is a suffix on its role.
Token Light Dark
background/primary warm-200 cool-900
background/elevated warm-100 cool-800
background/sunken warm-300 cool-950
text/primary swiss-ink cool-100
text/secondary warm-700 cool-500
text/disabled warm-500 cool-600
text/inverse warm-100 swiss-ink
border/default warm-400 cool-700
border/strong swiss-ink cool-300
border/subtle warm-300 cool-800
interactive/primary swiss-red accent-red-light
interactive/primary-hover bauhaus-red accent-red-lighter
interactive/primary-pressed swiss-red-deep accent-red-lightest
interactive/primary-disabled warm-500 cool-700
status/error swiss-red-deep accent-red-lighter
status/warning swiss-ochre swiss-yellow
status/success swiss-green accent-green-light
status/info swiss-blue accent-blue-light
accent/primary swiss-blue accent-blue-light
accent/secondary swiss-yellow swiss-yellow
interactive/primary can be swiss-blue instead, per brand.
text/inverse is the label on every interactive/primary state, in both modes.
- Light states get darker. Dark states get lighter. Both step the same way through default, hover, pressed.
status/* works as text and as a mark in both modes, on every background.
- Dark
status/info is blue, not cyan. Info is blue in light mode and a role keeps its hue across modes. Cyan stays an accent.
accent/* is decorative. It’s never the only thing carrying meaning and never an interactive state. accent/secondary is yellow, so it’s a fill or a large shape with swiss-ink on top, never text on a light surface.
swiss-yellow is still the warning fill in light mode when the warning is a banner or badge: yellow background, swiss-ink text. status/warning is for the mark and the text.
Color Pairing Logic
The Swiss Style used color in systematic relationships, not arbitrary combinations. These pairings come from the posters.
Primary pairings (two-color compositions)
Red + Black Classic. Muller-Brockmann road safety posters. Maximum force.
Red + White Swiss flag composition. Clean, forceful.
Blue + White Maritime, institutional. Hofmann school.
Blue + Yellow Maximum chromatic contrast. Use sparingly. Bauhaus triad.
Black + Yellow Warning/signal. Extremely high contrast. Use for critical states.
Black + White The Swiss Style baseline. Typography first.
Red + Yellow Energetic. Less common in Swiss design than Bauhaus.
Restraint (Swiss discipline)
The Swiss Style used two or three colors per composition. I hold UI to the same rule:
- One primary brand color (red, blue, or yellow)
- Black and white as the structure
- Optional: one secondary neutral (warm gray or cool gray)
Don’t use all the accent colors at once. If everything is emphasized, nothing is. Status colors are the exception. They’re functional signals, not part of the composition, and they only appear when a state calls for them. accent/* is optional. Most screens don’t need it.
Contrast
These are WCAG 2 relative-luminance ratios, computed from the palette values. Normal text needs 4.5:1 for AA and 7:1 for AAA. Large text (24px, or 18.66px bold, and up) and the visible boundaries of UI components and meaningful graphics need 3:1. Disabled controls and decorative elements have no requirement.
Light. Ratios on background/primary / elevated / sunken (warm-200 / warm-100 / warm-300)
text/primary swiss-ink 15.69 / 16.64 / 14.38
text/secondary warm-700 5.16 / 5.47 / 4.73
border/strong swiss-ink 15.69 / 16.64 / 14.38
status/error swiss-red-deep 6.13 / 6.50 / 5.62
status/warning swiss-ochre 5.29 / 5.61 / 4.84
status/success swiss-green 5.90 / 6.25 / 5.41
status/info swiss-blue 8.12 / 8.61 / 7.44
accent/primary swiss-blue 8.12 / 8.61 / 7.44
Dark. Ratios on background/primary / elevated / sunken (cool-900 / cool-800 / cool-950)
text/primary cool-100 16.19 / 12.51 / 18.06
text/secondary cool-500 6.65 / 5.14 / 7.42
border/strong cool-300 13.40 / 10.36 / 14.96
status/error accent-red-lighter 6.15 / 4.76 / 6.87
status/warning swiss-yellow 10.75 / 8.31 / 12.00
status/success accent-green-light 5.91 / 4.57 / 6.60
status/info accent-blue-light 5.92 / 4.58 / 6.61
accent/primary accent-blue-light 5.92 / 4.58 / 6.61
Labels. text/inverse on each interactive fill
interactive/primary light swiss-red 4.79 dark accent-red-light 4.85
interactive/primary-hover light bauhaus-red 5.13 dark accent-red-lighter 6.28
interactive/primary-pressed light swiss-red-deep 6.50 dark accent-red-lightest 8.32
Fills against the surface they sit on (3:1 needed)
interactive/primary light 4.14 min dark 3.68 min
interactive/primary-hover light 4.44 min dark 4.76 min
interactive/primary-pressed light 5.62 min dark 6.30 min
Every text, status, and label token passes 4.5:1 on background/primary in both modes. Every interactive fill clears 3:1 against every background.
Other pairings worth knowing:
swiss-ink on swiss-yellow 10.97 ✓ the warning fill
swiss-blue on swiss-yellow 5.67 ✓
warm-100 on swiss-blue 8.61 ✓
warm-100 on swiss-red-warm 4.14 ✗ large text only
swiss-red on warm-200 / warm-100 / warm-300 4.52 / 4.79 / 4.14 body text fails on warm-300
swiss-red on swiss-black 3.95 ✗ large text / UI only
swiss-red-warm on swiss-black 4.58 ✓
accent-red-light on swiss-black 5.52 ✓
swiss-orange on warm-200 3.31 ✗ large text / UI only
swiss-cyan on warm-200 3.16 ✗ large text / UI only
swiss-yellow on warm-200 1.43 ✗ fails at any size
accent-cyan-light on cool-900 7.09 ✓
accent-orange-light on cool-900 6.04 ✓
warm-400 on warm-200 (border/default) 1.25 decorative
cool-700 on cool-900 (border/default) 2.17 decorative
The rules that come out of the numbers:
- Yellow is a fill, never text or a mark on a light surface. Background with
swiss-ink on top, or text on dark surfaces only. On light surfaces the warning mark and text are swiss-ochre.
- Red as text has no margin on light neutrals.
swiss-red passes for body text on warm-100 and warm-200 and fails on warm-300. That’s why status/error is swiss-red-deep. On near-black use swiss-red-warm or accent-red-light.
swiss-red-warm isn’t a state color. It drops a warm-100 label to 4.14:1.
- Dark error is a step lighter than the dark primary.
accent-red-light is 3.68:1 on cool-800, fine for a fill and not for text. status/error uses accent-red-lighter, which holds 4.76:1 there.
- Default borders are decorative. Where a border is the only thing identifying a control, like an input field, use
border/strong.
- Color is never the only signal. Error, warning, and success get an icon or text alongside the color.
Compute any pairing that isn’t listed here before using it. Don’t estimate by eye.
Topic
How to write these primitives and semantic roles as tokens on each platform, and what the current color features do and don’t solve. General color technique is in the web-platform, apple-platform, and android-platform skills. This is the part specific to shipping this palette.
Status
Web support status lives in web-platform/reference/browser-compatibility.md. As of November 2026: oklch(), color-mix(), color(), and light-dark() are safe everywhere. Relative color syntax is Baseline (2024-09) and safe for current browsers. contrast-color() is Baseline only since 2026-04 and needs a fallback. dynamic-range-limit is Chromium only.
WCAG 2.2 is the normative contrast standard. WCAG 3 is a Working Draft whose contrast algorithm “is yet to be determined”. It doesn’t name APCA.
Contents
Web: CSS Tokens
Names follow figma-design-system/reference/token-architecture.md: --color-primitive-<name> and --color-semantic-<group>-<role>. Primitives and semantics live in one :root block. Write primitives in OKLCH, with values from palette.md. Semantic tokens reference primitives and switch modes with light-dark(), which removes the duplicated dark-mode media query.
:root {
color-scheme: light dark; /* required for light-dark() to resolve */
--color-primitive-swiss-red: oklch(56.71% 0.2067 29.54);
--color-primitive-bauhaus-red: oklch(54.87% 0.1896 27.76);
--color-primitive-swiss-red-deep: oklch(49.34% 0.1857 27.49);
--color-primitive-accent-red-light: oklch(64.93% 0.2141 28.23);
--color-primitive-accent-red-lighter: oklch(71.05% 0.1777 28.37);
--color-primitive-accent-red-lightest: oklch(78.11% 0.1239 28.63);
--color-primitive-swiss-blue: oklch(41.20% 0.1468 262.50);
--color-primitive-accent-blue-light: oklch(68.42% 0.1609 262.54);
--color-primitive-swiss-green: oklch(46.78% 0.1091 152.01);
--color-primitive-accent-green-light: oklch(66.40% 0.1384 151.66);
--color-primitive-swiss-ochre: oklch(50.91% 0.1037 77.95);
--color-primitive-swiss-yellow: oklch(84.94% 0.1739 92.82);
--color-primitive-swiss-ink: oklch(21.78% 0 0);
--color-primitive-warm-100: oklch(98.43% 0.0040 106.47);
--color-primitive-warm-200: oklch(96.43% 0.0070 88.64);
--color-primitive-warm-300: oklch(93.52% 0.0103 81.79);
--color-primitive-warm-400: oklch(89.06% 0.0138 78.26);
--color-primitive-warm-700: oklch(51.18% 0.0204 72.87);
--color-primitive-cool-100: oklch(98.16% 0.0017 247.84);
--color-primitive-cool-500: oklch(71.08% 0.0098 258.34);
--color-primitive-cool-700: oklch(43.71% 0.0111 258.36);
--color-primitive-cool-800: oklch(30.96% 0.0111 271.04);
--color-primitive-cool-900: oklch(22.60% 0.0083 264.39);
--color-primitive-cool-950: oklch(17.27% 0.0089 264.31);
/* Semantic: light value, dark value */
--color-semantic-background-primary: light-dark(var(--color-primitive-warm-200), var(--color-primitive-cool-900));
--color-semantic-background-elevated: light-dark(var(--color-primitive-warm-100), var(--color-primitive-cool-800));
--color-semantic-background-sunken: light-dark(var(--color-primitive-warm-300), var(--color-primitive-cool-950));
--color-semantic-text-primary: light-dark(var(--color-primitive-swiss-ink), var(--color-primitive-cool-100));
--color-semantic-text-secondary: light-dark(var(--color-primitive-warm-700), var(--color-primitive-cool-500));
--color-semantic-text-inverse: light-dark(var(--color-primitive-warm-100), var(--color-primitive-swiss-ink));
--color-semantic-border-default: light-dark(var(--color-primitive-warm-400), var(--color-primitive-cool-700));
--color-semantic-interactive-primary: light-dark(var(--color-primitive-swiss-red), var(--color-primitive-accent-red-light));
--color-semantic-interactive-primary-hover: light-dark(var(--color-primitive-bauhaus-red), var(--color-primitive-accent-red-lighter));
--color-semantic-interactive-primary-pressed: light-dark(var(--color-primitive-swiss-red-deep), var(--color-primitive-accent-red-lightest));
--color-semantic-status-error: light-dark(var(--color-primitive-swiss-red-deep), var(--color-primitive-accent-red-lighter));
--color-semantic-status-warning: light-dark(var(--color-primitive-swiss-ochre), var(--color-primitive-swiss-yellow));
--color-semantic-status-success: light-dark(var(--color-primitive-swiss-green), var(--color-primitive-accent-green-light));
--color-semantic-status-info: light-dark(var(--color-primitive-swiss-blue), var(--color-primitive-accent-blue-light));
--color-semantic-accent-primary: light-dark(var(--color-primitive-swiss-blue), var(--color-primitive-accent-blue-light));
--color-semantic-accent-secondary: var(--color-primitive-swiss-yellow);
}
.button {
background: var(--color-semantic-interactive-primary);
color: var(--color-semantic-text-inverse);
}
A manual theme toggle sets color-scheme: light or color-scheme: dark on the root, or on any subtree forced to one mode. Every light-dark() token below it follows.
Deriving States
The palette defines hover and pressed states as named primitives. Use those. When a design calls for a state the palette doesn’t name (a tint, a translucent overlay, a focus ring), derive it from a primitive and give the result a token. Don’t invent a new literal.
:root {
/* Relative color: same hue and chroma, adjusted alpha */
--color-semantic-interactive-primary-subtle: oklch(from var(--color-primitive-swiss-red) l c h / 0.12);
/* color-mix: blend toward a surface, interpolating in OKLCH */
--color-semantic-background-tinted: color-mix(in oklch, var(--color-primitive-swiss-red) 8%, var(--color-primitive-warm-200));
}
Mix and interpolate in OKLCH or OKLab, not sRGB. sRGB blends of saturated colors pass through muddy, darker midpoints. Same for gradients: linear-gradient(in oklch, ...).
A derived color has a new contrast ratio. Compute it. The parent’s ratio doesn’t carry over.
contrast-color()
contrast-color(var(--some-fill)) returns black or white, whichever contrasts more with the input. It’s for labels on user-chosen or data-driven fills. For this palette’s fixed fills, use the explicit pairings in the contrast tables:
- It only returns pure black or white, not
swiss-ink or warm-100.
- It guarantees the better of the two, not a passing ratio. On mid-tone fills neither option is comfortable, and several of this palette’s reds sit there (
swiss-red-warm gives 4.1:1 with white).
- It’s recent, so it needs a declared fallback:
.tag {
background: var(--tag-color);
color: var(--color-primitive-swiss-ink); /* fallback */
color: contrast-color(var(--tag-color));
}
Wide Gamut and HDR
Every primitive in this library is inside sRGB and renders identically on sRGB and Display P3 screens. That’s deliberate. The palette digitizes printed ink, which is less saturated than a P3 display can show.
A more saturated P3 variant is a new color and a design decision, not a conversion. If I ask for one, keep the primitive’s lightness and hue, raise chroma, and scope it to capable displays so sRGB screens keep the original value:
:root {
--color-primitive-swiss-red: oklch(56.71% 0.2067 29.54);
}
@media (color-gamut: p3) {
:root {
--color-primitive-swiss-red: oklch(56.71% 0.24 29.54); /* example chroma: get the real value from design */
}
}
Raising chroma changes contrast slightly. Recompute. HDR has no role in this palette. Where HDR photography or video sits next to UI in these colors, dynamic-range-limit keeps the media from overpowering the interface, but it’s Chromium only, so it’s an enhancement.
Apple: SwiftUI
Define each semantic color as a Color Set in the asset catalog with Any and Dark appearances, then expose it under the shared name. High Contrast variants live in the asset catalog too.
extension Color {
enum Semantic {
enum Background {
static let primary = Color(.colorSemanticBackgroundPrimary)
}
enum Text {
static let primary = Color(.colorSemanticTextPrimary)
}
enum Border {
static let `default` = Color(.colorSemanticBorderDefault)
}
enum Status {
static let error = Color(.colorSemanticStatusError)
}
enum Interactive {
static let primary = Color(.colorSemanticInteractivePrimary)
static let primaryHover = Color(.colorSemanticInteractivePrimaryHover)
}
enum Accent {
static let primary = Color(.colorSemanticAccentPrimary)
}
}
}
Text("Title")
.foregroundStyle(Color.Semantic.Text.primary)
.background(Color.Semantic.Background.primary)
.border(Color.Semantic.Border.default)
.colorSemanticBackgroundPrimary is the symbol Xcode generates for a color set of that name. Call sites use the Color.Semantic namespace. One nested enum per group: Background, Text, Border, Status, Interactive, Accent. Every role in the mapping table gets a member. default needs backticks where it’s declared, not where it’s used.
Enter the palette’s hex values with the color set’s color space left at sRGB. They’re sRGB values. Tag the same numbers as Display P3 and they render more saturated than specified. Use Display P3 in the asset catalog, or Color(.displayP3, red:green:blue:opacity:), only for a color that was designed in P3.
On Apple platforms the default is the system’s semantic colors, which also adapt to Increased Contrast and Liquid Glass. Use this palette for brand and accent roles when a custom palette has been chosen, and leave system backgrounds, labels, and separators to the system unless the design replaces them.
Android: Compose
It’s a fixed palette or dynamic color, not both. With no style specified, the Android default is dynamic color from the user’s wallpaper and this library doesn’t apply. When a brand palette is chosen, map the semantic roles onto Material color roles in one place:
private val SwissRed = Color(0xFFD52B1E)
private val SwissInk = Color(0xFF1A1A1A)
private val Warm200 = Color(0xFFF5F3EE)
private val Warm100 = Color(0xFFFAFAF7)
private val Warm400 = Color(0xFFE0DAD1)
val SwissLightColors = lightColorScheme(
primary = SwissRed,
onPrimary = Warm100,
background = Warm200,
onBackground = SwissInk,
surface = Warm100,
onSurface = SwissInk,
outlineVariant = Warm400
)
Material derives many roles this library doesn’t define: containers, tertiary, surface tones. Don’t leave them at the baseline purple defaults. Map each one deliberately, or generate the full scheme from the chosen primary with Material Theme Builder and pin the roles this library does define.
Contrast Standards
WCAG 2 contrast ratios, the numbers in SKILL.md, are the compliance measure. WCAG 2.2 is what laws and procurement rules reference.
APCA is a perceptual contrast model that accounts for font size, weight, and polarity. It was in early WCAG 3 drafts. The current draft leaves the algorithm undecided. It’s fine as an extra design check, particularly for dark mode, but an APCA score isn’t conformance to anything. A pair still has to meet its WCAG 2 ratio.
Contrast isn’t the only requirement. Color is never the sole carrier of meaning (WCAG 1.4.1). Error, warning, and success states need an icon or text alongside the red, yellow, and green.
Shared definitions
What this file relies on from other skills, copied in so nothing needs looking up.
From web-platform: reference/browser-compatibility.md
Web support status for every skill. motion-design, color-system and visual-styles defer to this file. The other files here say how a feature works. This one says whether it’s safe to ship.
As of November 2026. Baseline status moves every month. If that date is more than a few months old, check the feature at webstatus.dev or on its MDN page before relying on its row.
Contents
Support Policy
Baseline tracks Chrome, Edge, Firefox and Safari on desktop and mobile. A feature is newly available the day the last of them ships it. It’s widely available 30 months later.
| Status |
How to use it |
| Widely available |
Freely. No fallback, no @supports, no polyfill |
| Newly available |
Use it. Add a fallback only where a browser a version or two behind would lose function, not polish. A missing entry animation is fine. A menu that renders off-screen isn’t |
| Limited |
Progressive enhancement only. The page has to work without it |
No polyfills for anything above 95% support. The one polyfill I carry is Temporal.
Widely Available
- CSS: cascade layers, container size queries and
cq* units, nesting, :has(), oklch()/oklab(), color-mix(), subgrid, dvh/svh/lvh, linear() easing, individual translate/scale/rotate, :focus-visible, :user-valid/:user-invalid, :modal, clip-path, mix-blend-mode, scroll snap, touch-action, env() safe-area insets, overscroll-behavior: contain, @media (hover) and (pointer), prefers-reduced-motion, prefers-contrast, forced-colors
- HTML:
<dialog>, inert, loading="lazy", <search>, import maps, AVIF and WebP
- JS:
structuredClone, Array.fromAsync, Object.groupBy, Promise.withResolvers, AbortSignal.timeout and .any
- APIs: Web Animations, ResizeObserver, IntersectionObserver, Pointer Events, WebGL 2, OffscreenCanvas
Newly Available
In every current browser. The date is when the last engine shipped. Add 30 months for widely available.
| Feature |
Since |
Notes |
light-dark(), text-wrap: balance |
2024-05 |
Widely available from 2026-11 |
| Set methods |
2024-06 |
|
@property |
2024-07 |
|
@starting-style, transition-behavior: allow-discrete |
2024-08 |
Firefox doesn’t transition display, so exit animations are skipped there. Entry animations work. overlay is Chromium only |
<details name> |
2024-09 |
|
| Relative color syntax |
2024-09 |
|
Unprefixed backdrop-filter |
2024-09 |
|
content-visibility |
2024-09 |
|
fetchpriority |
2024-10 |
|
scrollbar-gutter |
2024-12 |
|
popover |
2025-01 |
|
| Iterator helpers |
2025-03 |
|
| JSON import attributes |
2025-04 |
|
RegExp.escape |
2025-05 |
|
Same-document view transitions, view-transition-class |
2025-10 |
|
Invoker commands (command, commandfor) |
2025-12 |
|
| Navigation API |
2026-01 |
|
| Trusted Types |
2026-02 |
|
shape() |
2026-02 |
|
@scope |
2026-03 |
|
CSP report-to |
2026-03 |
|
contrast-color() |
2026-04 |
|
| Container style queries on custom properties |
2026-05 |
Range syntax inside style() is missing in Safari |
field-sizing |
2026-06 |
|
sibling-index(), sibling-count() |
2026-08 |
|
Scroll-driven animations (animation-timeline, scroll(), view()) |
2026-10-27 |
Firefox 159 was last. Keep the @supports wrapper until it’s widely available |
Anchor positioning. The working core (anchor-name, anchor(), position-area, position-try-fallbacks, @position-try, anchor-size()) is in Chrome 125+, Firefox 147+ and Safari 26+. webstatus.dev still lists the group as limited, because the newest initial value (position-anchor: normal) only landed in Chrome 151, Firefox 151 and Safari 27. Treat the core as newly available and keep a non-anchored position as the fallback.
Web Vitals APIs. LargestContentfulPaint and Event Timing interactionId are in every engine, so LCP and INP are measurable everywhere. LayoutShift (CLS) and Long Animation Frames are Chromium only.
Limited Availability
“Missing” lists engines without it in a stable release.
| Feature |
Missing |
Until then |
| Temporal |
Safari |
temporal-polyfill in browser code |
using / Symbol.dispose |
Safari |
Server-only code, or try/finally |
Error.isError() |
Safari desktop, partial on iOS |
instanceof Error |
scheduler.yield() |
Safari |
Feature-detect, fall back to setTimeout |
Cross-document view transitions (@view-transition) |
Firefox |
Pages navigate without the transition |
interpolate-size, calc-size() |
Firefox, Safari |
Grid 0fr to 1fr row transition |
Scroll-triggered animations (animation-trigger) |
Firefox, Safari |
IntersectionObserver plus a class |
| Scroll-state container queries |
Firefox, Safari |
IntersectionObserver |
CSS if(), @function |
Firefox, Safari |
Custom properties plus style queries |
corner-shape |
Firefox, Safari |
clip-path with shape() |
Advanced typed attr() |
Safari |
Inline custom properties |
text-wrap: pretty |
Firefox |
Ship it. Ignored where missing |
prefers-reduced-transparency |
Firefox, Safari |
Ship it. Ignored where missing |
Unprefixed user-select |
Safari |
Ship -webkit-user-select beside it |
Customizable <select> (appearance: base-select) |
Firefox |
Falls back to the native select |
<dialog closedby> |
Safari |
Click handler on the backdrop |
popover="hint" |
Safari |
popover="manual" plus your own hover and focus handling |
hidden="until-found" |
Safari |
Keep a visible toggle |
<img sizes="auto"> |
Safari is partial |
Explicit sizes |
| WebGPU |
Firefox is partial (not every OS, not Android) |
WebGL 2 fallback path |
| Speculation Rules |
Firefox, Safari |
Framework prefetching |
Permissions-Policy header |
Firefox, Safari |
Send it anyway |
Fallback Patterns
Working default outside @supports, enhancement inside:
.tooltip {
position: absolute;
inset-block-start: 100%;
inset-inline-start: 0;
}
@supports (position-area: block-end) {
.tooltip {
position-anchor: --trigger;
position-area: block-end span-inline-end;
inset: auto;
position-try-fallbacks: flip-block, flip-inline;
}
}
Detect the capability, never the browser:
const yieldToMain = () =>
globalThis.scheduler?.yield?.() ?? new Promise((resolve) => setTimeout(resolve, 0));
Interop 2026
The engines’ shared focus list for the year. These are about to get more consistent: anchor positioning, container style queries, dialogs and popovers, scroll-driven animations, view transitions, typed attr(), contrast-color(), custom highlights, shape(), scroll snap, the Navigation API and zoom, among others. Live pass rates are at wpt.fyi/interop-2026.
Testing Checklist
- Chrome, Firefox, Edge: latest and latest-1
- Safari: latest on macOS and on a real iOS device. Every iOS browser is WebKit, and desktop emulation misses its viewport and touch behavior
- Samsung Internet
- One mid-range Android device, real or on a device cloud
Safari is usually the engine holding a feature out of Baseline. Anything in the newly available table gets one manual pass there.
From figma-design-system: Token Architecture
This file defines token naming for the whole skill library. color-system, web-platform, apple-platform, android-platform, visual-styles, and motion-design all follow it. Values aren’t defined here. Each family names the skill that has its values.
Contents
Three Tiers
Never apply a primitive directly to a component. Alias through a semantic token.
Tier 1: Primitive. Raw values, never applied directly. color/primitive/warm-200, spacing/primitive/16, radius/primitive/8.
Tier 2: Semantic. Meaning and intent. This is what gets applied. color/semantic/background/primary, spacing/5, radius/md.
Tier 3: Component. Enterprise scale only. button/primary/background/default, card/border.
Semantic tokens are the endpoint for most projects. Component tokens only pay off with multiple teams and strict governance. Three tiers is the max. Two is often enough.
Token Families
Figma paths are lowercase, slash-separated, kebab-case inside a segment. Every token in every skill is one of these.
| Family |
Figma path |
Names in use |
Values come from |
| Color, primitive |
color/primitive/<name> |
The palette name verbatim: swiss-red, warm-200, cool-900. A project ramp is <hue>-<step>, steps 50 to 950 |
color-system (reference/palette.md) |
| Color, semantic |
color/semantic/<group>/<role> |
Groups: background, text, border, status, interactive, accent. Roles below |
color-system (semantic mappings) |
| Color, component |
<component>/<variant>/<property>/<state> |
button/primary/background/hover, card/background. Drop segments that don’t apply |
Aliases of semantic tokens only |
| Spacing |
spacing/<step>, primitive spacing/primitive/<px> |
Steps 1 to 12 |
This file |
| Radius |
radius/<size>, primitive radius/primitive/<px> |
none, xs, sm, md, lg, xl, 2xl, sheet, pill |
This file |
| Size |
size/<name> |
Fixed dimensions: size/target-min, size/header-height |
Platform skills |
| Stacking |
z/<layer> |
One token per layer: z/modal, z/toast |
Platform skills |
| Effect |
effect/<kind>/<step> |
effect/blur/md, effect/glow/radius |
visual-styles, platform skills |
| Shadow |
shadow/<name> |
A whole shadow recipe: shadow/raised, shadow/inset, shadow/hard-sm |
visual-styles, platform skills |
| Typography |
typography/family/<name>, typography/size/<role>, typography/weight/<name>, typography/leading/<name>, typography/tracking/<name> |
Below |
Platform skills (sizes), this file (names) |
| Motion |
motion/spring/<name>, motion/duration/<name>, motion/ease/<name>, motion/distance/<size> |
Listed in motion-design (reference/cross-platform-tokens.md), not here |
motion-design |
| Glass |
glass/<parameter>/<variant> |
Figma-only, in liquid-glass.md |
No code equivalent. The system draws glass |
Semantic color roles:
color/semantic/background/primary | secondary | elevated
color/semantic/text/primary | secondary | disabled
color/semantic/border/default | focus
color/semantic/status/success | warning | error
color/semantic/interactive/primary | primary-hover | primary-pressed
color/semantic/accent/primary | secondary | tertiary
The six groups are fixed. accent is decorative brand color that isn’t interactive. New roles go inside a group (text/inverse, status/info). A state is a suffix on its role (-hover, -pressed, -disabled), not a new segment. Name by meaning, never by appearance: interactive/primary, not purple-500.
A style in visual-styles can declare extra style-local tokens. They follow the same families and the same conversion rule.
Conversion Rule
One Figma path gives every platform’s name mechanically. Nothing is abbreviated, reordered, or dropped.
- Figma: as written.
- CSS:
--, then every segment joined with -.
- Swift: every segment but the last is a PascalCase namespace. The last is lowerCamelCase.
- Kotlin: same as Swift, on the project’s token object.
| Figma |
CSS |
Swift and Kotlin |
color/semantic/background/primary |
--color-semantic-background-primary |
Color.Semantic.Background.primary |
color/primitive/warm-200 |
--color-primitive-warm-200 |
Color.Primitive.warm200 |
spacing/5 |
--spacing-5 |
Spacing.s5 |
radius/2xl |
--radius-2xl |
Radius.r2xl |
size/target-min |
--size-target-min |
Size.targetMin |
z/modal |
--z-modal |
Z.modal |
effect/blur/md |
--effect-blur-md |
Effect.Blur.md |
typography/size/display-lg |
--typography-size-display-lg |
Typography.Size.displayLg |
Hyphenated names become lowerCamelCase in Swift and Kotlin: expo-out is expoOut.
A last segment that starts with a digit can’t be a Swift or Kotlin identifier. It takes the lowercase first letter of its parent segment: spacing/5 → Spacing.s5, radius/2xl → Radius.r2xl. CSS needs no prefix.
Motion has two exceptions. They’re the only ones.
| Figma |
CSS |
Swift |
Kotlin |
motion/spring/snappy |
--spring-snappy |
Motion.Spring.snappy |
Motion.Spring.snappy() |
motion/duration/standard |
--duration-standard |
Motion.Duration.standard |
Motion.Duration.standard |
motion/ease/expo-out |
--ease-expo-out |
Motion.Ease.expoOut and Motion.Curve.expoOut |
Motion.Ease.expoOut |
motion/distance/sm |
--motion-distance-sm |
Motion.Distance.sm |
Motion.Distance.sm |
- CSS drops the
motion segment for springs, durations, and eases. motion/distance keeps it.
- In Swift,
motion/ease/<name> gives two members. Motion.Ease.<name> is the ready-to-use Animation, the curve at its default duration. Motion.Curve.<name> is the bare curve.
Kotlin follows the rule with nested objects. Springs are generic functions, so they’re called: Motion.Spring.snappy().
If a codebase already ships tokens under other names, the code’s names win. Rename the Figma variable so it converts to them. The rule decides names for anything new.
Code syntax carries the name. Every Figma variable has a per-platform code syntax field (Web, Android, iOS). Dev Mode and the MCP read tools show it. Set it on every variable, for every platform the project targets, to the literal string a developer types: var(--color-semantic-background-primary) for Web, with the var() wrapper, and Color.Semantic.Background.primary for iOS. A variable with the right path and an empty or wrong code syntax fails the token-name gate.
Platform-Supplied Tokens
Where the platform already provides the token, don’t redefine it in code. The Figma variable keeps its semantic path, because one file serves several platforms. The platform’s own name goes in that platform’s code syntax field.
| Figma token |
SwiftUI |
Compose (Material 3) |
color/semantic/background/primary |
Color(.systemBackground) |
MaterialTheme.colorScheme.surface |
color/semantic/background/secondary |
Color(.secondarySystemBackground) |
MaterialTheme.colorScheme.surfaceContainer |
color/semantic/text/primary |
Color(.label) |
MaterialTheme.colorScheme.onSurface |
color/semantic/text/secondary |
Color(.secondaryLabel) |
MaterialTheme.colorScheme.onSurfaceVariant |
color/semantic/border/default |
Color(.separator) |
MaterialTheme.colorScheme.outline |
color/semantic/interactive/primary |
Color.accentColor |
MaterialTheme.colorScheme.primary |
color/semantic/status/error |
project token |
MaterialTheme.colorScheme.error |
Use the platform token when it covers the case. Project tokens fill the gaps: brand colors, product-specific surfaces, anything the system palette doesn’t have. The full platform sets are in apple-platform and android-platform.
Variable Collections in Figma
Four collections, kept separate.
Primitives. All raw values. One mode. No light/dark here.
Semantic Color. Modes: Light, Dark. Every semantic color aliases a primitive. Switching modes swaps the alias target, not the token name.
Semantic Spacing, Radius, Motion. Number variables, one mode. Add modes only for density variants. If the file uses Figma Motion, durations and easing curves are timing and easing variables, not plain numbers.
Typography. String variables for font family and weight, number variables for size and line height. One family variable retypes the whole system.
On every variable:
- Scope it to where it can be used. A text color to text fills, a radius to corner radius. Primitives are scoped to nothing, so they can’t be applied directly. That enforces the tier rule.
- Code syntax per target platform.
Mode limits change what can be built. Light and Dark fit every paid plan. Before designing a collection around many modes (brand × theme × density × contrast), check the per-plan limit in platform-status.md. Multi-brand theming through extended collections is Enterprise only. On other plans, multi-brand is separate brand collections aliased by the semantic layer.
Light and Dark Modes
Both modes live in the Semantic Color collection. Every token has a Light value and a Dark value, and both alias primitives. Never put a raw hex value in a semantic token.
color/semantic/background/primary
Light: → color/primitive/warm-200
Dark: → color/primitive/cool-900
Which primitive each role aliases in each mode is color-system‘s call, or the project palette’s. Not this file’s.
Design in dark mode first. If it works in dark, it almost always works in light. The reverse doesn’t hold.
Set the mode on each screen’s top frame. Layers default to Auto and inherit from the nearest parent with a mode set, so nothing inside a component carries an explicit mode. For review, place each screen twice, one frame Light and one Dark, side by side.
Typography
typography/family/sans → "SF Pro" (iOS), "Roboto Flex" (Android), project typeface (Web)
typography/family/mono → "SF Mono", "Roboto Mono", project mono
typography/size/display-lg → 40
typography/size/display-sm → 32
typography/size/title-lg → 28
typography/size/title-md → 22
typography/size/title-sm → 20
typography/size/headline → 17
typography/size/body → 17
typography/size/callout → 16
typography/size/subhead → 15
typography/size/footnote → 13
typography/size/caption → 12
typography/weight/regular | medium | semibold | bold → "Regular", "Medium", "Semibold", "Bold"
typography/leading/tight | normal | relaxed → 1.2, 1.5, 1.7
These sizes are iOS and iPadOS at the default Dynamic Type size, from apple-platform (reference/color-typography.md). title-lg, title-md, title-sm are Apple’s title, title2, title3. macOS, Android, and Web sizes come from their platform skills. Where they differ, the Typography collection gets one mode per platform, not a second set of names.
One text style per semantic role, not per size:
Display Large / Small hero content, section headers
Title Large / Medium / Small screen titles, card headers, subheaders
Headline emphasized body
Body primary reading content
Callout supporting text
Subheadline metadata
Footnote timestamps, legal
Caption image captions, form help text
Label Large / Medium / Small button labels, tags, badges
Text styles reference the typography variables. Ask for that binding explicitly and read it back afterwards. The plugin’s library-generation path creates text styles with literal values unless told otherwise, and a style with raw numbers inside looks correct until the typeface changes. The font has to be uploaded to the Figma account. One installed only locally isn’t available to an MCP session.
Spacing and Radius
8pt grid. All spacing tokens are multiples of 4, preferring multiples of 8. Apply them as number variables to padding and gap in Auto Layout frames. No hardcoded values.
spacing/1 → 2 spacing/5 → 16 (base) spacing/9 → 40
spacing/2 → 4 spacing/6 → 20 spacing/10 → 48
spacing/3 → 8 spacing/7 → 24 spacing/11 → 64
spacing/4 → 12 spacing/8 → 32 spacing/12 → 80
radius/none → 0 radius/md → 12 radius/2xl → 28
radius/xs → 4 radius/lg → 16 radius/sheet → 34
radius/sm → 8 radius/xl → 24 radius/pill → capsule
radius/pill is a capsule, not a number. In code it’s the platform’s capsule shape (.capsule in SwiftUI), never a large radius. Figma needs a number, so the variable holds any value larger than half the tallest pill in the file. That number is a Figma artifact. It never goes in code syntax.
On iOS, radius/2xl is the floating-panel radius (Radius.r2xl, 28) and radius/sheet is the sheet radius (Radius.sheet, 34). Cards aren’t glass. On Android, radius/md is Material’s medium shape (12dp) and radius/2xl its extraLarge (28dp). The full shape scale is in android-platform.