opsinjs
FoundationsTheming & tokens

Design handoff

Getting the opsinjs token system into a design tool without retyping it, and keeping the two in step once you have.

Overview

Handoff usually fails in one specific way: a designer types a hex value into a style, an engineer types a slightly different hex value into a stylesheet, and six months later nobody can say which one is correct. The fix is not a better document. It is making one of them generated from the other.

opsinjs generates its tokens from JSON, which means the design tool can be downstream of the same source rather than parallel to it. This page describes the export format and the mapping into Figma Variables. Most important of all, it describes the three things that do not survive the trip and therefore have to stay in code.

Designers arriving here for the first time should start at For designers; this page is the mechanics.

What is available today

Two real artifacts, both already in the repository or served by the site:

tokens/*.json is the authored source. Six files: color, material, motion, type, space, shape. This is the truth, and it is small enough to read.

/r/themes/opsinjs-default.json is a served registry item whose cssVars object contains the resolved role tokens for light and dark. Convenient if your import script speaks HTTP rather than filesystem.

Between them you can already build an import today without waiting for anything.

The DTCG export

The intended artifact is a W3C Design Tokens Community Group format file, emitted by scripts/build-tokens.mts alongside the CSS, so that it is generated and diffed by the same check rather than exported by hand from a tab that somebody had open.

The proposed shape, so that it can be reviewed before it is built:

Proposed tokens.dtcg.json
{
  "opsin": {
    "status": {
      "$description": "Four ordinal clinical levels. Ordering is meaning.",
      "watch": {
        "surface": { "$type": "color", "$value": "oklch(0.966 0.042 85)" },
        "line":    { "$type": "color", "$value": "oklch(0.72 0.14 85)" },
        "ink":     { "$type": "color", "$value": "oklch(0.40 0.082 85)" }
      }
    },
    "duration": {
      "base": { "$type": "duration", "$value": { "value": 220, "unit": "ms" } }
    }
  }
}

Two decisions in that sketch are deliberate and worth arguing with now rather than later. The status group carries a $description stating that ordering is meaning, because a designer importing four colours has no other way to learn it. And colours are exported in OKLCH, which most tools cannot yet read. The alternative is exporting the sRGB clamp and silently losing the wide-gamut values.

Mapping into Figma Variables

Figma's variable model and the opsinjs tier model line up better than you would expect, with three exceptions.

opsinjsFigmaNotes
Tier 1 rampsA collection named opsin/ramps, one modePublished as a library; designers reference, never edit
Tier 2 rolesA collection named opsin/roles, modes light and darkAliases into the ramps collection
--radius, --spacingFLOAT variables in a scale collectionFigma has no calc, so derived steps are materialised
Status levelsFour variables, named in ordinal orderPrefix with 1-4- so the panel sorts correctly
Category rampsSix variables per slotGroup by slot, not by category, so a designer picks accent once

Roles alias ramps. This is the single most valuable part of the mapping. A role variable in the light mode should be an alias to a ramp variable, not a copy of its value. Then a ramp change propagates, and a designer who wants to retheme changes an alias rather than a colour. That is exactly the tier discipline the CSS enforces.

Modes are light and dark, and nothing else. Density and text size are not modes; they are FLOAT variables that a component consumes. Modelling them as modes produces a combinatorial explosion in the mode picker and does not match how the CSS works.

What does not survive the trip

Be explicit with your design team about these three, because each of them looks like a missing feature and is actually a boundary.

Display-P3. A Figma variable holds one colour. opsinjs holds two per token: an sRGB baseline and a wide-gamut escalation at identical lightness and hue. Import the sRGB baseline; the escalation is a rendering concern that only the browser can make. Nothing about meaning or measured contrast changes between them, which is why dropping it is safe. Gamut and P3 has the argument.

Motion. The two spring easings are CSS linear() curves with dozens of stops. No design tool models them, and approximating them with a cubic Bézier produces a curve that is visibly not the one that ships. Prototype with the tool's nearest equivalent, then check the real thing with <MotionDemo> on Springs as tokens.

The material ladder. Rungs 3 to 5 are backdrop blur plus translucency plus a border, and they collapse to opaque fallbacks under prefers-reduced-transparency. A design tool will happily render the blur and will not render the fallback, so a comp made from it shows one of the two states and hides the one that has the contrast problem. Review both, on The contrast floor.

Keeping the two in step

Make one direction canonical

tokens/*.json is upstream. The design library imports from it. Nothing imports back. A two-way sync between a repository and a design file has no conflict resolution and will drift.

Import, do not retype

Whatever plugin or script you use, it should read the export. A human copying values is the failure this whole page exists to prevent.

Re-import on a token change, not on a schedule

Token changes are rare and consequential. Tie the re-import to the changelog entry rather than to a weekly job, so a designer sees why something moved.

Check contrast in the browser, not in the tool

Design tools measure WCAG 2.1 ratios in sRGB. opsinjs publishes APCA and WCAG 2.2, measured against the shipped ramp, and the numbers will not match. Use /playground/contrast as the tiebreaker, and read Validating your theme before publishing a claim.

Verify it worked

Pick one role, one status and one radius at random, and compare each value in the design library against app/tokens.generated.css. If any differ, the import is stale or somebody edited the library by hand. Then confirm the negative: a status colour in the design file should be an alias, and editing it directly should be either impossible or obviously wrong.

Troubleshooting

Colours look different in the tool and the browser. Most tools work in sRGB and will show you the clamped value. That is the honest baseline; the browser is showing you the escalation on a capable display.

The dark mode looks wrong after import. Roles were imported as values rather than as aliases, so the dark mode is pointing at light-mode ramp entries.

A designer changed a token and nothing happened in the app. Correct, and intended. The design library is downstream. The change belongs in tokens/*.json. See Contributing tokens.

Next

On this page