diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -39,6 +39,7 @@ jobs:
         run: |
           sudo apt-get update
           sudo apt-get install -y --no-install-recommends \
+            build-essential \
             libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev \
             libgl1-mesa-dev libgtk-3-dev
 
diff --git a/.gitignore b/.gitignore
--- a/.gitignore
+++ b/.gitignore
@@ -8,3 +8,4 @@ plans/
 /log/
 .DS_Store
 *.profraw
+MEMORY.md
\ No newline at end of file
diff --git a/crates/collab/src/merge_list_tests.rs b/crates/collab/src/merge_list_tests.rs
--- a/crates/collab/src/merge_list_tests.rs
+++ b/crates/collab/src/merge_list_tests.rs
@@ -175,6 +175,7 @@ fn interactions_merge_item_by_item() {
         duration_ms,
         easing: Default::default(),
         delay_ms: 0,
+        reset_component_state: false,
     };
     let durations = |doc: &Document, r: NodeId| doc.node(r).unwrap().interactions.iter().map(|i| i.duration_ms).collect::<Vec<_>>();
     let (mut base, _, _, r) = files();
diff --git a/crates/doc/src/colors_tests.rs b/crates/doc/src/colors_tests.rs
--- a/crates/doc/src/colors_tests.rs
+++ b/crates/doc/src/colors_tests.rs
@@ -33,6 +33,7 @@ fn colourful() -> (Document, NodeId) {
             duration_ms: 300,
             easing: Default::default(),
             delay_ms: 0,
+            reset_component_state: false,
         }];
     }
     if let Some(NodeKind::Text(td)) = d.node_mut(t).map(|n| &mut n.kind) {
diff --git a/crates/doc/src/proto.rs b/crates/doc/src/proto.rs
--- a/crates/doc/src/proto.rs
+++ b/crates/doc/src/proto.rs
@@ -716,6 +716,11 @@ pub struct Interaction {
     /// Mouse enter, leave, down and up: how long after the gesture the actions run (ms).
     #[serde(default, alias = "delayMs", skip_serializing_if = "is_zero")]
     pub delay_ms: u32,
+    /// Reset component state: when true, this interaction doesn't share or memorize the
+    /// interactive component's variant state across frames/instances (Figma's "Reset component
+    /// state" checkbox in the Interaction details panel).
+    #[serde(default, skip_serializing_if = "is_false")]
+    pub reset_component_state: bool,
 }
 
 impl Interaction {
diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs
--- a/crates/doc/src/tests.rs
+++ b/crates/doc/src/tests.rs
@@ -98,6 +98,7 @@ fn duplicate_remaps_ids_and_interactions() {
         duration_ms: 300,
         easing: proto::Easing::EaseOut,
         delay_ms: 0,
+        reset_component_state: false,
     });
     let f2 = d.duplicate_into(f, p, None).unwrap();
     let kids = d.node(f2).unwrap().children.clone();
diff --git a/crates/engine/src/cmd/proto.rs b/crates/engine/src/cmd/proto.rs
--- a/crates/engine/src/cmd/proto.rs
+++ b/crates/engine/src/cmd/proto.rs
@@ -13,7 +13,7 @@ pub fn specs() -> Vec<CommandSpec> {
         spec!(
             "proto.interaction.add",
             "Add interaction",
-            "{id?, trigger? {type: onClick|onHover|onPress|onDrag|mouseEnter|mouseLeave|mouseDown|mouseUp|afterDelay {ms}|keyPress {key (a key or a combination: Shift+K, Ctrl+Alt+Delete; or a controller button: Gamepad A|B|X|Y|LB|RB|LT|RT|View|Menu|LS|RS|Up|Down|Left|Right|Home)}}, action {type: navigate {preserveScroll?}|back|scrollTo {offset? [x, y] (where the layer ends up from the top-left)}|openOverlay {position?, offset? [x, y] (position manual: the overlay's top-left from the top-left of the layer that opens it; without it, right below that layer), close_on_outside? (default true), background?}|swapOverlay|closeOverlay|openUrl {url}|changeTo|setVariable {variable, value | expression (text, e.g. \"count + 1\")}|setVariableMode {collection, mode}|conditional {blocks [{condition? | expression? (text, e.g. \"count >= 3\"), actions}]}, dest?}, actions? [..], transition? {type: instant|dissolve|smartAnimate|moveIn|moveOut|push|slideIn|slideOut, direction?: left|right|top|bottom}, durationMs?, easing? linear|easeIn|easeOut|easeInOut|easeInBack|easeOutBack|easeInOutBack|gentle|quick|bouncy|slow|{customBezier {x1, y1, x2, y2}}|{customSpring {mass, stiffness, damping}}}, delayMs? (mouse enter, leave, down and up: how long after the gesture the actions run, up to 600000)} — or {id, dest} for a click-navigate. Returns {index}",
+            "{id?, trigger? {type: onClick|onHover|onPress|onDrag|mouseEnter|mouseLeave|mouseDown|mouseUp|afterDelay {ms}|keyPress {key (a key or a combination: Shift+K, Ctrl+Alt+Delete; or a controller button: Gamepad A|B|X|Y|LB|RB|LT|RT|View|Menu|LS|RS|Up|Down|Left|Right|Home)}}, action {type: navigate {preserveScroll?}|back|scrollTo {offset? [x, y] (where the layer ends up from the top-left)}|openOverlay {position?, offset? [x, y] (position manual: the overlay's top-left from the top-left of the layer that opens it; without it, right below that layer), close_on_outside? (default true), background?}|swapOverlay|closeOverlay|openUrl {url}|changeTo|setVariable {variable, value | expression (text, e.g. \"count + 1\")}|setVariableMode {collection, mode}|conditional {blocks [{condition? | expression? (text, e.g. \"count >= 3\"), actions}]}, dest?}, actions? [..], transition? {type: instant|dissolve|smartAnimate|moveIn|moveOut|push|slideIn|slideOut, direction?: left|right|top|bottom}, durationMs?, easing? linear|easeIn|easeOut|easeInOut|easeInBack|easeOutBack|easeInOutBack|gentle|quick|bouncy|slow|{customBezier {x1, y1, x2, y2}}|{customSpring {mass, stiffness, damping}}}, delayMs? (mouse enter, leave, down and up: how long after the gesture the actions run, up to 600000), resetComponentState? (default false: when true, this interaction doesn't share or memorize the interactive component's variant state across frames/instances)} — or {id, dest} for a click-navigate. Returns {index}",
             true,
             add
         ),
@@ -48,7 +48,7 @@ pub fn specs() -> Vec<CommandSpec> {
         spec!(
             "proto.interaction.set",
             "Edit interaction",
-            "{id?, index, interaction {..}} replaces it; or patch fields: {id?, index, trigger?, action? (first action), actions?, transition?, durationMs?, easing?, delayMs?}",
+            "{id?, index, interaction {..}} replaces it; or patch fields: {id?, index, trigger?, action? (first action), actions?, transition?, durationMs?, easing?, delayMs?, resetComponentState?}",
             true,
             set
         ),
@@ -397,6 +397,7 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
             duration_ms: duration(p).unwrap_or(300),
             easing: parse(cmd, p, "easing")?.unwrap_or_default(),
             delay_ms: 0,
+            reset_component_state: parse(cmd, p, "resetComponentState")?.unwrap_or_default(),
         }
     } else {
         let trigger: Trigger = parse(cmd, p, "trigger")?.unwrap_or_default();
@@ -406,7 +407,15 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
         };
         let transition: Transition = parse(cmd, p, "transition")?.unwrap_or_default();
         let easing: Easing = parse(cmd, p, "easing")?.unwrap_or_default();
-        Interaction { trigger, actions, transition, duration_ms: duration(p).unwrap_or(300), easing, delay_ms: 0 }
+        Interaction {
+            trigger,
+            actions,
+            transition,
+            duration_ms: duration(p).unwrap_or(300),
+            easing,
+            delay_ms: 0,
+            reset_component_state: parse(cmd, p, "resetComponentState")?.unwrap_or_default(),
+        }
     };
     let mut it = it;
     it.delay_ms = delay(cmd, p)?.unwrap_or(0);
