Skip to content

0030. 0030 — Interaction states are a table on the element

Status: accepted 2026-10-30.

Users want Figma/Framer-style components in a simple mode: a ”+” to add a variant, a state switcher (Default, Hover, Pressed) and, on any element, per-state overrides that animate with a curve when the pointer hovers it in the prototype and the web export. Components already exist (component.set + component.variant in src/lib/motion/variants.ts); prototype links already carry triggers and a transition with easing and duration (packages/motion-core/src/motion/prototype/model.ts).

FigmaFramerFeega
Container of variantsComponent set (dashed purple frame)Component with variantsComponent set (component.set)
Add a variant”+” just below the selected component set; a second ”+” next to Add property creates a variant propertyAdd a variant from the component’s variant bar”+” below the selected instance → appendVariant (first property, default name Variant, values Variant N as Figma)
State namingVariant property, convention State=Default/Hover/PressedBuilt-in Hover and Pressed variants, plus custom onesBuilt-in default, hover, pressed, focus rows on the element
Where the state livesA whole variant (copy of the component)A variant of the component (override layer props)A row of overrides on the element itself — no copy
TriggeringInteractive components: “On click” / “On hover” + Change to (variant). Prototype “While hovering” / “While pressing” go back on leave/release. A prototype interaction with the same trigger overrides the variant one. Components remember their last variant unless resetHover and Press states branch from one variant and carry their interaction automaticallyAutomatic in the prototype player and export: pointer enter/leave, down/up, focus
TransitionSmart Animate + easing preset or custom bezier/spring + durationSpring or tween per transition (Motion: physical props default to spring)transition: { easing, durationMs } per row; easing is a house ease id or a custom bezier, edited with the existing curve picker
OverridableEvery visual propertyEvery visual propertySTATE_OVERRIDES: opacity, scale, rotateZ, blur, fill, stroke, strokeWidth, cornerRadius
Any element, not only componentsNo (needs a component)No (needs a component)Yes: text, shape, frame, image

Sources read 2026-10-30: help.figma.com “Create and use variants” (360056440594), “Create interactive components” (360061175334), “Prototype triggers” (360040035834); framer.com/learn/component-variants; framer.com/motion/transition and /gestures.

  1. States are data on the element: clip.states, one row per state (stateRowSchema: state, overrides, transition). No row for default — the element as drawn is the default. One registry, STATE_OVERRIDES, says for every overridable key where it lands (transform or props) and how it is validated; a new property is a row there.
  2. Resolution is one pure function, resolveState(clip, state), shared by canvas preview, prototype player and export.
  3. Component variants stay what they are (component.set/variant): the ”+” button calls addVariant; built-in states are element rows, so a component and its instances get them without a variant per state. This is Framer’s built-in Hover/Pressed rather than Figma’s variant-per-state, chosen because it works on every element and doubles nothing.
  4. Agent parity: set_element_state / remove_element_state motion tools, which the edit catalogue exposes to MCP.
  5. Optimistic without a REGISTRY row. REGISTRY runs canvas rows (nodes, edges). states is a field inside a frame document, edited through FrameEditSession.apply, which already changes local state in the same frame and saves in the background with undo. A second path would mean two writers for one document.
  6. Preview is view state, never saved. The inspector switcher writes statePreview (per frame, per clip); the canvas draws previewDoc(doc, picks). The prototype player tweens the same picks with amountAt and mixState.
  • Figma’s variant-per-state: a hover on a single button would copy the whole component.
  • Spring transitions: the house eases cover the cases; a spring is a new row in the ease table when needed.

Imported pages bring accordions, tabs, switches and menus, which change structure, not just a colour: a row of overrides cannot open an FAQ answer. They need Figma’s interactive components.

  • ActionKind.ChangeTo (change_to) carries variant ({ property: value }) and no target. It changes the nearest component instance around the layer whose set has that variant (changeTo in src/lib/motion/prototype/variant-change.ts); inner layers keep their own links, so each variant of a main says where its triggers lead, as in Figma.
  • The player keeps the swap as view state, never saved, and finds each spot by the precomp path down to the layer (pathRect), so two instances of one main are two spots. A While hovering link swaps back on leave.
  • URL import builds one set per part with a variant per state (default = the state the page was in); the part’s triggers carry the links.
  • Element states now also play inside groups and instances: an instance whose main has its own Hover row shows that main drawn hovered (withStatedComps), a set with State=Hover swaps. A fill row on a text writes its color, as the export already did.
  • Discarded: a toggle action (one link that flips between two variants), which cannot say which of N tabs to show and which Figma does not have; a page-level link naming a variant comp id, which breaks when the set is edited.