Web Platform: css.md
Self-contained. The skill’s rules, then this topic, then anything it relies on from other skills.
Skill rules
How I build for the web: browser-native HTML, CSS and JS, the Next.js App Router, Astro, and Supabase auth. Plus the accessibility, performance, security and visual details that separate hand-built work from component-library output.
Evergreen browsers only. No legacy prefixes. No polyfills for anything above 95% support. Mobile-first: build for a mid-range Android phone on a cell network, not a desktop monitor.
JavaScript causes most performance problems. Load less of it. Move logic to the server. If CSS or HTML can do it, that’s where it goes.
Reference Files
Load the one or two files the task needs. Not the whole set.
| Topic |
File |
| Markup rules, images, forms, dialog, popover, invoker commands |
reference/html.md |
Layers, tokens, oklch, @scope, container and style queries, :has(), fluid type, anchor positioning, web motion rules |
reference/css.md |
| Built-ins, Temporal, tsconfig, TypeScript rules |
reference/javascript-typescript.md |
Cache Components and "use cache", proxy.ts, async request APIs, Server Functions, revalidation, prefetching, images, fonts |
reference/nextjs.md |
| Astro compiler rules, islands, content collections, server islands |
reference/astro.md |
@supabase/ssr clients, session refresh in proxy.ts, getClaims() vs getUser(), key naming, lane rules in code |
reference/supabase-integration.md |
Error boundaries, retry, Server Function result types, loading states |
reference/error-handling.md |
| WCAG 2.2 AA, contrast, focus, ARIA, form errors, user preferences, testing |
reference/accessibility.md |
| Touch targets, safe areas, pointer input, PWA |
reference/mobile-web.md |
| Security headers, CSP, input validation, Core Web Vitals, bundles |
reference/security-performance.md |
| Blend modes, clip-path, filters, custom cursor, SVG, Canvas 2D |
reference/advanced-visual-techniques.md |
| Baseline status of every web feature the skills name, support policy, fallbacks |
reference/browser-compatibility.md |
Other skills own the rest. Motion tokens and CSS motion technique: motion-design. Palette values: color-system. Token names: figma-design-system (reference/token-architecture.md). Style languages: visual-styles. Anything on Railway, including the Supabase secret key and JWT verification: backend-conventions. Keystatic on Astro sites: site-content-editing.
Version Snapshot
As of November 2026. Version numbers live in this table and nowhere else. Support status lives in reference/browser-compatibility.md and nowhere else. If that date is more than a few months old, run npm view <package> version and read the release blog before trusting it.
| Package |
Current |
Notes |
| Next.js |
16.4 (16.x is Active LTS) |
Cache Components is the recommended model and becomes the default in 17 |
| React |
19.3 |
<ViewTransition> is stable |
| Astro |
7.4 |
Rust compiler, Vite 8, Node 22+ |
| TypeScript |
7.0 |
Native compiler. strict is on by default |
@supabase/ssr |
0.12 |
setAll receives cache headers as a second argument |
@supabase/supabase-js |
2.x |
Publishable and secret keys. Supabase removes the legacy JWT API keys at the end of 2026 |
| Zod |
4 |
Astro re-exports it as astro/zod |
Rules for Every Web Task
CSS layer order. Declare it once at the top of the root stylesheet. A later layer beats an earlier one regardless of specificity:
@layer reset, base, tokens, components, utilities, overrides;
Tokens live in one :root block. Color, spacing, type, radius, shadow and motion all reference custom properties named after the design file’s token paths (--color-semantic-text-primary). No raw values in component rules. Colors are oklch().
Animate transform and opacity. They composite. width, height, top, left, margin and padding run layout every frame. Motion values come from the design or motion-design. Don’t invent them. Write motion inside prefers-reduced-motion: no-preference.
Server Components are the Next.js default. "use client" only for state, effects, event handlers or browser APIs. Keep it at the leaves.
Request APIs are async. Await cookies(), headers(), draftMode(), params and searchParams. Request interception lives in proxy.ts.
Caching is explicit. With Cache Components on, nothing is cached unless it’s marked "use cache". Anything that reads request data or uncached data sits inside <Suspense>.
Every input has a <label>. Validate on the server, always. Client-side validation is UX, not security.
Lane rules. Vercel is frontend only. Publishable key, RLS enforced, the secret key never touches it. The publishable key goes through @supabase/ssr with the user’s cookie session, and RLS via auth.uid() is the security boundary. A query that needs the secret key belongs on Railway behind an API endpoint. Every Server Function verifies the user itself, because it’s reachable by direct POST.
Support policy. Baseline widely available: use it freely. Newly available: use it, with an @supports or feature-detected fallback only where a miss would break function, not polish. Limited availability: progressive enhancement only. Check reference/browser-compatibility.md before reaching for anything newer than container queries.
Common Mistakes
| Mistake |
Why it’s wrong |
| JS-computed sizing or scroll listeners where container queries or scroll-driven animations do the job |
Layout reads force reflow on the main thread |
outline: none with no replacement |
Fails WCAG 2.4.7 |
user-scalable=no or maximum-scale=1 |
Blocks zoom. WCAG 1.4.4 |
html { font-size: <16px } or the 62.5% rem hack |
Overrides the user’s font-size preference |
| Placeholder text as a label |
Disappears on input and isn’t a dependable accessible name |
| Infinite scroll with no alternative |
Keyboard and screen-reader users lose the footer and their place |
loading="lazy" on the LCP image, or no fetchpriority="high" on it |
Directly hurts LCP |
revalidateTag("tag") with one argument |
Type error. Pass a profile: revalidateTag("tag", "max") |
priority on next/image |
Not the prop. The LCP image takes fetchPriority="high" with loading="eager" |
Plain @supabase/supabase-js client in server code on Vercel |
No cookie session, no refresh. Use @supabase/ssr |
getSession() to authorize on the server |
Reads the cookie without verifying it. Use getClaims() |
proxy.ts as the only auth check |
A matcher change silently removes it. Check in the page and the Server Function too |
Per-user data inside a shared "use cache" |
Replayed with no RLS check |
z from astro:content, or entry.render() |
z is from astro/zod. render() is imported from astro:content |
Topic
All CSS is custom. I don’t use Tailwind or any framework, and no preprocessor unless the project already has one. Support status for every feature is in browser-compatibility.md. Sections marked Newer aren’t widely available yet, so that file’s policy applies.
Who owns what:
- Token names come from
figma-design-system/reference/token-architecture.md. It’s the Figma path with slashes turned into hyphens: color/semantic/text/primary is --color-semantic-text-primary. Names in code and in the design file match exactly.
- Color values come from
color-system (reference/palette.md, reference/implementation.md).
- Motion tokens and CSS motion technique come from
motion-design (reference/cross-platform-tokens.md, reference/web-css-transitions.md). Samples here use its names (--duration-fast, --ease-enter) and never define values.
Contents
Layers and Tokens
@layer reset, base, tokens, components, utilities, overrides;
Each layer beats the ones before it regardless of specificity. !important is never needed to win a specificity fight. Unlayered styles beat every layer, so import third-party CSS into one: @import url("vendor.css") layer(reset);.
All tokens live in one :root block. Primitives hold raw values. Semantic tokens alias primitives, and components use the semantic ones. light-dark() keeps both color schemes in that one block. No second block inside a media query.
@layer tokens {
:root {
color-scheme: light dark;
--color-primitive-gray-50: oklch(98% 0 0);
--color-primitive-gray-900: oklch(15% 0 0);
--color-primitive-blue-300: oklch(78% 0.12 260);
--color-primitive-blue-500: oklch(60% 0.2 260);
--spacing-2: 0.5rem;
--spacing-4: 1rem;
--radius-md: 0.5rem;
--color-semantic-background-primary: light-dark(var(--color-primitive-gray-50), var(--color-primitive-gray-900));
--color-semantic-text-primary: light-dark(var(--color-primitive-gray-900), var(--color-primitive-gray-50));
--color-semantic-interactive-primary: light-dark(var(--color-primitive-blue-500), var(--color-primitive-blue-300));
}
/* Manual theme switch: flip the scheme, not the tokens */
:root[data-theme="light"] { color-scheme: light; }
:root[data-theme="dark"] { color-scheme: dark; }
}
@layer components {
.button {
background: var(--color-semantic-interactive-primary);
padding: var(--spacing-2) var(--spacing-4);
border-radius: var(--radius-md);
}
}
Those values show the shape. Real ones come from the design file and color-system. A component rule never contains a raw color, length, radius, shadow, duration or easing.
Nesting is native. Keep it to two levels. Deeper nesting rebuilds the specificity problems layers exist to end.
Color
oklch(lightness chroma hue). Equal lightness steps look equal, and changing lightness doesn’t drift the hue. HSL can’t promise either. Derive variants, don’t hand-pick them:
:root {
--color-semantic-interactive-primary-hover:
oklch(from var(--color-semantic-interactive-primary) calc(l - 0.08) c h);
--color-semantic-interactive-primary-subtle:
color-mix(in oklch, var(--color-semantic-interactive-primary) 20%, transparent);
}
contrast-color() (Newer) only picks black or white. Anything beyond that is color-system‘s job, along with palette generation, contrast math and gamut clamping.
@scope
Newer. Scopes a component’s rules to its subtree, with an optional lower boundary so they stop at nested components. It replaces BEM naming and CSS-in-JS scoping.
@scope (.card) to (.card-slot) {
:scope { padding: var(--spacing-4); }
img { border-radius: var(--radius-md); }
}
img matches images in .card, but not inside a .card-slot within it.
Container and Style Queries
Media queries are for page layout and user preferences. Component layout uses container queries, because a component doesn’t know how wide its slot is.
.card-wrapper { container: card / inline-size; }
.card {
display: grid;
@container card (inline-size >= 25rem) {
grid-template-columns: 12rem 1fr;
}
}
Newer: style queries. A parent sets a custom property once and descendants respond. No modifier class on each child. Only custom properties can be queried.
.card-wrapper { --variant: compact; }
@container style(--variant: compact) {
.card-meta { display: none; }
}
When cards in a row have to align their internal rows, use grid-template-rows: subgrid. Don’t equalize heights in JS.
:has()
.field:has(input:user-invalid) { color: var(--color-semantic-status-error); }
/* Lock page scroll while a modal dialog is open */
html:has(dialog:modal) { overflow: hidden; }
Use :user-invalid, not :invalid. It only matches after the user has interacted, so an empty required field isn’t red on load.
Fluid Type and Viewport Units
:root {
--typography-size-title-lg: clamp(1.75rem, 1.3rem + 2.5vw, 3rem);
}
The preferred value always includes a rem term. A size in vw alone ignores zoom and the user’s font-size setting, and fails WCAG 1.4.4. Same reason font sizes are never plain px.
100vh is the largest viewport on mobile. It overflows whenever browser chrome is visible. Use 100dvh for full-height sections, svh for content that can’t be cut off, lvh only for decorative backgrounds.
Anchor Positioning
Newer. The native replacement for JS positioning libraries in tooltips, menus and popovers.
.trigger { anchor-name: --trigger; }
.menu {
position: absolute;
position-anchor: --trigger;
position-area: block-end span-inline-end;
margin-block-start: var(--spacing-2);
position-try-fallbacks: flip-block, flip-inline;
}
position-area places the element on a 3×3 grid around the anchor. position-try-fallbacks flips it when it would overflow. Name the anchor explicitly, even for a popover and its invoker. Implicit anchoring isn’t consistent across engines. Keep a plain positioned fallback outside @supports (position-area: block-end). The pattern is in browser-compatibility.md.
Motion Rules
Technique and tokens are in motion-design/reference/web-css-transitions.md: entry and exit with @starting-style, intrinsic-size animation, scroll-driven animations, view transitions. Don’t write a second version here. The web rules on top of it:
- Motion is opt-in. Write it inside
@media (prefers-reduced-motion: no-preference), so the default state is the finished, readable one.
- The un-animated state is always complete. An element that starts at
opacity: 0 and needs an animation to appear is invisible where the feature is missing.
- Keep scroll-driven animations inside
@supports (animation-timeline: view()) until browser-compatibility.md lists them as widely available.
- Where a browser can’t transition
display, exit animations built on allow-discrete are skipped. That’s acceptable. Don’t add JS to paper over it.
- A disclosure animates height with
grid-template-rows: 0fr to 1fr. interpolate-size replaces that only once it’s in every browser.
- Scroll-driven animations scrub with the scroll position. For an animation that plays once when an element enters the viewport, IntersectionObserver plus a class is still the cross-browser tool.
- Each
view-transition-name is unique at the moment of the snapshot. In React use <ViewTransition> (nextjs.md), not document.startViewTransition. Cross-document transitions fall back to a normal navigation where unsupported.
CSS Instead of JavaScript
text-wrap: balance on headings, text-wrap: pretty on body copy.
field-sizing: content grows a <textarea> with its content. Newer.
sibling-index() for staggered delays without inline styles. Newer.
scrollbar-gutter: stable stops the shift when a scrollbar appears.
scroll-padding-block-start on the scroller keeps anchored and focused content clear of a sticky header.
@property gives a custom property a type, which makes it animatable (gradient angles, numeric counters).
Performance
Animate transform (or translate, scale, rotate) and opacity. They composite without layout or paint. width, height, top, left, margin and padding run layout every frame.
.card {
transition: translate var(--duration-fast) var(--ease-enter);
@media (hover: hover) {
&:hover { translate: 0 calc(-1 * var(--motion-distance-sm)); }
}
}
One exception: the disclosure above. Its content has to push other content down, and it’s small, user-triggered and short.
will-change only on an element about to animate, removed afterwards. Left on, it pins a layer in memory.
content-visibility: auto with contain-intrinsic-size skips rendering for long off-screen sections.
filter, backdrop-filter and mix-blend-mode are cheap to write and expensive to paint over large areas. Test on a mid-range phone.
Shared definitions
What this file relies on from other skills, copied in so nothing needs looking up.
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.
From color-system: Palette: Named Primitives
All 73 primitives with hex and OKLCH values. Hex is the source of truth. The OKLCH column is the same sRGB color, precise enough to round-trip to the identical 8-bit hex, so the two are interchangeable in CSS. Use OKLCH on web, hex or component values elsewhere.
Every value is inside sRGB. Wide-gamut and per-platform token setup are in implementation.md. Semantic roles, pairing rules, and contrast are in ../SKILL.md.
Contents
Swiss International Style
Derived from the poster work of Josef Muller-Brockmann, Armin Hofmann, Theo Ballmer, Max Bill, and others working in the International Typographic Style from the 1950s through the 1970s. They were used in deliberate, limited combinations, rarely more than two or three per composition.
The Swiss style used color functionally: one bold color as an accent against black and white, or two carefully chosen primaries in contrast. The colors are saturated but not garish. They read as strong and precise, not loud.
Reds
| Token |
Hex |
OKLCH |
Notes |
swiss-red |
#D52B1E |
oklch(56.71% 0.2067 29.54) |
Signal red. The Swiss flag red. Used in the most iconic posters. |
swiss-red-warm |
#E63229 |
oklch(60.33% 0.2164 28.55) |
Warmer, more orange-leaning variant. Muller-Brockmann road safety series. |
swiss-red-deep |
#B31B1B |
oklch(49.34% 0.1857 27.49) |
Deeper crimson. Shadows, backgrounds when red is the primary hue. |
swiss-red-dark |
#8C1414 |
oklch(41.28% 0.1541 27.35) |
Dark red. Near-black use cases within a red composition. |
swiss-vermillion |
#E8401A |
oklch(61.74% 0.2100 33.59) |
Orange-red. Appears in warmer poster compositions. |
Blues
| Token |
Hex |
OKLCH |
Notes |
swiss-blue |
#1B4499 |
oklch(41.20% 0.1468 262.50) |
Cobalt blue. The classic Swiss poster blue. |
swiss-blue-bright |
#1A5CB5 |
oklch(48.64% 0.1555 257.69) |
Brighter, more electric variant. Contrast against warm yellows. |
swiss-blue-deep |
#0F2E6E |
oklch(32.22% 0.1173 262.44) |
Deep navy-cobalt. Background use in blue-dominant compositions. |
swiss-cyan |
#0097A7 |
oklch(61.87% 0.1063 207.97) |
Cyan-teal. Appears in later Swiss style work, especially print. |
Yellows and Golds
| Token |
Hex |
OKLCH |
Notes |
swiss-yellow |
#F5C900 |
oklch(84.94% 0.1739 92.82) |
Warm primary yellow. The standard Swiss poster yellow. |
swiss-yellow-bright |
#FCE000 |
oklch(90.20% 0.1876 100.13) |
Brighter, purer yellow. Closer to pure primary. |
swiss-yellow-warm |
#F0B800 |
oklch(81.11% 0.1660 86.40) |
Amber-warm yellow. Pairs with deep blue or black backgrounds. |
swiss-gold |
#C8960A |
oklch(70.18% 0.1422 84.30) |
Deep gold. Tertiary use, printed-ink feel. |
swiss-amber |
#D4860A |
oklch(68.55% 0.1477 68.68) |
Amber. Appears in earth-toned compositions. |
swiss-ochre |
#865D07 |
oklch(50.91% 0.1037 77.95) |
Dark ochre. The yellow family at a weight that reads on light surfaces. Light-mode warning. |
Greens
| Token |
Hex |
OKLCH |
Notes |
swiss-green |
#1B6B3A |
oklch(46.78% 0.1091 152.01) |
Forest green. Less common but appears in nature/environment posters. |
swiss-green-bright |
#2D8A4A |
oklch(56.29% 0.1305 150.15) |
Brighter green. Spring/seasonal poster work. |
swiss-teal |
#0B7A6E |
oklch(52.14% 0.0906 182.82) |
Blue-green teal. Mid-century crossover with cyan palette. |
Oranges
| Token |
Hex |
OKLCH |
Notes |
swiss-orange |
#E05C1A |
oklch(63.61% 0.1795 42.38) |
Warm orange. Appears in consumer/lifestyle poster work. |
swiss-orange-deep |
#C44810 |
oklch(56.54% 0.1695 40.14) |
Deep burnt orange. Background-weight orange. |
Neutrals and Achromatics
The backbone of Swiss design. Most compositions are primarily these, with color used as accent.
| Token |
Hex |
OKLCH |
Notes |
swiss-black |
#0A0A0A |
oklch(14.48% 0 0) |
Near-black. More nuanced than pure #000000 in print. |
swiss-ink |
#1A1A1A |
oklch(21.78% 0 0) |
Standard ink black. Primary text color. |
swiss-charcoal |
#2E2E2E |
oklch(30.12% 0 0) |
Charcoal. Dark backgrounds that aren’t fully black. |
swiss-dark-gray |
#4A4A4A |
oklch(40.91% 0 0) |
Dark gray. Secondary text, supporting elements. |
swiss-mid-gray |
#787878 |
oklch(57.27% 0 0) |
Mid gray. Dividers, secondary backgrounds. |
swiss-light-gray |
#B4B4B4 |
oklch(76.99% 0 0) |
Light gray. Subtle backgrounds, disabled states. |
swiss-pale-gray |
#DCDCDC |
oklch(89.45% 0 0) |
Pale gray. Near-white backgrounds with warmth. |
swiss-off-white |
#F0EDE8 |
oklch(94.70% 0.0074 80.72) |
Off-white with slight warm tone. Paper-feel backgrounds. |
swiss-pure-white |
#FFFFFF |
oklch(100.00% 0 0) |
Pure white. For cases where maximum contrast is required. |
Bauhaus Primary Palette
From Johannes Itten’s color theory. Itten taught the Bauhaus preliminary course from 1919 to 1923 and published the system in The Art of Color (1961). The school itself ran until 1933. These colors are the purest articulation of primary-triad thinking in 20th-century design, and they shaped Swiss Style directly.
Itten’s system: three primaries at maximum saturation, always used in relationship to each other and to the neutrals black and white. Bauhaus white is swiss-pure-white.
| Token |
Hex |
OKLCH |
Notes |
bauhaus-red |
#C8302A |
oklch(54.87% 0.1896 27.76) |
Itten primary red. Neither blue-shifted nor orange-shifted. |
bauhaus-yellow |
#E8C018 |
oklch(81.91% 0.1639 93.23) |
Itten primary yellow. Warm gold-yellow, not pure lemon. |
bauhaus-blue |
#1E3878 |
oklch(35.96% 0.1142 264.64) |
Itten primary blue. Deep cobalt, not navy and not sky. |
bauhaus-black |
#000000 |
oklch(0.00% 0 0) |
Pure black. Itten used it as a structural color, not just text. |
bauhaus-gray |
#808080 |
oklch(59.99% 0 0) |
Neutral mid-gray. Itten’s intermediate between black and white. |
Secondaries (Itten’s mixed secondaries):
| Token |
Hex |
OKLCH |
Notes |
bauhaus-orange |
#D4600A |
oklch(62.29% 0.1669 47.85) |
Red + Yellow. Warm, saturated. |
bauhaus-green |
#2A7A3A |
oklch(51.49% 0.1247 147.26) |
Yellow + Blue. Forest, not lime. |
bauhaus-violet |
#5C2080 |
oklch(38.46% 0.1553 309.43) |
Red + Blue. Deep purple-violet. |
Mid-Century Modernist Palette
Colors from the wider 1950s-1970s modernist era: Swiss Style, the Bauhaus legacy, and related movements like Dutch Total Design and Italian rational design.
| Token |
Hex |
OKLCH |
Notes |
modernist-red |
#E0272A |
oklch(58.53% 0.2177 26.69) |
The standard mid-century poster red. |
modernist-blue |
#1C5CB2 |
oklch(48.46% 0.1510 257.50) |
The standard mid-century poster blue. |
modernist-yellow |
#F2C200 |
oklch(83.26% 0.1702 90.64) |
The standard mid-century poster yellow. |
modernist-green |
#1E6E44 |
oklch(47.93% 0.1022 155.81) |
Deep functional green. |
modernist-orange |
#D95B1A |
oklch(62.40% 0.1734 43.04) |
The characteristic mid-century orange-red. |
modernist-teal |
#00717A |
oklch(50.04% 0.0854 204.48) |
Characteristic 1960s teal. |
modernist-brown |
#6E3A1C |
oklch(40.81% 0.0849 48.65) |
Warm functional brown. Appears in earth-toned work. |
modernist-cream |
#F2EDE0 |
oklch(94.65% 0.0181 89.36) |
Warm cream. Slightly more yellow than warm-200. |
Contemporary Swiss-Influenced Palette
Colors used by designers working in the Swiss/modernist tradition today: the same principles (primary relationships, functional color, deliberate restraint) with full neutral ramps for interface work.
Warm Neutrals (Neo-Minimalism adjacent)
| Token |
Hex |
OKLCH |
Notes |
warm-100 |
#FAFAF7 |
oklch(98.43% 0.0040 106.47) |
Warm near-white. Avoids the sterility of pure #FFFFFF. |
warm-200 |
#F5F3EE |
oklch(96.43% 0.0070 88.64) |
Standard warm background. The Swiss paper tone, not pure white. |
warm-300 |
#EDE9E2 |
oklch(93.52% 0.0103 81.79) |
Warm light. |
warm-400 |
#E0DAD1 |
oklch(89.06% 0.0138 78.26) |
Warm mid-light. |
warm-500 |
#C4BCB0 |
oklch(79.84% 0.0189 78.22) |
Warm mid. |
warm-600 |
#9A9086 |
oklch(65.91% 0.0188 67.49) |
Warm mid-dark. |
warm-700 |
#6E655A |
oklch(51.18% 0.0204 72.87) |
Warm dark. |
warm-800 |
#433B31 |
oklch(35.74% 0.0201 73.27) |
Warm darker. |
warm-900 |
#2A2420 |
oklch(26.56% 0.0117 55.83) |
Near-black warm. |
warm-950 |
#1A1614 |
oklch(20.42% 0.0076 48.33) |
Very near-black warm. |
Cool Neutrals (Editorial, Futuristic adjacent)
| Token |
Hex |
OKLCH |
Notes |
cool-100 |
#F8F9FA |
oklch(98.16% 0.0017 247.84) |
Cool near-white. |
cool-200 |
#F0F1F3 |
oklch(95.79% 0.0029 264.54) |
Cool light background. |
cool-300 |
#E2E4E8 |
oklch(91.85% 0.0058 264.53) |
Cool light. |
cool-400 |
#C8CACE |
oklch(83.86% 0.0060 264.53) |
Cool mid-light. |
cool-500 |
#9EA2A8 |
oklch(71.08% 0.0098 258.34) |
Cool mid. |
cool-600 |
#74787F |
oklch(57.16% 0.0118 261.77) |
Cool mid-dark. |
cool-700 |
#4E5258 |
oklch(43.71% 0.0111 258.36) |
Cool dark. |
cool-800 |
#2E3036 |
oklch(30.96% 0.0111 271.04) |
Cool darker. |
cool-900 |
#1A1C20 |
oklch(22.60% 0.0083 264.39) |
Near-black cool. |
cool-950 |
#0E1014 |
oklch(17.27% 0.0089 264.31) |
Very near-black cool. |
Accent Lights (Dark Surfaces)
The Swiss accents lifted for dark backgrounds. On light surfaces use the swiss-* originals. Red has three steps because dark-mode interactive states get lighter: light, lighter, lightest.
| Token |
Hex |
OKLCH |
Notes |
accent-red-light |
#F6463B |
oklch(64.93% 0.2141 28.23) |
Light red for dark backgrounds. Dark-mode primary. |
accent-red-lighter |
#FD6E5F |
oklch(71.05% 0.1777 28.37) |
Lighter step of the same red. Dark-mode hover and error. |
accent-red-lightest |
#FD988A |
oklch(78.11% 0.1239 28.63) |
Lightest step of the same red. Dark-mode pressed. |
accent-blue-light |
#6296FC |
oklch(68.42% 0.1609 262.54) |
Light blue for dark backgrounds. Dark-mode info and accent. |
accent-yellow-light |
#FFDA00 |
oklch(89.27% 0.1839 96.61) |
Brighter yellow for impact. |
accent-green-light |
#45AB67 |
oklch(66.40% 0.1384 151.66) |
Light green for dark backgrounds. Dark-mode success. |
accent-orange-light |
#F07830 |
oklch(70.08% 0.1687 47.71) |
Light orange for dark backgrounds. |
accent-cyan-light |
#00B8CC |
oklch(71.61% 0.1233 208.50) |
Light cyan for dark backgrounds. |
Source Notes
Swiss International Style colors come from the posters: Muller-Brockmann’s Musica Viva and road safety series, Hofmann’s Basel Theater posters, Ballmer’s Norm poster (1928), and related works held by MoMA and the Museum of Design Zurich. Fabian Burghardt’s Swiss Style Color Picker (fabianburghardt.de/swisscolors, 2015) systematized these combinations, and the colors here line up with his. The hex values are a digitization of ink on paper. They’re a faithful approximation, not a colorimetric match.
Bauhaus primary palette values come from Hue Atlas (hueatlas.com) and its reading of Itten’s The Art of Color and archival originals. #C8302A / #E8C018 / #1E3878 sit in the middle of the range across those sources, allowing for the gamut difference between lithographic inks and RGB displays. Same caveat: ink to RGB is approximate.
From color-system: Implementing the Palette
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.
From motion-design: Motion Tokens
This is the one definition of the motion token names and their values on every platform. The other files in this skill, and the apple-platform, android-platform, web-platform, and visual-styles skills, use these names and don’t define their own. Change a value here and nowhere else.
Three families, matching the spring-vs-ease rule. Spring tokens for user-triggered motion. Duration and easing tokens, used together, for system-triggered motion. A project’s design files or existing token set override the values. The names stay. A visual style can declare extra tokens in its own file under the same naming rule. A style-local token is a token, not an inline value.
Contents
Spring Tokens
One spring per token, written three ways. All three platforms run the same spring: mass 1, stiffness (2π / duration)², damping ratio 1 – bounce.
| Token |
Use |
Spring |
Swift |
Kotlin |
CSS |
| snappy |
Press states, toggles, controls |
0.25s, bounce 0.15 |
Motion.Spring.snappy |
Motion.Spring.snappy() |
var(--spring-snappy) over var(--spring-snappy-duration) |
| standard |
Default for interactive elements: cards, sheets, menus, navigation |
0.35s, bounce 0 |
Motion.Spring.standard |
Motion.Spring.standard() |
var(--spring-standard) over var(--spring-standard-duration) |
| expressive |
Visible bounce: FABs, notifications, moments meant to delight |
0.4s, bounce 0.3 |
Motion.Spring.expressive |
Motion.Spring.expressive() |
var(--spring-expressive) over var(--spring-expressive-duration) |
| dragRelease |
An element let go after a drag, settling with the gesture’s velocity |
0.3s, bounce 0.28 |
Motion.Spring.dragRelease |
Motion.Spring.dragRelease() |
none: CSS can’t inherit gesture velocity, so drag release on web is driven from JavaScript |
A spring’s duration is perceptual, not its settle time. The CSS --spring-*-duration values (350ms, 520ms, 660ms) are settle times, which is why they’re longer. Each CSS curve is only valid at its paired duration.
Android apps themed with Material 3 Expressive take component and component-like motion from MaterialTheme.motionScheme instead. See android-platform. Motion.Spring.*() is for custom motion the scheme doesn’t cover.
Duration Tokens
For system-triggered motion, always paired with an easing token. Springs don’t take a duration token.
| Token |
Value |
Use |
Swift |
Kotlin |
CSS |
| micro |
83ms |
Feedback that isn’t spring-driven: icon swaps, color flips |
Motion.Duration.micro |
Motion.Duration.micro |
var(--duration-micro) |
| fast |
150ms |
Small state changes, exits, fades |
Motion.Duration.fast |
Motion.Duration.fast |
var(--duration-fast) |
| standard |
250ms |
Most transitions: dropdown open, toast appear, content swap |
Motion.Duration.standard |
Motion.Duration.standard |
var(--duration-standard) |
| slow |
350ms |
Large layout shifts, full-screen transitions |
Motion.Duration.slow |
Motion.Duration.slow |
var(--duration-slow) |
| cinematic |
600ms |
Deliberate editorial moments, ambient loops |
Motion.Duration.cinematic |
Motion.Duration.cinematic |
var(--duration-cinematic) |
Nothing shorter than micro. Below that, make the change instant. Nothing longer than 700ms in product UI. If an animation needs longer to feel right, more motion isn’t the fix. Scroll-scrubbed and ambient WebGL sequences have no duration of their own, so they’re exempt.
Easing Tokens
| Token |
Curve |
Use |
Swift |
Kotlin |
CSS |
| enter |
0, 0, 0.2, 1 |
Entrances: decelerates as it arrives |
Motion.Curve.enter |
Motion.Ease.enter |
var(--ease-enter) |
| exit |
0.4, 0, 1, 1 |
Exits: accelerates as it leaves |
Motion.Curve.exit |
Motion.Ease.exit |
var(--ease-exit) |
| standard |
0.4, 0, 0.2, 1 |
State changes that stay on screen |
Motion.Curve.standard |
Motion.Ease.standard |
var(--ease-standard) |
| emphasized |
0.2, 0, 0, 1 |
Quick start, long settle: hero and container moves |
Motion.Curve.emphasized |
Motion.Ease.emphasized |
var(--ease-emphasized) |
| expo-out |
0.16, 1, 0.3, 1 |
Very fast start, very long tail: reveals |
Motion.Curve.expoOut |
Motion.Ease.expoOut |
var(--ease-expo-out) |
| sine-in-out |
0.45, 0.05, 0.55, 0.95 |
Gentle and symmetrical: slow, ambient |
Motion.Curve.sineInOut |
Motion.Ease.sineInOut |
var(--ease-sine-in-out) |
Linear isn’t a token. It’s only right for continuous loops and scroll-linked motion.
Swift is the one exception to the naming rule. SwiftUI animations carry their duration, so motion/ease/<name> becomes two things: Motion.Curve.<name> is the bare curve, and Motion.Ease.<name> is the ready-to-use Animation, the curve at its default duration. The pairs are Motion.Ease.enter (enter, standard), exit (exit, fast), standard (standard, standard), emphasized (emphasized, slow), expoOut (expo-out, cinematic), fade (enter, fast), and ambient (sine-in-out, cinematic). In Kotlin Motion.Ease.enter, exit, and standard alias Compose’s LinearOutSlowInEasing, FastOutLinearInEasing, and FastOutSlowInEasing. Call sites use the token name.
Definitions per Platform
One definition site per platform. Call sites reference the token, never a raw curve, duration, or spring constant.
:root {
--duration-micro: 83ms;
--duration-fast: 150ms;
--duration-standard: 250ms;
--duration-slow: 350ms;
--duration-cinematic: 600ms;
--ease-enter: cubic-bezier(0, 0, 0.2, 1);
--ease-exit: cubic-bezier(0.4, 0, 1, 1);
--ease-standard: cubic-bezier(0.4, 0, 0.2, 1);
--ease-emphasized: cubic-bezier(0.2, 0, 0, 1);
--ease-expo-out: cubic-bezier(0.16, 1, 0.3, 1);
--ease-sine-in-out: cubic-bezier(0.45, 0.05, 0.55, 0.95);
/* Springs as linear() stops: mass 1, stiffness (2π / duration)²,
damping ratio 1 - bounce, sampled to the point the spring settles
within 0.1%. */
/* snappy: duration 0.25s, bounce 0.15 (stiffness 632, damping ratio 0.85) */
--spring-snappy: linear(0, 0.006 1.2%, 0.026 2.8%, 0.057 4.2%, 0.096 5.8%, 0.191 8.8%, 0.413 15%, 0.514 18%, 0.613 21.2%, 0.698 24.5%, 0.765 27.5%, 0.825 30.8%, 0.873 34%, 0.914 37.5%, 0.946 41.2%, 0.971 45.5%, 0.989 50.2%, 1 55.8%, 1.006 67.8%, 1);
--spring-snappy-duration: 350ms;
/* standard: duration 0.35s, bounce 0 (stiffness 322, damping ratio 1) */
--spring-standard: linear(0, 0.004 1%, 0.019 2.2%, 0.043 3.5%, 0.08 5%, 0.156 7.5%, 0.367 13.8%, 0.463 16.8%, 0.557 20%, 0.638 23.2%, 0.707 26.5%, 0.769 30%, 0.819 33.5%, 0.862 37.2%, 0.901 41.8%, 0.93 46.5%, 0.954 52%, 0.972 58.2%, 0.984 65.2%, 0.992 73.5%, 1);
--spring-standard-duration: 520ms;
/* expressive: duration 0.4s, bounce 0.3 (stiffness 247, damping ratio 0.7), 4.6% overshoot */
--spring-expressive: linear(0, 0.008 1.2%, 0.03 2.5%, 0.063 3.8%, 0.114 5.2%, 0.218 7.8%, 0.476 13.2%, 0.587 15.8%, 0.697 18.5%, 0.783 21%, 0.855 23.5%, 0.918 26.2%, 0.966 29%, 1.003 32%, 1.022 34.2%, 1.036 36.8%, 1.044 39.5%, 1.046 42.5%, 1.041 47.8%, 1.012 62.3%, 1.003 70%, 0.998 81.8%, 1);
--spring-expressive-duration: 660ms;
--motion-distance-sm: 12px;
--motion-distance-md: 24px;
}
import SwiftUI
enum Motion {
enum Duration {
static let micro: TimeInterval = 0.083
static let fast: TimeInterval = 0.15
static let standard: TimeInterval = 0.25
static let slow: TimeInterval = 0.35
static let cinematic: TimeInterval = 0.6
}
enum Curve {
static let enter = UnitCurve.bezier(startControlPoint: UnitPoint(x: 0, y: 0), endControlPoint: UnitPoint(x: 0.2, y: 1))
static let exit = UnitCurve.bezier(startControlPoint: UnitPoint(x: 0.4, y: 0), endControlPoint: UnitPoint(x: 1, y: 1))
static let standard = UnitCurve.bezier(startControlPoint: UnitPoint(x: 0.4, y: 0), endControlPoint: UnitPoint(x: 0.2, y: 1))
static let emphasized = UnitCurve.bezier(startControlPoint: UnitPoint(x: 0.2, y: 0), endControlPoint: UnitPoint(x: 0, y: 1))
static let expoOut = UnitCurve.bezier(startControlPoint: UnitPoint(x: 0.16, y: 1), endControlPoint: UnitPoint(x: 0.3, y: 1))
static let sineInOut = UnitCurve.bezier(startControlPoint: UnitPoint(x: 0.45, y: 0.05), endControlPoint: UnitPoint(x: 0.55, y: 0.95))
}
enum Spring {
static let snappy = Animation.snappy(duration: 0.25) // bounce 0.15
static let standard = Animation.spring(duration: 0.35, bounce: 0)
static let expressive = Animation.spring(duration: 0.4, bounce: 0.3)
static let dragRelease = Animation.spring(duration: 0.3, bounce: 0.28)
}
enum Ease {
static let enter = Animation.timingCurve(Curve.enter, duration: Duration.standard)
static let exit = Animation.timingCurve(Curve.exit, duration: Duration.fast)
static let standard = Animation.timingCurve(Curve.standard, duration: Duration.standard)
static let emphasized = Animation.timingCurve(Curve.emphasized, duration: Duration.slow)
static let expoOut = Animation.timingCurve(Curve.expoOut, duration: Duration.cinematic)
static let fade = Animation.timingCurve(Curve.enter, duration: Duration.fast)
static let ambient = Animation.timingCurve(Curve.sineInOut, duration: Duration.cinematic)
}
}
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.Easing
import androidx.compose.animation.core.FastOutLinearInEasing
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.LinearOutSlowInEasing
import androidx.compose.animation.core.SpringSpec
import androidx.compose.animation.core.spring
object Motion {
object Spring {
fun <T> snappy(): SpringSpec<T> = spring(dampingRatio = 0.85f, stiffness = 632f)
fun <T> standard(): SpringSpec<T> = spring(dampingRatio = 1f, stiffness = 322f)
fun <T> expressive(): SpringSpec<T> = spring(dampingRatio = 0.7f, stiffness = 247f)
fun <T> dragRelease(): SpringSpec<T> = spring(dampingRatio = 0.72f, stiffness = 439f)
}
object Duration {
const val micro = 83
const val fast = 150
const val standard = 250
const val slow = 350
const val cinematic = 600
}
object Ease {
val enter: Easing = LinearOutSlowInEasing
val exit: Easing = FastOutLinearInEasing
val standard: Easing = FastOutSlowInEasing
val emphasized: Easing = CubicBezierEasing(0.2f, 0f, 0f, 1f)
val expoOut: Easing = CubicBezierEasing(0.16f, 1f, 0.3f, 1f)
val sineInOut: Easing = CubicBezierEasing(0.45f, 0.05f, 0.55f, 0.95f)
}
}
Usage:
.button { transition: scale var(--spring-snappy-duration) var(--spring-snappy); }
.toast { transition: opacity var(--duration-standard) var(--ease-enter); }
withAnimation(Motion.Spring.standard) { isExpanded.toggle() }
animateFloatAsState(target, animationSpec = Motion.Spring.snappy(), label = "scale")
tween<Float>(durationMillis = Motion.Duration.standard, easing = Motion.Ease.enter)
GSAP registers the easing tokens under the same names ("enter", "expo-out", and so on). See gsap.md.
Reduced Motion Policy
The policy lives here. Each platform file only shows the API that reads the setting. The reasons are in foundations.md.
When the user asks for reduced motion:
- Spatial motion becomes a fade. Replace slides, zooms, scale, and shared-element travel with an opacity change at fast with enter (
Motion.Ease.fade), or an instant change.
- Springs lose their bounce. Where a spring stays (a control following a finger), use standard, never expressive.
- Scroll-linked motion goes. No parallax, no scroll-driven transforms, no pinned or scrubbed sequences. Content sits in its final position. Smooth scrolling is off.
- Autoplaying and looping decoration stops. A spinner that says “working” can stay.
- Feedback stays. Color, opacity, and state changes still happen. Reduced motion never means a control gives no response.
Android’s “Remove animations” goes further. It zeroes the animator duration scale, so Compose animations jump to their end state and no substitute fade runs. See android-compose.md.
Anti-Patterns
Linear motion on interactive elements. Nothing physical moves at constant velocity. Use a spring or an ease.
Identical timing for all properties. Opacity that finishes slightly before position reads as one arrival. Opacity that finishes long before it shows the destination before the journey is over.
Animating layout properties. width, height, top, left, margin, padding trigger layout every frame. Animate render-time properties: transform/translate/scale and opacity on web, scaleEffect/offset in SwiftUI, graphicsLayer in Compose.
Interrupting without velocity handoff. When a user reverses direction mid-flight, the new animation has to pick up the current velocity. Springs do. Duration-based animations restart from rest.
Staggering too much. 30-60ms between items creates rhythm. 150ms makes the UI feel slow. Stagger the first several items and bring the rest in together.
Decorative animation on high-frequency paths. Press feedback is snappy or micro, nothing longer.
Parallax on critical content. It can’t be what positions content a user needs to read.
Inline motion values. A raw cubic-bezier(), a bare millisecond count, or spring constants at a call site.
Not testing interruption. Tap, release, tap again rapidly. Swipe, reverse, swipe again. If it breaks, it’s duration-based where it should be a spring, or it restarts from a hard-coded start value.
Skipping reduced motion. Test every animated surface with the system setting on.
From motion-design: Web: CSS, Web Animations API & View Transitions
CSS comes first for web motion. transform, opacity, and filter can animate on the compositor and stay smooth while the main thread is busy. GSAP (gsap.md) is for timeline choreography, pinning, and text splitting. Nothing else.
Every sample uses the tokens from cross-platform-tokens.md: --duration-*, --ease-*, --spring-*, --motion-distance-*. That :root block is the only place a curve or duration literal appears.
Status
Support status lives in web-platform/reference/browser-compatibility.md. Check it before shipping. What these samples depend on, as of November 2026:
- Safe everywhere:
linear(), individual transform properties, Web Animations API, @starting-style entrances, same-document View Transitions.
- New in one engine, keep the fallback: scroll-driven animations (Baseline since 2026-10; Firefox only just shipped them).
- Enhancement only: animated exits via
display (no Firefox), overlay and interpolate-size (Chromium), cross-document View Transitions (no Firefox). The page has to be correct without them, just less animated.
Contents
Transitions
.reveal {
opacity: 0;
translate: 0 var(--motion-distance-md);
transition:
translate var(--duration-slow) var(--ease-enter),
opacity var(--duration-standard) var(--ease-enter);
}
.reveal.visible {
opacity: 1;
translate: 0 0;
}
The individual translate, scale, and rotate properties composite exactly like transform and let each take its own duration and curve.
Don’t transition width, height, top, left, margin, padding, or border-width. They trigger layout every frame. For layout changes use a View Transition, FLIP (gsap.md), or the intrinsic-size techniques below. Don’t leave will-change on permanently. It holds a layer’s memory for as long as it applies, and the browser already promotes an element while it animates.
Springs with linear()
CSS has no spring physics, but linear() takes any number of stops, so a spring’s response can be sampled into one. That’s what the --spring-* tokens are.
.button {
transition: scale var(--spring-snappy-duration) var(--spring-snappy);
}
.button:active {
scale: 0.97;
}
Two limits:
- It’s a spring’s shape, not a spring. The curve assumes a start from rest. An interrupted transition restarts from the current value with zero velocity. For taps, hovers, toggles, and menus you can’t tell it from a real spring. For drag release, flings, or anything that inherits gesture velocity, compute the spring in JavaScript per frame.
- A curve is only valid at its paired duration. Stretch
--spring-expressive over two seconds and you get a slow wobble, not a softer spring. A different feel is a new token with new spring parameters, defined in the tokens file.
Entry and Exit
@starting-style gives an element a “before” state the first time it renders or leaves display: none, so entrances need no JavaScript.
.toast {
opacity: 1;
translate: 0 0;
transition:
opacity var(--duration-standard) var(--ease-enter),
translate var(--duration-standard) var(--ease-enter);
@starting-style {
opacity: 0;
translate: 0 var(--motion-distance-sm);
}
}
Exits need the element to stay rendered until the transition ends. allow-discrete lets display take part so it flips at the end, and overlay keeps a popover or dialog in the top layer meanwhile:
.popover {
opacity: 0;
transition:
opacity var(--duration-fast) var(--ease-exit),
display var(--duration-fast) allow-discrete,
overlay var(--duration-fast) allow-discrete;
}
.popover:popover-open {
opacity: 1;
transition-timing-function: var(--ease-enter);
@starting-style {
opacity: 0;
}
}
Where exits aren’t supported the element disappears immediately. That’s fine. Don’t add JavaScript just to animate an exit.
Animating to Intrinsic Size
Opening a disclosure to height: auto has no transform equivalent.
/* Works everywhere: animate a grid track from 0fr to 1fr */
.disclosure {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows var(--duration-standard) var(--ease-standard);
}
.disclosure[data-open="true"] {
grid-template-rows: 1fr;
}
.disclosure > .content {
overflow: hidden;
min-height: 0;
}
/* Chromium enhancement: lets height transition to and from auto */
:root {
interpolate-size: allow-keywords;
}
Both animate layout, so keep them to small regions.
Web Animations API
When JavaScript has to start, reverse, seek, or await an animation, use element.animate() before a library. It runs the same compositor-backed animations and reads the same tokens.
const rootStyle = getComputedStyle(document.documentElement);
const token = (name) => rootStyle.getPropertyValue(name).trim();
async function dismiss(card) {
const animation = card.animate(
{ opacity: [1, 0], translate: ["0 0", `0 ${token("--motion-distance-sm")}`] },
{
duration: parseFloat(token("--duration-fast")),
easing: token("--ease-exit"),
fill: "forwards"
}
);
await animation.finished;
card.remove();
}
reverse(), playbackRate, and currentTime cover interruption and scrubbing. If the element stays in the DOM, call commitStyles() then cancel(). Don’t leave a fill: "forwards" animation holding styles.
Scroll-Driven Animations
No JavaScript, and transform/opacity keyframes run on the compositor. Use them for scroll progress, reveal-on-enter, and single-element parallax. Move to GSAP ScrollTrigger when a section has to pin or one scrubbed timeline choreographs many elements.
The timing function is linear on purpose: scroll position is the clock. Shape the motion with keyframe offsets and animation-range.
/* Scroll progress timeline: tied to a scroll container's position */
.progress-bar {
transform-origin: left;
animation: grow linear both;
animation-timeline: scroll(root block); /* after the shorthand, which resets it */
}
@keyframes grow {
from { scale: 0 1; }
to { scale: 1 1; }
}
/* View timeline: tied to the element's own visibility in the scrollport */
.section-reveal {
animation: reveal linear both;
animation-timeline: view();
animation-range: entry 0% cover 40%;
}
@keyframes reveal {
from { opacity: 0; translate: 0 var(--motion-distance-md); }
to { opacity: 1; translate: 0 0; }
}
/* Named timeline: several elements driven by one scroller */
.scroll-container {
scroll-timeline: --hero-scroll block;
}
.hero-text {
animation: fade-out linear both;
animation-timeline: --hero-scroll;
animation-range: 0% 30%;
}
@keyframes fade-out {
to { opacity: 0; }
}
For the fallback, ship a base state driven by a class from IntersectionObserver and upgrade inside @supports.
.animated-element {
opacity: 0;
translate: 0 var(--motion-distance-md);
transition:
opacity var(--duration-slow) var(--ease-enter),
translate var(--duration-slow) var(--ease-enter);
}
.animated-element.visible {
opacity: 1;
translate: 0 0;
}
@supports (animation-timeline: view()) {
.animated-element {
transition: none;
animation: reveal linear both;
animation-timeline: view();
animation-range: entry 0% entry 60%;
}
}
View Transitions
Same-document transitions start from JavaScript. Cross-document transitions are declared in CSS for multi-page sites. A browser without support just navigates normally.
function navigateTo(url) {
if (!document.startViewTransition) {
return loadPage(url);
}
return document.startViewTransition(() => loadPage(url)).finished;
}
/* The default is a root cross-fade. Name an element to morph it between states. */
.hero-image {
view-transition-name: hero;
}
::view-transition-group(hero) {
animation-duration: var(--duration-slow);
animation-timing-function: var(--ease-emphasized);
}
::view-transition-old(root) {
animation: var(--duration-fast) var(--ease-exit) both fade-out;
}
::view-transition-new(root) {
animation: var(--duration-standard) var(--ease-enter) both fade-in;
}
@keyframes fade-out { to { opacity: 0; } }
@keyframes fade-in { from { opacity: 0; } }
A view-transition-name has to be unique among rendered elements at capture time. Two elements sharing one aborts the transition. For lists, give each item its own name with an inline style and style them as a group with view-transition-class.
Cross-document:
/* In the CSS of every page that should participate */
@view-transition {
navigation: auto;
}
Both documents have to opt in. The transition only runs for a same-origin navigation with no cross-origin redirect. Back and forward qualify. A new navigation qualifies only when it starts from the page (link, form, script), not from browser UI like the address bar. Reloads never transition. Names and pseudo-elements work exactly as above.
Reduce Motion (Web)
The policy is in cross-platform-tokens.md. In CSS, write spatial motion as an opt-in for users who haven’t asked for less. The fade stays for everyone.
.reveal {
opacity: 0;
transition: opacity var(--duration-fast) var(--ease-enter);
}
.reveal.visible {
opacity: 1;
}
@media (prefers-reduced-motion: no-preference) {
.reveal {
translate: 0 var(--motion-distance-md);
transition:
translate var(--duration-slow) var(--ease-enter),
opacity var(--duration-standard) var(--ease-enter);
}
.reveal.visible {
translate: 0 0;
}
}
/* Scroll-linked motion and View Transition movement are removed outright */
@media (prefers-reduced-motion: reduce) {
.parallax,
.section-reveal {
animation: none;
opacity: 1;
translate: none;
}
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation: none;
}
html {
scroll-behavior: auto;
}
}
The global reset (* { animation-duration: 0.01ms !important; ... }) kills fades and spinners too. Scope it to third-party markup you don’t control. It isn’t the primary mechanism.
In JavaScript, window.matchMedia("(prefers-reduced-motion: reduce)") gives the same answer. Listen for its change event, because the user can flip the setting while the page is open. GSAP doesn’t read the preference on its own (gsap.md). Lenis does by default.