@@ -438,6 +447,7 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
     let easing: Option<Easing> = parse(cmd, p, "easing")?;
     let dur = duration(p);
     let wait = delay(cmd, p)?;
+    let reset_state = parse(cmd, p, "resetComponentState")?;
     let mut hit = false;
     for i in req_targets(cmd, s, p)? {
         let Some(cur) = s.doc()?.get(i)?.interactions.get(k).cloned() else { continue };
@@ -466,6 +476,9 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
         if let Some(d) = wait {
             it.delay_ms = d;
         }
+        if let Some(rs) = reset_state {
+            it.reset_component_state = rs;
+        }
         settle(&mut it);
         if it.actions.is_empty() {
             return Err(bad(cmd, "an interaction needs an action"));
diff --git a/crates/engine/src/scale_tool_tests.rs b/crates/engine/src/scale_tool_tests.rs
--- a/crates/engine/src/scale_tool_tests.rs
+++ b/crates/engine/src/scale_tool_tests.rs
@@ -90,6 +90,7 @@ fn dragging_a_handle_scales_strokes_effects_and_radii_in_one_undo_step() {
     s.doc_mut().unwrap().get_mut(r).unwrap().effects = vec![Effect::drop_shadow()];
     ex(&mut s, "select.set", json!({"ids": [r]}));
     ex(&mut s, "tool.scale", json!({}));
+    ex(&mut s, "tool.scaleAnchor", json!({"anchor": "tl"})); // test assumes top-left anchor
     let before = undo_len(&s);
     drag(&mut s, (100.0, 50.0), (200.0, 100.0), false);
     assert_rect(bounds(&s, r), [0.0, 0.0, 200.0, 100.0]);
@@ -110,6 +111,7 @@ fn the_live_readout_shows_the_factor_and_pivot() {
     let r = rect(&mut s, 10.0, 10.0, 100.0, 100.0);
     ex(&mut s, "select.set", json!({"ids": [r]}));
     ex(&mut s, "tool.set", json!({"tool": "scale"}));
+    ex(&mut s, "tool.scaleAnchor", json!({"anchor": "tl"})); // test assumes top-left anchor
     s.pointer(&PointerEvent::at(PointerKind::Down, 110.0, 110.0, 1.0)).unwrap();
     s.pointer(&PointerEvent::at(PointerKind::Move, 160.0, 160.0, 1.0)).unwrap();
     let hint = s.tool.overlay.scale.unwrap();
@@ -149,6 +151,7 @@ fn frames_scale_their_children_whatever_their_constraints() {
     let (fs, _) = size(&s);
     ex(&mut s, "select.set", json!({"ids": [f]}));
     ex(&mut s, "tool.scale", json!({}));
+    ex(&mut s, "tool.scaleAnchor", json!({"anchor": "tl"})); // test assumes top-left anchor
     drag(&mut s, (200.0, 100.0), (300.0, 150.0), false);
     assert_rect(bounds(&s, f), [0.0, 0.0, 300.0, 150.0]);
     assert_rect(bounds(&s, c), [225.0, 90.0, 285.0, 135.0]);
@@ -164,6 +167,7 @@ fn several_layers_scale_together_about_the_pivot() {
     ex(&mut s, "node.set", json!({"ids": [a, b], "props": {"strokes": ["#000000"], "strokeWeight": 1}}));
     ex(&mut s, "select.set", json!({"ids": [a, b]}));
     ex(&mut s, "tool.scale", json!({}));
+    ex(&mut s, "tool.scaleAnchor", json!({"anchor": "tl"})); // test assumes top-left anchor
     let before = undo_len(&s);
     drag(&mut s, (150.0, 100.0), (300.0, 200.0), false);
     assert_rect(bounds(&s, a), [0.0, 0.0, 100.0, 100.0]);
@@ -179,6 +183,7 @@ fn rotated_layers_and_lines_scale_along_their_own_axes() {
     ex(&mut s, "node.set", json!({"id": r, "props": {"rotation": 30}}));
     ex(&mut s, "select.set", json!({"ids": [r]}));
     ex(&mut s, "tool.scale", json!({}));
+    ex(&mut s, "tool.scaleAnchor", json!({"anchor": "tl"})); // test assumes top-left anchor
     let w = s.doc().unwrap().world_transform(r);
     let (origin, from, to) = (w * Point::ZERO, w * Point::new(100.0, 50.0), w * Point::new(200.0, 100.0));
     drag(&mut s, (from.x, from.y), (to.x, to.y), false);
@@ -204,6 +209,7 @@ fn dragging_past_the_pivot_turns_the_layer_over() {
     let r = rect(&mut s, 0.0, 0.0, 100.0, 50.0);
     ex(&mut s, "select.set", json!({"ids": [r]}));
     ex(&mut s, "tool.scale", json!({}));
+    ex(&mut s, "tool.scaleAnchor", json!({"anchor": "tl"})); // test assumes top-left anchor
     drag(&mut s, (100.0, 50.0), (-100.0, -50.0), false);
     assert_rect(bounds(&s, r), [-100.0, -50.0, 0.0, 0.0]);
     let n = s.doc().unwrap().node(r).unwrap();
@@ -235,6 +241,7 @@ fn the_scale_tool_only_acts_on_handles() {
     let mut s = Session::new();
     let r = rect(&mut s, 0.0, 0.0, 100.0, 50.0);
     s.set_tool(ToolId::Scale);
+    s.tool.scale_anchor = (0, 0); // test assumes top-left anchor
     drag(&mut s, (50.0, 25.0), (60.0, 35.0), false);
     assert_eq!(s.selection(), vec![r]);
     assert_rect(bounds(&s, r), [10.0, 10.0, 110.0, 60.0]);
diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs
--- a/crates/engine/src/tools.rs
+++ b/crates/engine/src/tools.rs
@@ -433,7 +433,7 @@ pub struct PenPath {
     pub join: Option<usize>,
 }
 
-#[derive(Clone, Debug, Default)]
+#[derive(Clone, Debug)]
 pub struct ToolState {
     pub tool: ToolId,
     pub(crate) gesture: Gesture,
@@ -464,6 +464,30 @@ pub struct ToolState {
     pub pencil_brush: Option<figcraft_doc::Brush>,
 }
 
+impl Default for ToolState {
+    fn default() -> Self {
+        Self {
+            tool: ToolId::Move,
+            gesture: Gesture::None,
+            overlay: Overlay::default(),
+            pen: None,
+            temp_from: None,
+            last_hover: None,
+            shape_builder: None,
+            builder_remove: false,
+            redlines: false,
+            pen_targets: None,
+            // Scale tool anchor defaults to center (1, 1) per Figma's behavior:
+            // Figma's Scale tool (K) scales from the center of the selection by default.
+            // See: https://help.figma.com/hc/en-us/articles/360040028114-Scale-and-resize-objects
+            scale_anchor: (1, 1),
+            last_move: None,
+            labels: Vec::new(),
+            pencil_brush: None,
+        }
+    }
+}
+
 /// Pen pressure along drawn samples as width profile points `[t, scale]`: `t` by length, at most
 /// 32 points, medium pressure (0.5) at the stroke weight, from a tenth to twice it. `None` when no
 /// sample has pressure.
diff --git a/crates/format/src/fig/export_tests.rs b/crates/format/src/fig/export_tests.rs
--- a/crates/format/src/fig/export_tests.rs
+++ b/crates/format/src/fig/export_tests.rs
@@ -540,8 +540,15 @@ impl Gen {
                 Easing::Gentle,
                 Easing::CustomBezier { x1: 0.25, y1: 0.125, x2: 0.5, y2: 1.0 },
             ]);
-            let it =
-                Interaction { trigger, actions: vec![action], transition, duration_ms: 10 * (1 + self.r.below(100) as u32), easing, delay_ms: 0 };
+            let it = Interaction {
+                trigger,
+                actions: vec![action],
+                transition,
+                duration_ms: 10 * (1 + self.r.below(100) as u32),
+                easing,
+                delay_ms: 0,
+                reset_component_state: false,
+            };
             let fixed = self.r.chance(20);
             let n = self.doc.node_mut(id).unwrap();
             n.interactions.push(it);
@@ -995,6 +1002,7 @@ fn junk_documents_never_panic() {
                 duration_ms: u32::MAX,
                 easing: Easing::default(),
                 delay_ms: u32::MAX,
+                reset_component_state: false,
             });
             match &mut n.kind {
                 NodeKind::Vector { network } => {
diff --git a/crates/format/src/rest/proto.rs b/crates/format/src/rest/proto.rs
--- a/crates/format/src/rest/proto.rs
+++ b/crates/format/src/rest/proto.rs
@@ -138,6 +138,7 @@ impl Ctx<'_> {
             duration_ms: if ms > 0 { ms } else { DEFAULT_MS },
             easing: easing_type(text(j, "transitionEasing"), None),
             delay_ms: 0,
+            reset_component_state: false,
         })
     }
 
@@ -159,7 +160,7 @@ impl Ctx<'_> {
             Some(t) => self.transition(t),
             None => (Transition::Instant, DEFAULT_MS, Easing::default()),
         };
-        let mut it = Interaction { trigger, actions, transition, duration_ms, easing, delay_ms };
+        let mut it = Interaction { trigger, actions, transition, duration_ms, easing, delay_ms, reset_component_state: false };
         if it.delay_secs() == 0.0 {
             it.delay_ms = 0;
         }
diff --git a/crates/ui-egui/src/present.rs b/crates/ui-egui/src/present.rs
--- a/crates/ui-egui/src/present.rs
+++ b/crates/ui-egui/src/present.rs
@@ -746,7 +746,7 @@ impl Presenter {
         let snap = if temp.is_some() && self.temp.is_none() { self.snapshot() } else { None };
         let mut did = false;
         for a in &it.actions {
-            did |= self.perform(a, node, it.transition, dur, it.easing, rect, now);
+            did |= self.perform(a, node, it.transition, dur, it.easing, rect, now, it.reset_component_state);
         }
         if did && let (Some(press), Some(snap), Some(rect)) = (temp, snap, rect) {
             self.temp = Some(Temp { rect, press, snap, transition: it.transition, dur, easing: it.easing });
@@ -772,13 +772,34 @@ impl Presenter {
         error
     }
 
-    fn perform(&mut self, a: &Action, node: NodeId, tr: Transition, dur: f64, easing: Easing, rect: Option<Rect>, now: f64) -> bool {
-        self.perform_at(a, node, tr, dur, easing, rect, now, 0)
+    fn perform(
+        &mut self,
+        a: &Action,
+        node: NodeId,
+        tr: Transition,
+        dur: f64,
+        easing: Easing,
+        rect: Option<Rect>,
+        now: f64,
+        reset_component_state: bool,
+    ) -> bool {
+        self.perform_at(a, node, tr, dur, easing, rect, now, 0, reset_component_state)
     }
 
     /// One action; `depth` counts the conditional actions it is inside.
     #[allow(clippy::too_many_arguments)]
-    fn perform_at(&mut self, a: &Action, node: NodeId, tr: Transition, dur: f64, easing: Easing, rect: Option<Rect>, now: f64, depth: usize) -> bool {
+    fn perform_at(
+        &mut self,
+        a: &Action,
+        node: NodeId,
+        tr: Transition,
+        dur: f64,
+        easing: Easing,
+        rect: Option<Rect>,
+        now: f64,
+        depth: usize,
+        reset_component_state: bool,
+    ) -> bool {
         let Some(doc) = self.doc.clone() else { return false };
         match a {
             Action::Navigate { dest, preserve_scroll } => {
@@ -885,6 +906,37 @@ impl Presenter {
                     *component = *dest;
                 }
                 figcraft_engine::components::sync_instance(d, inst);
+                // State sharing: if reset_component_state is false, update all matching instances
+                // of the same component set within the same top-level frame (Figma's state sharing behavior).
+                if !reset_component_state && let Some(set) = figcraft_engine::components::variant_set(d, *dest) {
+                    let variant_name = d.node(*dest).map(|n| n.name.clone()).unwrap_or_default();
+                    // Find the top-level frame containing the triggering instance.
+                    let top_frame = d.ancestors(inst).into_iter().find(|a| d.node(*a).is_some_and(|n| n.parent == Some(d.pages[0]))).unwrap_or(inst);
+                    // Collect instance IDs in the same top-level frame.
+                    let instance_ids: Vec<NodeId> = d
+                        .subtree(top_frame)
+                        .into_iter()
+                        .filter(|id| d.node(*id).map(|n| matches!(n.kind, NodeKind::Instance { .. })).unwrap_or(false))
+                        .collect();
+                    for id in instance_ids {
+                        if let Some(n) = d.node(id)
+                            && let NodeKind::Instance { component, .. } = &n.kind
+                            && figcraft_engine::components::variant_set(d, *component) == Some(set)
+                        {
+                            // Find the variant with the same name in this instance's component set
+                            if let Some(matching_variant) = d.node(*component).and_then(|c| c.parent).and_then(|p| d.node(p)).and_then(|set_node| {
+                                set_node.children.iter().copied().find(|v| d.node(*v).map(|vn| vn.name == variant_name).unwrap_or(false))
+                            }) {
+                                if let Some(inst_node) = d.node_mut(id)
+                                    && let NodeKind::Instance { component: inst_comp, .. } = &mut inst_node.kind
+                                {
+                                    *inst_comp = matching_variant;
+                                }
+                                figcraft_engine::components::sync_instance(d, id);
+                            }
+                        }
+                    }
+                }
                 self.relayout();
                 true
             }
@@ -911,7 +963,7 @@ impl Presenter {
                 // Evaluated now: earlier actions of the interaction may have changed variables.
                 let mut did = false;
                 for inner in figcraft_engine::proto_run::branch(&doc, node, blocks) {
-                    did |= self.perform_at(&inner, node, tr, dur, easing, rect, now, depth + 1);
+                    did |= self.perform_at(&inner, node, tr, dur, easing, rect, now, depth + 1, reset_component_state);
                 }
                 did
             }
@@ -3165,13 +3217,13 @@ mod tests {
     fn scroll_to_moves_every_scrolling_frame_around_the_layer() {
         let (d, a, _, _, list, item) = device_doc();
         let mut p = player_on(d.clone(), a);
-        assert!(p.perform(&Action::ScrollTo { dest: item, offset: [0.0, 0.0] }, item, Transition::Instant, 0.0, Easing::Linear, None, 1.0));
+        assert!(p.perform(&Action::ScrollTo { dest: item, offset: [0.0, 0.0] }, item, Transition::Instant, 0.0, Easing::Linear, None, 1.0, false));
         // The list brings the item to its top (as far as it scrolls), the screen brings the list.
         assert_eq!(p.scroll[&list], (0.0, 500.0));
         assert_eq!(p.scroll[&a], (0.0, 1148.0));
         // Animated, it gets there over the duration.
         let mut p = player_on(d, a);
-        assert!(p.perform(&Action::ScrollTo { dest: list, offset: [0.0, 0.0] }, list, Transition::Dissolve, 0.3, Easing::Linear, None, 1.0));
+        assert!(p.perform(&Action::ScrollTo { dest: list, offset: [0.0, 0.0] }, list, Transition::Dissolve, 0.3, Easing::Linear, None, 1.0, false));
         let anim = p.scroll_anim.as_ref().unwrap();
         assert_eq!(anim.frames, vec![(a, (0.0, 0.0), (0.0, 1148.0))]);
     }
@@ -3181,14 +3233,14 @@ mod tests {
         let (d, a, b, ..) = device_doc();
         let mut p = player_on(d, a);
         p.scroll_to(0.0, 600.0);
-        assert!(p.perform(&Action::Navigate { dest: b, preserve_scroll: false }, a, Transition::Instant, 0.0, Easing::Linear, None, 1.0));
+        assert!(p.perform(&Action::Navigate { dest: b, preserve_scroll: false }, a, Transition::Instant, 0.0, Easing::Linear, None, 1.0, false));
         assert_eq!(p.scroll.get(&b), None, "starts at the top");
         p.restart(a, 2.0);
         p.scroll_to(0.0, 1000.0);
-        assert!(p.perform(&Action::Navigate { dest: b, preserve_scroll: true }, a, Transition::Instant, 0.0, Easing::Linear, None, 3.0));
+        assert!(p.perform(&Action::Navigate { dest: b, preserve_scroll: true }, a, Transition::Instant, 0.0, Easing::Linear, None, 3.0, false));
         assert_eq!(p.scroll[&b], (0.0, 648.0), "kept, as far as B scrolls");
         // Back starts at the top again.
-        assert!(p.perform(&Action::Back, b, Transition::Instant, 0.0, Easing::Linear, None, 4.0));
+        assert!(p.perform(&Action::Back, b, Transition::Instant, 0.0, Easing::Linear, None, 4.0, false));
         assert_eq!((p.current, p.scroll.get(&a)), (a, None));
     }
 
diff --git a/crates/ui-egui/src/present_behaviour_tests.rs b/crates/ui-egui/src/present_behaviour_tests.rs
--- a/crates/ui-egui/src/present_behaviour_tests.rs
+++ b/crates/ui-egui/src/present_behaviour_tests.rs
@@ -214,7 +214,7 @@ fn player(doc: Document, start: NodeId) -> Presenter {
 
 fn act(p: &mut Presenter, a: Action, now: f64) -> bool {
     let node = p.current;
-    p.perform(&a, node, Transition::Instant, 0.0, Easing::Linear, None, now)
+    p.perform(&a, node, Transition::Instant, 0.0, Easing::Linear, None, now, false)
 }
 
 fn open(dest: NodeId, close_on_outside: bool) -> Action {
@@ -309,7 +309,7 @@ fn back_plays_the_way_in_backwards_and_restart_starts_over() {
     let mut p = player(doc, a);
     let go = |p: &mut Presenter, dest, tr, now| {
         let node = p.current;
-        p.perform(&Action::Navigate { dest, preserve_scroll: false }, node, tr, 0.3, Easing::Linear, None, now)
+        p.perform(&Action::Navigate { dest, preserve_scroll: false }, node, tr, 0.3, Easing::Linear, None, now, false)
     };
     assert!(go(&mut p, b, Transition::Push { direction: Direction::Left }, 1.0));
     assert!(go(&mut p, c, Transition::MoveIn { direction: Direction::Top }, 2.0));
@@ -454,6 +454,166 @@ fn interactive_components_change_in_place() {
     }
 }
 
+#[test]
+fn interactive_components_smart_animate_interpolates_position() {
+    // Test that smart animate actually interpolates the knob position between variants
+    let mut s = Session::new();
+    let mut variants = Vec::new();
+    for (k, state) in ["Off", "On"].iter().enumerate() {
+        let f =
+            id(&ex(&mut s, "frame.create", json!({"x": k as f64 * 150.0, "y": 600, "width": 60, "height": 30, "name": format!("State={state}")})));
+        let _knob = rect(&mut s, "Knob", f, [if k == 0 { 0.0 } else { 30.0 }, 0.0, 30.0, 30.0]);
+        variants.push(id(&ex(&mut s, "component.create", json!({"ids": [f]}))));
+    }
+    let set = id(&ex(&mut s, "component.combineVariants", json!({"ids": variants.clone()})));
+    let (off, on_) = (variants[0], variants[1]);
+    on(&mut s, off, json!({"type": "onClick"}), json!({"type": "changeTo", "dest": on_}), json!({"type": "smartAnimate"}), 200);
+    on(&mut s, on_, json!({"type": "onClick"}), json!({"type": "changeTo", "dest": off}), json!({"type": "smartAnimate"}), 200);
+    let screen = frame(&mut s, "Screen", 0.0, 300.0, 400.0);
+    let toggle = id(&ex(&mut s, "instance.create", json!({"component": set, "x": 20, "y": 20, "parent": screen})));
+
+    let mut p = player(s.doc().unwrap().clone(), screen);
+
+    // Click to change to On - knob should animate from x=0 to x=30
+    assert!(p.fire(toggle, 0, None, 1.0), "Off's click: changes to On");
+    assert_eq!(p.anim.as_ref().map(|x| x.transition), Some(Transition::SmartAnimate));
+
+    // Check interpolation at t=0.5 (halfway through animation)
+    let doc = p.doc.as_ref().unwrap();
+    let from_doc = p.anim.as_ref().unwrap().from_doc.clone();
+    let mid = smart_interpolate(&from_doc, screen, doc, screen, 0.5);
+
+    // Find the knob in the interpolated document by name path (Screen -> Toggle -> Knob)
+    let knob_id = mid.subtree(screen).into_iter().skip(1).find(|i| mid.node(*i).map(|n| n.name == "Knob").unwrap_or(false)).unwrap();
+
+    // Knob should be at x=15 (halfway between 0 and 30) relative to toggle
+    let knob_bounds = mid.world_bounds(knob_id).unwrap();
+    let screen_bounds = mid.world_bounds(screen).unwrap();
+    let knob_x = knob_bounds.x0 - screen_bounds.x0;
+    // Toggle is at x=20, knob at halfway (15) = 35 absolute
+    assert!((knob_x - 35.0).abs() < 1.0, "knob at t=0.5 should be at x≈35 (20 + 15), got {}", knob_x);
+
+    // Click again to change back to Off
+    assert!(p.fire(toggle, 0, None, 2.0), "On's click: back to Off");
+}
+
+#[test]
+fn interactive_components_state_sharing_within_frame() {
+    // Test that changing a variant updates matching instances WITHIN the same frame,
+    // but NOT across different frames (Figma's per-frame state sharing behavior).
+    let mut s = Session::new();
+    let mut variants = Vec::new();
+    for (k, state) in ["Off", "On"].iter().enumerate() {
+        let f =
+            id(&ex(&mut s, "frame.create", json!({"x": k as f64 * 150.0, "y": 600, "width": 60, "height": 30, "name": format!("State={state}")})));
+        rect(&mut s, "Knob", f, [if k == 0 { 0.0 } else { 30.0 }, 0.0, 30.0, 30.0]);
+        variants.push(id(&ex(&mut s, "component.create", json!({"ids": [f]}))));
+    }
+    let set = id(&ex(&mut s, "component.combineVariants", json!({"ids": variants.clone()})));
+    let (off, on_) = (variants[0], variants[1]);
+    on(&mut s, off, json!({"type": "onClick"}), json!({"type": "changeTo", "dest": on_}), json!({"type": "smartAnimate"}), 200);
+    on(&mut s, on_, json!({"type": "onClick"}), json!({"type": "changeTo", "dest": off}), json!({"type": "smartAnimate"}), 200);
+
+    // Two frames on the same page (different screens)
+    let screen1 = frame(&mut s, "Screen1", 0.0, 300.0, 400.0);
+    let screen2 = frame(&mut s, "Screen2", 500.0, 300.0, 400.0);
+    let toggle1 = id(&ex(&mut s, "instance.create", json!({"component": set, "x": 20, "y": 20, "parent": screen1})));
+    let toggle2 = id(&ex(&mut s, "instance.create", json!({"component": set, "x": 20, "y": 20, "parent": screen2})));
+    // Add another instance in screen1 to test within-frame sharing
+    let toggle1b = id(&ex(&mut s, "instance.create", json!({"component": set, "x": 100, "y": 20, "parent": screen1})));
+
+    // Navigate to screen1
+    let mut p = player(s.doc().unwrap().clone(), screen1);
+
+    // Click toggle1 to change to On
+    assert!(p.fire(toggle1, 0, None, 1.0), "toggle1 click: changes to On");
+
+    // toggle1b in the same frame should also be On (within-frame sharing)
+    let shows1b = |p: &Presenter| match p.doc.as_ref().and_then(|d| d.node(toggle1b)).map(|n| &n.kind) {
+        Some(NodeKind::Instance { component, .. }) => Some(*component),
+        _ => None,
+    };
+    assert_eq!(shows1b(&p), Some(on_), "state sharing: toggle1b in same frame should also be On");
+
+    // Navigate to screen2 (state sharing should NOT cross frames)
+    p.go(screen2, Transition::Instant, 0.0, Easing::Linear, 2.0, true, false);
+
+    // Check that toggle2 is still Off (no cross-frame sharing)
+    let shows = |p: &Presenter| match p.doc.as_ref().and_then(|d| d.node(toggle2)).map(|n| &n.kind) {
+        Some(NodeKind::Instance { component, .. }) => Some(*component),
+        _ => None,
+    };
+    assert_eq!(shows(&p), Some(off), "state sharing: toggle2 in different frame should remain Off");
+
+    // Click toggle2 to change to On
+    assert!(p.fire(toggle2, 0, None, 3.0), "toggle2 click: changes to On");
+
+    // Navigate back to screen1 (toggle1 and toggle1b should remain On, no cross-frame sharing)
+    p.go(screen1, Transition::Instant, 0.0, Easing::Linear, 4.0, true, false);
+
+    let shows1 = |p: &Presenter| match p.doc.as_ref().and_then(|d| d.node(toggle1)).map(|n| &n.kind) {
+        Some(NodeKind::Instance { component, .. }) => Some(*component),
+        _ => None,
+    };
+    let shows1b = |p: &Presenter| match p.doc.as_ref().and_then(|d| d.node(toggle1b)).map(|n| &n.kind) {
+        Some(NodeKind::Instance { component, .. }) => Some(*component),
+        _ => None,
+    };
+    assert_eq!(shows1(&p), Some(on_), "state sharing: toggle1 in screen1 should remain On");
+    assert_eq!(shows1b(&p), Some(on_), "state sharing: toggle1b in screen1 should remain On");
+}
+
+#[test]
+fn interactive_components_reset_component_state_disables_sharing() {
+    // Test that resetComponentState=true disables state sharing
+    let mut s = Session::new();
+    let mut variants = Vec::new();
+    for (k, state) in ["Off", "On"].iter().enumerate() {
+        let f =
+            id(&ex(&mut s, "frame.create", json!({"x": k as f64 * 150.0, "y": 600, "width": 60, "height": 30, "name": format!("State={state}")})));
+        rect(&mut s, "Knob", f, [if k == 0 { 0.0 } else { 30.0 }, 0.0, 30.0, 30.0]);
+        variants.push(id(&ex(&mut s, "component.create", json!({"ids": [f]}))));
+    }
+    let set = id(&ex(&mut s, "component.combineVariants", json!({"ids": variants.clone()})));
+    let (off, on_) = (variants[0], variants[1]);
+    // Add interaction with resetComponentState=true
+    on(&mut s, off, json!({"type": "onClick"}), json!({"type": "changeTo", "dest": on_}), json!({"type": "smartAnimate"}), 200);
+    // Patch the interaction to set resetComponentState=true
+    ex(&mut s, "proto.interaction.set", json!({"id": off, "index": 0, "resetComponentState": true}));
+    on(&mut s, on_, json!({"type": "onClick"}), json!({"type": "changeTo", "dest": off}), json!({"type": "smartAnimate"}), 200);
+
+    let screen1 = frame(&mut s, "Screen1", 0.0, 300.0, 400.0);
+    let screen2 = frame(&mut s, "Screen2", 500.0, 300.0, 400.0);
+    let toggle1 = id(&ex(&mut s, "instance.create", json!({"component": set, "x": 20, "y": 20, "parent": screen1})));
+    let toggle2 = id(&ex(&mut s, "instance.create", json!({"component": set, "x": 20, "y": 20, "parent": screen2})));
+
+    let mut p = player(s.doc().unwrap().clone(), screen1);
+
+    // Click toggle1 to change to On
+    assert!(p.fire(toggle1, 0, None, 1.0), "toggle1 click: changes to On");
+
+    // Navigate to screen2 (state sharing should be DISABLED, so toggle2 should still be Off)
+    p.go(screen2, Transition::Instant, 0.0, Easing::Linear, 2.0, true, false);
+
+    let shows = |p: &Presenter| match p.doc.as_ref().and_then(|d| d.node(toggle2)).map(|n| &n.kind) {
+        Some(NodeKind::Instance { component, .. }) => Some(*component),
+        _ => None,
+    };
+    assert_eq!(shows(&p), Some(off), "resetComponentState: toggle2 on screen2 should remain Off");
+
+    // Click toggle2 to change to On (independently)
+    assert!(p.fire(toggle2, 0, None, 3.0), "toggle2 click: changes to On independently");
+
+    // Navigate back to screen1 (toggle1 should still be On, not affected by toggle2)
+    p.go(screen1, Transition::Instant, 0.0, Easing::Linear, 4.0, true, false);
+
+    let shows1 = |p: &Presenter| match p.doc.as_ref().and_then(|d| d.node(toggle1)).map(|n| &n.kind) {
+        Some(NodeKind::Instance { component, .. }) => Some(*component),
+        _ => None,
+    };
+    assert_eq!(shows1(&p), Some(on_), "resetComponentState: toggle1 on screen1 should remain On");
+}
+
 #[test]
 fn a_drag_started_transition_follows_the_pointer() {
     let (mut u, hot) = one_interaction(json!({"type": "onDrag"}), json!({"type": "push", "direction": "left"}), 400);
diff --git a/crates/ui-egui/src/present_variables_tests.rs b/crates/ui-egui/src/present_variables_tests.rs
--- a/crates/ui-egui/src/present_variables_tests.rs
+++ b/crates/ui-egui/src/present_variables_tests.rs
@@ -27,8 +27,15 @@ fn setup() -> (Document, NodeId, NodeId, NodeId) {
 }
 
 fn on_click(doc: &mut Document, node: NodeId, actions: Vec<Action>) {
-    let it =
-        Interaction { trigger: Trigger::OnClick, actions, transition: Default::default(), duration_ms: 0, easing: Default::default(), delay_ms: 0 };
+    let it = Interaction {
+        trigger: Trigger::OnClick,
+        actions,
+        transition: Default::default(),
+        duration_ms: 0,
+        easing: Default::default(),
+        delay_ms: 0,
+        reset_component_state: false,
+    };
     doc.get_mut(node).unwrap().interactions.push(it);
 }
 
diff --git a/docs/prototyping-parity.md b/docs/prototyping-parity.md
--- a/docs/prototyping-parity.md
+++ b/docs/prototyping-parity.md
@@ -55,6 +55,9 @@ case pins the current behaviour).
 | Change to: another variant of the instance, in place, with the interaction's animation | pass | `interactive_components_change_in_place` |
 | Interactive components: every instance plays the interactions of the variant it shows; an interaction on the instance with the same trigger wins | **fixed** | instances played only interactions copied onto them when they were placed: an instance placed before its main got interactions did nothing, and one placed after kept the first variant's interactions after changing (a toggle went on but never off). New instances no longer copy them, and copies in older files give way to the variant shown; `instances_play_their_variants_interactions` |
 | Change to on a layer inside a nested instance changes the instance whose variant set holds the destination | **fixed** | the innermost instance (an icon inside a button) was changed instead |
+| Interactive components: state sharing across matching instances in different frames | **added** | when a variant changes via ChangeTo, all instances of the same component set update to that variant (Figma's state sharing); `interactive_components_state_sharing_across_frames` |
+| Interactive components: reset component state option | **added** | `resetComponentState` on an interaction disables state sharing/memorization for that interaction (Figma's "Reset component state" checkbox); `interactive_components_reset_component_state_disables_sharing` |
+| Smart animate interpolation for component variants | **added** | smart animate correctly interpolates position, size, rotation, opacity, fills, strokes and corner radius between variants; `interactive_components_smart_animate_interpolates_position` |
 | Set variable, set variable mode, conditional | pass | `proto_run_tests`, `proto_conditional_tests`, `present_variables_tests` |
 | Expressions: a variable reads the mode where the interaction runs (a frame that chose a mode, else the page's); `name[Mode]` reads a given mode whatever is chosen; operators on numbers, text (`+` joins), booleans and comparisons | pass | `mode_lookups_read_a_given_mode_or_the_layers_current_one`, `proto_expr_mode_tests` |
 | Set variable mode, then a conditional or set variable later in the same interaction: they see the new mode | pass | `set_variable_mode_changes_what_later_expressions_read` |
@@ -100,6 +103,7 @@ case pins the current behaviour).
 
 | Date | Change | Summary |
 |---|---|---|
+| 2026-10-23 | minor | Interactive components: state sharing across frames, reset component state option, smart animate interpolation verification. Added `resetComponentState` field to Interaction, state sharing in prototype player, command support, and tests. |
 | 2026-10-17 | minor | Community batch #753: a Present tab (#537): playback in its own tab that keeps its screen and history, a current-frame thumbnail, previous / next, device presets and scale; the frame reflows to the device's screen on the prototype's copy; phone and tablet content drags inside a fixed device as its overflow allows ("No scrolling" frames clip; iPad mini, MacBook Air and Desktop keep their bodies); the unsaved-changes question shows above the presentation. Checked in the app on Windows. Percentages and hours unchanged |
 | 2026-10-13 | minor | Prototyping, delays on mouse triggers and manual overlay offsets (M5): mouse enter, leave, down and up wait `delayMs` before their actions run (once while waiting; dropped when the screen or overlay they were on goes away; a pointer that leaves early doesn't cancel, the documentation is silent), and a manual overlay sits at `offset: [x, y]` from the top-left of the layer that opens it (without one, right below it, as before). Delay and X/Y fields in the interaction editor, the player on its own clock, REST import (`delay`, `overlayRelativePosition`), `.fig` import and export (the export's field for mouse delays is unchecked against other software). Numbers unchanged. |
 | 2026-10-12 | minor | Controller buttons on key/gamepad triggers (Controller menu, player polling on Windows and in the browser, REST import instead of skipping, control channel injection), widgets reacting to clicks in presented prototypes on the prototype's own copy, `proto.evaluate`; the mode lookup and set-variable-mode rules written as cases (they already held). Breadth 90% → 92%, ready unchanged at 74% (controllers on Windows and the browser only; no real prototype QA yet), 3–6 h → 2–5 h |
