States
figma.states classifies your library’s Figma variant props as semantic states. A library fact, declared in config/conventions.yaml: which prop expresses which state concept is an agreement no rule can recover — a wrong entry lands a concept on the wrong prop, and an unclassified prop emits as a data-* attribute. Declaring it enables two downstream behaviors:
- The
csstransformer emits real CSS pseudo-classes and ARIA attribute selectors instead ofdata-*attributes for classified props. - The
contracttransformer omits browser-driven props from generated Props interfaces.
Declare the classification once and both transforms apply it consistently.
How it works
The same config drives both CSS and contract output — classify a prop once, and both transforms apply it consistently.
Props from Figma
These props are typical outputs from specs generate — the raw Figma variant structure before any state classification is applied:
props: state: type: string default: rest enum: - rest - hover - pressed # cross-platform name; maps to :active on web isDisabled: # library uses "is" prefix convention type: boolean default: false focused: type: boolean default: falsefigma.states acts on these props during specs transform — to determine CSS selector strategy and contract inclusion. The api.yaml itself is not modified.
State concepts
Each concept resolves to a canonical CSS selector and determines whether the prop is included in or omitted from the generated Props interface.
| Concept | CSS selector(s) | Contract |
|---|---|---|
hover | :hover | omitted |
active | :active | omitted |
focus | :focus-visible | omitted |
focus-visible | :focus-visible | omitted |
focus-within | :focus-within | omitted |
placeholder-shown | :placeholder-shown | omitted |
disabled | :disabled, [aria-disabled="true"] | included |
readonly | [readonly], [aria-readonly="true"] | included |
required | [required], [aria-required="true"] | included |
invalid | [aria-invalid="true"] | included |
valid | [aria-invalid="false"] | included |
selected | [aria-selected="true"] | included |
checked | :checked, [aria-checked="true"] | included |
indeterminate | :indeterminate, [aria-checked="mixed"] | included |
expanded | [aria-expanded="true"] | included |
collapsed | [aria-expanded="false"] | included |
pressed | [aria-pressed="true"] | included |
busy | [aria-busy="true"] | included |
current | [aria-current="true"] | included |
Mapping Props to Concepts
Declare mappings under figma.states in config/conventions.yaml. Use prop to name the Figma variant prop and value for the specific enum value that activates the concept.
figma: states: active: prop: state # Figma value "pressed" → active concept → :active on web value: pressed disabled: # "is" prefix convention → disabled concept → :disabled / aria-disabled prop: isDisabledFigma naming conventions don’t need to match the concept name. Many design systems name their pointer-down state pressed rather than active because pressed is platform-neutral — it maps to :active on web, UIControlState.highlighted on iOS, and press Indication in Compose. Naming it active in Figma would embed a web-specific term into a shared design language. Similarly, a library using isDisabled as its boolean prop convention is still expressing the disabled concept.
:::tip Setting up for the first time?
Run the CSS States Setup skill in Claude Code — it scans your specs output directory, matches variant props against the concept table, and proposes a ready-to-paste figma.states block.
:::
CSS transform
Without states config, all variant props produce data-* attribute selectors:
/* Without states config */.ds-text-input[data-state="hover"] { … }.ds-text-input[data-is-disabled] { … } /* boolean prop → presence selector */.ds-text-input[data-focused] { … } /* boolean prop → presence selector */With states config, classified props produce semantic selectors:
/* With states config — disabled concept configured */.ds-text-input:hover:not(:disabled):not([aria-disabled="true"]) { … }.ds-text-input:disabled,.ds-text-input[aria-disabled="true"] { … }.ds-text-input:focus-within { … }Props not listed in states continue to emit as data-* attribute selectors. Unmatched values on a classified prop (e.g. the rest default on a state prop) are treated as the base state and skipped — the base block already covers them.
Contract transform
Browser-driven concepts (hover, active, focus, focus-within, etc.) are omitted from generated Props interfaces — the browser fires these without the application setting anything. Consumer-controlled concepts (disabled, readOnly, validation, etc.) are included — the consumer sets them and the component bridges them to the appropriate HTML or ARIA attribute.
// state and focused omitted — browser-driven, never set by consumersinterface TextInputProps { disabled?: boolean; // bridges to :disabled / aria-disabled readOnly?: boolean; // bridges to [readonly] / aria-readonly validation?: 'none' | 'invalid'; // bridges to aria-invalid}Configuration
figma: states: # Concept key → { prop, value?, contract? } # value: the Figma variant value that activates this concept (defaults to "true" for booleans) # contract: rarely needed — derived from the concept hover: prop: state value: hover active: prop: state value: pressed # Figma uses cross-platform name "pressed"; concept maps to :active focus-within: prop: focused # boolean prop; value defaults to "true" disabled: prop: isDisabled # library uses "is" prefix convention readonly: prop: readOnly invalid: prop: validation value: invalid # only one enum value maps to this concept expanded: prop: expandedProperties
| Property | Type | Required | Default | Description |
|---|---|---|---|---|
prop | string | Yes | — | Figma variant prop name (e.g. state, disabled, readOnly) |
value | string | No | "true" | Figma enum value that activates this concept. Omit for boolean props. |
contract | "omit" | "keep" | No | concept default | Override the concept’s default contract behavior. Rarely needed. |
Run specs transform css to regenerate stylesheets after updating this declaration. Absence of figma.states is safe — all variant props continue to emit as data-* selectors.
Path
figma.states in config/conventions.yaml
See Also
csstransformer — CSS output affected by this classificationcontracttransformer — Props interface affected bycontract: omitsubcomponents— another presence-driven convention