Design System Contracts

Spec reference

States & state previews

Interaction states, declared once — real pseudo-classes in code, generator-owned preview variants on the canvas, and token overrides down to individual parts.

Declared statesGeneratedCurated#

The interaction states the component must support. Declared once; rendered per surface at that surface’s fidelity — code gets real CSS pseudo-class rules, the canvas gets opt-in preview variants.

contract.states — rendered from the schema
states: Array<"hover" | "active" | "focus-visible" | "disabled">  // default: []
contracts/button.contract.json (excerpt · v1.5.0) — shipping contract, loaded at build time
{
  "states": [
    "hover",
    "focus-visible",
    "disabled"
  ],
  "anatomy": {
    "root": {
      "states": {
        "hover": {
          "background-color": "{color.action.{variant}.background-hover}"
        },
        "focus-visible": {
          "outline-color": "{color.border.focus}",
          "outline-width": "{border.width.focus}"
        },
        "disabled": {
          "opacity": "{opacity.disabled}"
        }
      }
    }
  }
}

State token overrides — root and partsGeneratedCurated#

part.states maps a state to token overrides: Record<state, Record<cssProperty, TokenRef>>. On the root: the full state vocabulary (background-color, outline-*, opacity, …). On a non-ref part (text, icon, box — never a component ref or slot): color-kind channels only (color, background-color, border-color), rendered as descendant rules under the root’s state selector (.root:disabled .label { color: … }) and applied inside canvas state-preview variants.

Refusals: Each of these fails the build by name — the generator refuses, it never papers over (source: core/emit-react.ts validateContract, exercised by the C2 eval family).

  • unknown state names; a part override for a state the contract’s states does not declare
  • states on a component-instance part (the child contract owns its styling) or on a slot (the consumer owns its content)
  • non-color channels on non-root parts
a part-level state override (schema v13) — illustrative, schema-validated at build time
{
  "element": "span",
  "tokens": {
    "color": "{color.text.primary}"
  },
  "states": {
    "disabled": {
      "color": "{color.text.secondary}"
    }
  }
}

No shipping contract in contracts/ uses part-level state overrides yet — the capability came from a brownfield field case (a disabled table row washing out its label) and is exercised by npm run extract:figma:partstate:check against committed fixtures. The example above is validated against the live schema at build time.

States by propGeneratedCurated#

statesByProp (v17, the hover-plane round) — an interaction state whose binding is also a function of an enum axis. states holds one ref per channel, and a root ref carrying a single {prop} placeholder already expands into per-value state rules — but that reaches only a token family ({button.bg.hover.{variant}}). The far commoner case is a design system whose per-variant state colours are unrelated names: Eventz’s hover backgrounds are comp/button/primary/color/background/hover on primary and comp/button/color/background/knockout-hover on knockout — no family, no shared stem, nothing a placeholder can reach. Measured, refusing that shape cost Button and Icon Button their entire hover and active planes, and a state crossed with a variant axis is the single most common pattern in a real design system.

The shape is tokensByProp’s plus the state it applies to — one map grammar in the vocabulary, not two. Entries are ordered like tokensByProp entries; refs are plain (the axis is already pinned by the map key). Code renders enum-class state rules (.variant-primary:hover), emitted after the plain states rules so the per-value binding wins at equal specificity — the tokensByProp cascade discipline applied to a state selector.

proprequiredstring

The driving enum prop, by canonical name.

staterequiredstring

The declared interaction state the map applies to.

maprequiredRecord<string, Record<string, TokenRef>>

enum value → (CSS property → plain TokenRef).

Refusals — the same discipline states is held to: Each of these fails the build by name — the generator refuses, it never papers over (source: core/emit-react.ts validateContract, exercised by the C2 eval family).

  • unknown state names; a state the contract’s states does not declare
  • component-instance and slot parts; non-color channels on non-root parts
  • the driving prop must be a declared enum; every map key one of its canonical values
  • a channel bound for the same state by BOTH states and statesByProp — ambiguous, refused by name rather than resolved by sheet order
per-variant hover backgrounds with unrelated token names (committed usage: examples/eventz-vars/contracts/atoms-button.contract.json) — illustrative, schema-validated at build time
{
  "statesByProp": [
    {
      "prop": "variant",
      "state": "hover",
      "map": {
        "primary": {
          "background-color": "{comp.button.primary.color.background.hover}"
        },
        "knockout": {
          "background-color": "{comp.button.color.background.knockout-hover}"
        }
      }
    }
  ]
}

Canvas state previews (figmaStatePreviews)GeneratedCurated#

Code gets real :hover/:focus-visible/:disabled; the canvas cannot run pseudo-classes, so real systems hand-build “State=Hover” variant axes — and those rot. figmaStatePreviews: true makes the generator own that axis instead: a State variant axis (Default, Hover, …) where each non-default state applies the state’s token overrides on top of the variant’s base bindings. The mirror image of code-only events: state previews are canvas-only, and the code surface is completely unaffected.

Bounded explosion: previews multiply only the primary enum axis — the one the overrides substitute ({color.action.{variant}.background-hover} names variant); every other axis sits at its default.

The axis is live, not just drawn. The generator wires Figma prototype reactions from every State=Default cell on the default plane to its twin: ON_HOVERCHANGE_TO State=Hover, ON_PRESSCHANGE_TO State=Active, both auto-reverting, always with transition: null (durations are not contract facts). focus-visible and disabled are excluded by name: Figma’s trigger vocabulary has no focus and no disabled trigger, so those cells stay static previews — destinations of nothing. Cells whose non-primary axes sit off their defaults have no twin, so they carry no reaction: a named coverage limit that follows from the bounded explosion above.

The opt-in is refused by name when: Each of these fails the build by name — the generator refuses, it never papers over (source: core/emit-react.ts validateContract, exercised by the C2 eval family).

  • the contract declares no states
  • any declared state has no root token overrides — its preview would render identically to Default
  • overrides substitute more than one enum prop
  • a prop already binds the design property State

The differ treats the axis as contract API in both directions: a missing axis on an opted-in contract is figma BEHIND; a hand-built State axis without the opt-in is figma AHEAD — the kit-rot detector — and the proposed patch is the honest one: figmaStatePreviews: true, never a bogus state prop.

contracts/button.contract.json (excerpt · v1.5.0) — shipping contract, loaded at build time
{
  "figmaStatePreviews": true,
  "states": [
    "hover",
    "focus-visible",
    "disabled"
  ]
}