diff --git a/ROADMAP.md b/ROADMAP.md
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -117,6 +117,11 @@ Come tell us what matters most to you on [Discord](https://discord.gg/artcraft).
 
 ## Progress log
 
+- 2026-10-24: Shape layers have the Wiggle Transform path operation (Contents ▸ Add, or
+  `layer.addShapeItem {kind: "wiggler"}`): it wiggles the anchor point, position, scale and
+  rotation of the paths above it by random amounts that change over time, up to the Transform
+  values it holds. The copies of a Repeater above it wiggle independently as far as its
+  Correlation allows; with the Repeater below it, they wiggle together.
 - 2026-10-16: Convert To Bezier Path turns a shape layer's Rectangle, Ellipse or Polystar
   path into an editable Path 1 with the same geometry, as After Effects does; it used to
   apply to mask RotoBezier only, so a parametric shape path could not be converted at all
diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs
--- a/crates/engine/src/cmd/layer.rs
+++ b/crates/engine/src/cmd/layer.rs
@@ -183,7 +183,7 @@ pub fn specs() -> Vec<CommandSpec> {
             "Add (Shape)",
             [],
             None,
-            "{layer?, kind: group|rect|ellipse|star|polygon|path|fill|stroke|gfill|gstroke|trim|repeater|round|offset|pucker|twist|zigzag|wiggle|merge|frontColor|bevelColor|sideColor|backColor, group?: uid|path} → {uid, path} (frontColor…backColor: the group's Front / Bevel / Side / Back item, which colours that surface of every extruded paint in the group in Advanced 3D)",
+            "{layer?, kind: group|rect|ellipse|star|polygon|path|fill|stroke|gfill|gstroke|trim|repeater|round|offset|pucker|twist|zigzag|wiggle|wiggler|merge|frontColor|bevelColor|sideColor|backColor, group?: uid|path} → {uid, path} (frontColor…backColor: the group's Front / Bevel / Side / Back item, which colours that surface of every extruded paint in the group in Advanced 3D)",
             has_layers,
             add_shape_item
         ),
diff --git a/crates/engine/src/cmd/paths.rs b/crates/engine/src/cmd/paths.rs
--- a/crates/engine/src/cmd/paths.rs
+++ b/crates/engine/src/cmd/paths.rs
@@ -360,7 +360,7 @@ fn free_transform(s: &mut Session, p: &Value) -> Result<Value> {
 
 const SHAPE_ITEMS: &[&str] = &[
     "group", "rect", "ellipse", "star", "path", "fill", "stroke", "gfill", "gstroke", "trim", "repeater", "round", "offset", "pucker", "twist", "zigzag",
-    "wiggle", "merge", "front", "bevel", "side", "back",
+    "wiggle", "wiggler", "merge", "front", "bevel", "side", "back",
 ];
 
 /// Selected shape items (contents entries) of shape layers: (layer, uid).
diff --git a/crates/project/src/build.rs b/crates/project/src/build.rs
--- a/crates/project/src/build.rs
+++ b/crates/project/src/build.rs
@@ -595,6 +595,22 @@ pub fn shape_simple_op(ids: &mut Ids, kind: &str) -> Option<PropGroup> {
             .with(ids.prop("correlation", "Correlation", Value::Scalar(50.0)).with_ui(ParamUi::Percent))
             .with(ids.prop("phase", "Temporal Phase", Value::Scalar(0.0)).with_ui(ParamUi::Angle))
             .with(ids.prop("seed", "Random Seed", Value::Scalar(0.0))),
+        "wiggler" | "wiggleTransform" => {
+            // The Transform group holds how far each transformation wiggles.
+            let tr = ids
+                .group("transform", "Transform")
+                .with(ids.prop("anchor", "Anchor Point", Value::Vec2([0.0, 0.0])).with_ui(ParamUi::Point))
+                .with(ids.prop("position", "Position", Value::Vec2([0.0, 0.0])).with_ui(ParamUi::Point))
+                .with(ids.prop("scale", "Scale", Value::Vec2([0.0, 0.0])).with_ui(ParamUi::Percent))
+                .with(ids.prop("rotation", "Rotation", Value::Scalar(0.0)).with_ui(ParamUi::Angle));
+            indexed(ids.group("wiggler", "Wiggle Transform 1"))
+                .with(ids.prop("speed", "Wiggles/Second", Value::Scalar(2.0)))
+                .with(ids.prop("correlation", "Correlation", Value::Scalar(50.0)).with_ui(ParamUi::Percent))
+                .with(ids.prop("phase", "Temporal Phase", Value::Scalar(0.0)).with_ui(ParamUi::Angle))
+                .with(ids.prop("spatialPhase", "Spatial Phase", Value::Scalar(0.0)).with_ui(ParamUi::Angle))
+                .with(ids.prop("seed", "Random Seed", Value::Scalar(0.0)))
+                .with(tr)
+        }
         "merge" => indexed(ids.group("merge", "Merge Paths 1")).with(ids.prop("mode", "Mode", Value::Enum(0)).with_ui(popup(&[
             "Merge",
             "Add",
diff --git a/crates/render/src/cache.rs b/crates/render/src/cache.rs
--- a/crates/render/src/cache.rs
+++ b/crates/render/src/cache.rs
@@ -458,7 +458,7 @@ fn time_dependent(layer: &Layer, effects: bool) -> bool {
     if matches!(layer.source, LayerSource::Shape)
         && let Some(c) = layer.props.sub("contents")
     {
-        return group_has(c, "wiggle");
+        return group_has(c, "wiggle") || group_has(c, "wiggler");
     }
     // Wiggly selectors move with time; Expression selectors evaluate per character (their
     // property value alone doesn't capture the result).
diff --git a/crates/render/src/shapes.rs b/crates/render/src/shapes.rs
--- a/crates/render/src/shapes.rs
+++ b/crates/render/src/shapes.rs
@@ -2,8 +2,8 @@
 //! with the After Effects stacking semantics:
 //!
 //! - path operators (Trim Paths, Round Corners, Offset Paths, Pucker & Bloat, Twist, Zig Zag,
-//!   Wiggle Paths, Merge Paths, Repeater) act on all paths above them in the same group, including
-//!   the paths of nested groups;
+//!   Wiggle Paths, Wiggle Transform, Merge Paths, Repeater) act on all paths above them in the
+//!   same group, including the paths of nested groups;
 //! - paint items (fills, strokes, gradient fills/strokes) paint the paths above them, *as modified
 //!   by every operator in the stack* — a Trim Paths below a Stroke still trims it;
 //! - items higher in the list draw on top;
@@ -17,7 +17,9 @@ use effectcraft_keyframe::{Gradient, Value};
 use effectcraft_path::{BezPath, Cap, FillRule, Join, StrokeStyle, ops, varstroke};
 use effectcraft_project::{Layer, Node, PropGroup};
 use effectcraft_raster::{Image, Mask};
+use effectcraft_text::selectors::{self, Wiggly};
 use rayon::prelude::*;
+use std::collections::{HashMap, HashSet};
 
 use crate::eval::EvalCtx;
 
@@ -185,9 +187,84 @@ fn used_slots(live: &[usize], draws: &[Draw]) -> Vec<usize> {
     v
 }
 
+/// Wiggle Transform: moves the live slots by a random offset of the Transform group's Anchor
+/// Point, Position, Scale and Rotation (the most each one wiggles), changing over time. Its
+/// Wiggles/Second, Correlation, Temporal Phase, Spatial Phase and Random Seed work as the Wiggly
+/// Selector's do, with each Repeater copy (`copy_of`) as one unit: the copies of a Repeater above
+/// the operation move on their own as far as Correlation allows, and the copies of a Repeater
+/// below it move as one. The four transformations of one operation move together.
+#[allow(clippy::too_many_arguments)]
+fn wiggle_transform(
+    ctx: &EvalCtx,
+    layer: &Layer,
+    g: &PropGroup,
+    tr: &PropGroup,
+    arena: &mut [BezPath],
+    live: &[usize],
+    copy_of: &HashMap<usize, usize>,
+    draws: &mut [Draw],
+) {
+    let w = Wiggly {
+        max: 1.0,
+        min: -1.0,
+        wiggles_per_second: ctx.f(layer, g, "speed", 2.0),
+        correlation: ctx.f(layer, g, "correlation", 50.0) / 100.0,
+        temporal_phase: ctx.f(layer, g, "phase", 0.0),
+        spatial_phase: ctx.f(layer, g, "spatialPhase", 0.0),
+        lock_dimensions: false,
+        seed: ctx.f(layer, g, "seed", 0.0).round() as i64 as u64,
+    };
+    let anchor = ctx.v2(layer, tr, "anchor", [0.0; 2]);
+    let pos = ctx.v2(layer, tr, "position", [0.0; 2]);
+    let scale = ctx.v2(layer, tr, "scale", [0.0; 2]);
+    let rot = ctx.f(layer, tr, "rotation", 0.0);
+    let t = layer.layer_time(ctx.time).seconds();
+    let matrix = |k: usize| {
+        let (x, y) = (selectors::wiggly_value(&w, t, k, 0), selectors::wiggly_value(&w, t, k, 1));
+        Mat3::translate(vec2(pos[0] * x, pos[1] * y))
+            * Mat3::rotate_deg(rot * x)
+            * Mat3::scale(vec2(1.0 + scale[0] * x / 100.0, 1.0 + scale[1] * y / 100.0))
+            * Mat3::translate(vec2(-anchor[0] * x, -anchor[1] * y))
+    };
+    let mut by_copy: HashMap<usize, Mat3> = HashMap::new();
+    let mut done: HashSet<usize> = HashSet::new();
+    for &s in live {
+        if !done.insert(s) {
+            continue;
+        }
+        let k = copy_of.get(&s).copied().unwrap_or(0);
+        let m = *by_copy.entry(k).or_insert_with(|| matrix(k));
+        arena[s] = mat_path(&arena[s], &m);
+    }
+    // A paint above the operation whose paths all moved by one matrix moves its gradient and
+    // stroke scale with them, as a group transform does. A paint over copies that moved by
+    // different matrices stays one paint (its fill rule and opacity apply to all of them at once)
+    // and keeps its gradient and stroke scale.
+    for d in draws {
+        let mut ms = d.slots.iter().map(|s| done.contains(s).then(|| by_copy.get(&copy_of.get(s).copied().unwrap_or(0)).copied()).flatten());
+        let Some(Some(m)) = ms.next() else { continue };
+        if ms.all(|x| x == Some(m)) {
+            d.xf = m * d.xf;
+        }
+    }
+}
+
 /// Collect the draws of a contents group and the path slots live at its end (in group space).
 /// `arena` holds every path slot; operators rewrite slots in place.
 fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec<BezPath>) -> (Vec<Draw>, Vec<usize>) {
+    collect_in(ctx, layer, contents, arena, &mut Copies::default())
+}
+
+/// The Repeater copy each path slot belongs to, for Wiggle Transform: `of` maps a slot a Repeater
+/// made to its copy's id (slots no Repeater made are copy 0). Ids are unique across the layer's
+/// groups, so copies made in a nested group stay apart in the groups around it.
+#[derive(Default)]
+struct Copies {
+    of: HashMap<usize, usize>,
+    next: usize,
+}
+
+fn collect_in(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec<BezPath>, copy_ids: &mut Copies) -> (Vec<Draw>, Vec<usize>) {
     let mut draws: Vec<Draw> = Vec::new();
     let mut live: Vec<usize> = Vec::new();
     let opacity = |g: &PropGroup| (ctx.f(layer, g, "opacity", 100.0) / 100.0) as f32;
@@ -329,6 +406,10 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec<B
                 let t = layer.layer_time(ctx.time).seconds();
                 apply(arena, &live, |p| ops::wiggle(p, &w, t));
             }
+            "wiggler" => {
+                let Some(tr) = g.sub("transform") else { continue };
+                wiggle_transform(ctx, layer, g, tr, arena, &live, &copy_ids.of, &mut draws);
+            }
             "merge" => {
                 let mode = ops::MergeMode::from_index(ctx.e(layer, g, "mode"));
                 let input: Vec<BezPath> = live.iter().map(|&i| arena[i].clone()).collect();
@@ -341,7 +422,7 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec<B
             }
             "group" => {
                 let Some(inner) = g.sub("contents") else { continue };
-                let (cd, cl) = collect(ctx, layer, inner, arena);
+                let (cd, cl) = collect_in(ctx, layer, inner, arena, copy_ids);
                 let (m, op) = g.sub("transform").map(|t| group_matrix(ctx, layer, t)).unwrap_or((Mat3::IDENTITY, 1.0));
                 for i in used_slots(&cl, &cd) {
                     arena[i] = mat_path(&arena[i], &m);
@@ -378,12 +459,20 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec<B
                 // Each copy gets its own transformed slots, so operators below the repeater act on
                 // the repeated geometry (copy order: original first).
                 let mut copy_maps: Vec<std::collections::HashMap<usize, usize>> = Vec::with_capacity(n);
+                // Each copy of each earlier copy (a second Repeater) gets an id of its own.
+                let mut ids: HashMap<(usize, usize), usize> = HashMap::new();
                 for i in 0..n {
                     let m = step(i as f64 + offset);
                     let mut map = std::collections::HashMap::new();
                     for &s in &base_slots {
                         arena.push(mat_path(&arena[s], &m));
                         map.insert(s, arena.len() - 1);
+                        let base = copy_ids.of.get(&s).copied().unwrap_or(0);
+                        let id = *ids.entry((i, base)).or_insert_with(|| {
+                            copy_ids.next += 1;
+                            copy_ids.next
+                        });
+                        copy_ids.of.insert(arena.len() - 1, id);
                     }
                     live.extend(base_live.iter().map(|s| map[s]));
                     copy_maps.push(map);
diff --git a/crates/render/src/shapes/tests.rs b/crates/render/src/shapes/tests.rs
--- a/crates/render/src/shapes/tests.rs
+++ b/crates/render/src/shapes/tests.rs
@@ -270,6 +270,202 @@ fn wiggle_paths_vary_over_time_deterministically() {
     assert_ne!(a.data, c.data);
 }
 
+/// Alpha-weighted centroid of the pixels in columns `x0..x1`.
+fn centroid(img: &Image, x0: i64, x1: i64) -> [f32; 2] {
+    let (mut sx, mut sy, mut sa) = (0.0, 0.0, 0.0);
+    for x in x0..x1 {
+        for y in 0..img.height as i64 {
+            let a = alpha(img, x, y);
+            sx += a * x as f32;
+            sy += a * y as f32;
+            sa += a;
+        }
+    }
+    [sx / sa, sy / sa]
+}
+
+fn wiggle_transform(ids: &mut Ids, params: &[(&str, Value)]) -> PropGroup {
+    op(ids, "wiggler", params)
+}
+
+#[test]
+fn wiggle_transform_moves_paths_over_time() {
+    let run = |position: [f64; 2], t: f64| {
+        let (p, cid) = shape_comp(|ids| {
+            let w = wiggle_transform(ids, &[("transform/position", Value::Vec2(position))]);
+            vec![build::shape_rect(ids, [20.0, 20.0], [0.0, 0.0], 0.0), w, build::shape_fill(ids, RED)]
+        });
+        render_frame(&p, cid, Tick::from_seconds_f64(t), 1.0)
+    };
+    // The defaults wiggle nothing: the square stays centered on pixel 99.5, 49.5.
+    let rest = [99.5, 49.5];
+    assert_eq!(centroid(&run([0.0, 0.0], 0.37), 0, 200), rest);
+    let a = run([15.0, 15.0], 0.37);
+    assert_eq!(a.data, run([15.0, 15.0], 0.37).data, "the same time gives the same frame");
+    let b = run([15.0, 15.0], 1.21);
+    let (ca, cb) = (centroid(&a, 0, 200), centroid(&b, 0, 200));
+    assert!(ca != rest && ca != cb, "{ca:?} {cb:?}");
+    for c in [ca, cb] {
+        assert!((c[0] - rest[0]).abs() <= 15.01 && (c[1] - rest[1]).abs() <= 15.01, "{c:?}");
+    }
+    // A translation keeps the square's area.
+    assert!((coverage(&a) - 400.0).abs() < 1.0, "{}", coverage(&a));
+    // The layer cache keys a layer with a Wiggle Transform by time.
+    let (p, cid) = shape_comp(|ids| vec![build::shape_rect(ids, [20.0, 20.0], [0.0, 0.0], 0.0), wiggle_transform(ids, &[]), build::shape_fill(ids, RED)]);
+    let comp = p.comp(cid).unwrap();
+    let key = |t: f64| crate::cache::layer_key(&crate::eval::EvalCtx::new(&p, cid, comp, Tick::from_seconds_f64(t)), &comp.layers[0], 1.0, false, false);
+    assert_ne!(key(0.37), key(1.21));
+}
+
+#[test]
+fn wiggle_transform_scales_and_rotates() {
+    let run = |params: &[(&str, Value)]| {
+        let (p, cid) = shape_comp(|ids| {
+            let w = wiggle_transform(ids, params);
+            vec![build::shape_rect(ids, [40.0, 40.0], [0.0, 0.0], 0.0), w, build::shape_fill(ids, RED)]
+        });
+        render_frame(&p, cid, Tick::from_seconds_f64(0.37), 1.0)
+    };
+    let scaled = coverage(&run(&[("transform/scale", Value::Vec2([50.0, 50.0]))]));
+    assert!((scaled - 1600.0).abs() > 20.0, "{scaled}");
+    let rotated = run(&[("transform/rotation", Value::Scalar(45.0))]);
+    assert!((coverage(&rotated) - 1600.0).abs() < 2.0, "{}", coverage(&rotated));
+    assert!(alpha(&rotated, 81, 31) < 0.5 || alpha(&rotated, 119, 31) < 0.5, "a corner turned away");
+}
+
+/// A gradient above a Wiggle Transform moves with the paths it paints, as under a group transform.
+#[test]
+fn wiggle_transform_moves_the_gradient_with_the_path() {
+    let (p, cid) = shape_comp(|ids| {
+        let g = Gradient { colors: vec![(0.0, [0.0, 0.0, 0.0, 1.0]), (1.0, [1.0, 1.0, 1.0, 1.0])], opacities: vec![(0.0, 1.0), (1.0, 1.0)] };
+        vec![
+            build::shape_rect(ids, [40.0, 40.0], [0.0, 0.0], 0.0),
+            build::shape_gradient_fill(ids, false, [-20.0, 0.0], [20.0, 0.0], g),
+            wiggle_transform(ids, &[("transform/position", Value::Vec2([60.0, 0.0]))]),
+        ]
+    });
+    let img = render_frame(&p, cid, Tick::from_seconds_f64(0.37), 1.0);
+    let cols: Vec<i64> = (0..200).filter(|&x| alpha(&img, x, 50) > 0.99).collect();
+    let (l, r) = (cols[0], cols[cols.len() - 1]);
+    assert!((l - 80).abs() > 3, "the square moved: {l}");
+    let (cl, cr) = (img.get(l, 50), img.get(r, 50));
+    assert!(cl[0] < 0.1 && cr[0] > 0.9, "the ramp runs edge to edge: {cl:?} {cr:?}");
+}
+
+/// A gradient that paints every copy of a Repeater moves with the copies when they wiggle alike
+/// (Correlation 100).
+#[test]
+fn wiggle_transform_moves_a_gradient_shared_by_repeater_copies() {
+    let (p, cid) = shape_comp(|ids| {
+        let g = Gradient { colors: vec![(0.0, [0.0, 0.0, 0.0, 1.0]), (1.0, [1.0, 1.0, 1.0, 1.0])], opacities: vec![(0.0, 1.0), (1.0, 1.0)] };
+        vec![
+            build::shape_rect(ids, [20.0, 20.0], [-60.0, 0.0], 0.0),
+            build::shape_repeater(ids, 3.0, [60.0, 0.0]),
+            build::shape_gradient_fill(ids, false, [0.0, -10.0], [0.0, 10.0], g),
+            wiggle_transform(ids, &[("correlation", Value::Scalar(100.0)), ("transform/position", Value::Vec2([0.0, 15.0]))]),
+        ]
+    });
+    let img = render_frame(&p, cid, Tick::from_seconds_f64(0.9), 1.0);
+    for cx in [40, 100, 160] {
+        let rows: Vec<i64> = (0..100).filter(|&y| alpha(&img, cx, y) > 0.99).collect();
+        let (t, b) = (rows[0], rows[rows.len() - 1]);
+        assert!((t - 40).abs() > 3, "copy at {cx} moved: {t}");
+        let (ct, cb) = (img.get(cx, t), img.get(cx, b));
+        assert!(ct[0] < 0.1 && cb[0] > 0.9, "copy at {cx}: {ct:?} {cb:?}");
+    }
+}
+
+/// A gradient over copies that wiggle on their own keeps its place: every copy shows the ramp's
+/// middle at the group's origin row.
+#[test]
+fn wiggle_transform_keeps_a_gradient_over_copies_wiggling_apart() {
+    let (p, cid) = shape_comp(|ids| {
+        let g = Gradient { colors: vec![(0.0, [0.0, 0.0, 0.0, 1.0]), (1.0, [1.0, 1.0, 1.0, 1.0])], opacities: vec![(0.0, 1.0), (1.0, 1.0)] };
+        vec![
+            build::shape_rect(ids, [20.0, 40.0], [-60.0, 0.0], 0.0),
+            build::shape_repeater(ids, 3.0, [60.0, 0.0]),
+            build::shape_gradient_fill(ids, false, [0.0, -20.0], [0.0, 20.0], g),
+            wiggle_transform(ids, &[("correlation", Value::Scalar(0.0)), ("transform/position", Value::Vec2([0.0, 15.0]))]),
+        ]
+    });
+    let img = render_frame(&p, cid, Tick::from_seconds_f64(0.9), 1.0);
+    for cx in [40, 100, 160] {
+        let c = img.get(cx, 50);
+        assert!(c[3] > 0.99 && (c[0] - 0.5).abs() < 0.05, "copy at {cx}: {c:?}");
+    }
+}
+
+/// Copies wiggling on their own under one 50 % fill stay one paint: where they overlap, the fill
+/// covers them once.
+#[test]
+fn wiggle_transform_keeps_one_fill_over_overlapping_copies() {
+    let (p, cid) = shape_comp(|ids| {
+        let mut fill = build::shape_fill(ids, RED);
+        set(&mut fill, "opacity", Value::Scalar(50.0));
+        vec![
+            build::shape_rect(ids, [40.0, 40.0], [-20.0, 0.0], 0.0),
+            build::shape_repeater(ids, 3.0, [20.0, 0.0]),
+            fill,
+            wiggle_transform(ids, &[("correlation", Value::Scalar(0.0)), ("transform/position", Value::Vec2([0.0, 15.0]))]),
+        ]
+    });
+    let img = render_frame(&p, cid, Tick::from_seconds_f64(0.9), 1.0);
+    let top = img.data.iter().map(|px| px[3]).fold(0.0f32, f32::max);
+    assert!((top - 0.5).abs() < 0.01, "{top}");
+}
+
+/// A long stack of Repeaters, each cleared by a zero-copy Repeater, still renders the last Repeater's copies.
+#[test]
+fn long_repeater_stack_with_cleared_copies_renders() {
+    let (p, cid) = shape_comp(|ids| {
+        let mut items = vec![];
+        for _ in 0..70 {
+            items.push(build::shape_rect(ids, [20.0, 20.0], [0.0, 0.0], 0.0));
+            items.push(build::shape_repeater(ids, 3.0, [10.0, 0.0]));
+            items.push(build::shape_repeater(ids, 0.0, [10.0, 0.0]));
+        }
+        items.push(build::shape_rect(ids, [20.0, 20.0], [0.0, 0.0], 0.0));
+        items.push(build::shape_repeater(ids, 3.0, [30.0, 0.0]));
+        items.push(wiggle_transform(ids, &[("correlation", Value::Scalar(0.0)), ("transform/position", Value::Vec2([0.0, 15.0]))]));
+        items.push(build::shape_fill(ids, RED));
+        items
+    });
+    let img = render_frame(&p, cid, Tick::from_seconds_f64(0.37), 1.0);
+    assert!((coverage(&img) - 1200.0).abs() < 2.0, "{}", coverage(&img));
+}
+
+/// Copies a Repeater makes above a Wiggle Transform wiggle on their own (Correlation 0) or as one
+/// (Correlation 100); with the Wiggle Transform above the Repeater every copy wiggles the same.
+#[test]
+fn wiggle_transform_and_repeater_order() {
+    let offsets = |wiggle_first: bool, correlation: f64| {
+        let (p, cid) = shape_comp(|ids| {
+            let rect = build::shape_rect(ids, [20.0, 20.0], [-60.0, 0.0], 0.0);
+            let rep = build::shape_repeater(ids, 3.0, [60.0, 0.0]);
+            let w = wiggle_transform(ids, &[("correlation", Value::Scalar(correlation)), ("transform/position", Value::Vec2([0.0, 15.0]))]);
+            let fill = build::shape_fill(ids, RED);
+            if wiggle_first { vec![rect, w, rep, fill] } else { vec![rect, rep, w, fill] }
+        });
+        let img = render_frame(&p, cid, Tick::from_seconds_f64(0.37), 1.0);
+        [(0, 70), (70, 130), (130, 200)].map(|(x0, x1)| centroid(&img, x0, x1)[1] - 50.0)
+    };
+    let own = offsets(false, 0.0);
+    assert!((own[0] - own[1]).abs() > 0.5 || (own[1] - own[2]).abs() > 0.5, "copies wiggle on their own: {own:?}");
+    // A Repeater inside a group above the Wiggle Transform: its copies still wiggle on their own.
+    let (p, cid) = shape_comp(|ids| {
+        let inner = vec![build::shape_rect(ids, [20.0, 20.0], [-60.0, 0.0], 0.0), build::shape_repeater(ids, 3.0, [60.0, 0.0]), build::shape_fill(ids, RED)];
+        let group = build::shape_group(ids, "Repeated", inner);
+        vec![group, wiggle_transform(ids, &[("correlation", Value::Scalar(0.0)), ("transform/position", Value::Vec2([0.0, 15.0]))])]
+    });
+    let img = render_frame(&p, cid, Tick::from_seconds_f64(0.37), 1.0);
+    let nested = [(0, 70), (70, 130), (130, 200)].map(|(x0, x1)| centroid(&img, x0, x1)[1] - 50.0);
+    assert!((nested[0] - nested[1]).abs() > 0.5 || (nested[1] - nested[2]).abs() > 0.5, "nested copies: {nested:?}");
+    for same in [offsets(false, 100.0), offsets(true, 0.0)] {
+        assert!(same[0].abs() > 0.5, "{same:?}");
+        assert!((same[0] - same[1]).abs() < 0.05 && (same[1] - same[2]).abs() < 0.05, "copies wiggle as one: {same:?}");
+    }
+}
+
 #[test]
 fn zigzag_and_twist_render() {
     let (p, cid) = shape_comp(|ids| {
diff --git a/crates/script/src/matchnames.rs b/crates/script/src/matchnames.rs
--- a/crates/script/src/matchnames.rs
+++ b/crates/script/src/matchnames.rs
@@ -147,6 +147,7 @@ pub const SHAPE_ITEMS: &[(&str, &str)] = &[
     ("trim", "ADBE Vector Filter - Trim"),
     ("twist", "ADBE Vector Filter - Twist"),
     ("wiggle", "ADBE Vector Filter - Roughen"),
+    ("wiggler", "ADBE Vector Filter - Wiggler"),
     ("zigzag", "ADBE Vector Filter - Zigzag"),
 ];
 
@@ -208,6 +209,12 @@ const SHAPE_PROPS: &[(&str, &str, &str)] = &[
     ("pucker", "amount", "ADBE Vector PuckerBloat Amount"),
     ("twist", "angle", "ADBE Vector Twist Angle"),
     ("twist", "center", "ADBE Vector Twist Center"),
+    ("wiggler", "speed", "ADBE Vector Xform Temporal Freq"),
+    ("wiggler", "correlation", "ADBE Vector Correlation"),
+    ("wiggler", "phase", "ADBE Vector Temporal Phase"),
+    ("wiggler", "spatialPhase", "ADBE Vector Spatial Phase"),
+    ("wiggler", "seed", "ADBE Vector Random Seed"),
+    ("wiggler", "transform", "ADBE Vector Wiggler Transform"),
     ("merge", "mode", "ADBE Vector Merge Type"),
 ];
 
@@ -393,6 +400,7 @@ pub fn shape_kind(name: &str) -> Option<&'static str> {
         "twist" => "twist",
         "zigzag" => "zigzag",
         "wigglepaths" => "wiggle",
+        "wiggletransform" => "wiggler",
         "mergepaths" => "merge",
         _ => return None,
     })
diff --git a/crates/script/src/tests.rs b/crates/script/src/tests.rs
--- a/crates/script/src/tests.rs
+++ b/crates/script/src/tests.rs
@@ -333,6 +333,31 @@ fn match_names_and_lookups() {
     assert_eq!(sh.props.prop("contents/#1/contents/fill/color").unwrap().value, KV::Color([1.0, 0.0, 0.0, 1.0]));
 }
 
+/// Scripts add a Wiggle Transform by its match name or display name and reach its properties.
+#[test]
+fn wiggle_transform_by_match_name() {
+    let mut s = session();
+    let o = ok(
+        &mut s,
+        r#"
+        var c = app.project.items.addComp("W", 200, 200, 1, 2, 30);
+        var sh = c.layers.addShape();
+        var root = sh.property("ADBE Root Vectors Group");
+        var w = root.addProperty("ADBE Vector Filter - Wiggler");
+        var byName = root.addProperty("Wiggle Transform");
+        w.property("ADBE Vector Wiggler Transform").property("Position").setValue([20, 10]);
+        [
+          w.matchName, w.name, byName.matchName, w.property("ADBE Vector Xform Temporal Freq").value,
+          w.property("ADBE Vector Spatial Phase").name, w.property("ADBE Vector Wiggler Transform").name
+        ]
+        "#,
+    );
+    assert_eq!(o.result, json!(["ADBE Vector Filter - Wiggler", "Wiggle Transform 1", "ADBE Vector Filter - Wiggler", 2, "Spatial Phase", "Transform"]));
+    let (_, c) = comp_named(&s, "W");
+    let sh = c.layers.iter().find(|l| l.props.sub("contents").is_some()).unwrap();
+    assert_eq!(sh.props.prop("contents/wiggler/transform/position").unwrap().value, KV::Vec2([20.0, 10.0]));
+}
+
 #[test]
 fn keyframes_eases_and_interpolation() {
     let mut s = session();
diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs
--- a/crates/ui-egui/src/panels/timeline.rs
+++ b/crates/ui-egui/src/panels/timeline.rs
@@ -3921,6 +3921,7 @@ const SHAPE_ADD_ITEMS: &[(&str, &str)] = &[
     ("trim", "Trim Paths"),
     ("twist", "Twist"),
     ("wiggle", "Wiggle Paths"),
+    ("wiggler", "Wiggle Transform"),
     ("zigzag", "Zig Zag"),
 ];
 
diff --git a/crates/ui-egui/tests/it/ui_timeline_shapes.rs b/crates/ui-egui/tests/it/ui_timeline_shapes.rs
--- a/crates/ui-egui/tests/it/ui_timeline_shapes.rs
+++ b/crates/ui-egui/tests/it/ui_timeline_shapes.rs
@@ -75,6 +75,22 @@ fn shape_contents_add_menu_adds_path_operations() {
     assert_eq!(items, ["group", "trim"]);
 }
 
+#[test]
+fn shape_contents_add_menu_adds_wiggle_transform() {
+    let mut s = session();
+    let l = s.execute("layer.newShape", json!({"kind": "ellipse"})).unwrap()["layer"].as_u64().unwrap();
+    let contents = s.active_comp().unwrap().layer(LayerId(l)).unwrap().props.sub("contents").unwrap().uid;
+    let mut h = harness(s);
+    open_layer(&mut h, l);
+    click(&mut h, &format!("timeline.group.{contents}.add"));
+    assert!(h.get_by_label("Zig Zag").rect().max.y <= 1000.0, "the whole menu fits in the window");
+    let entry = h.get_by_label("Wiggle Transform").rect();
+    click_at(&mut h, entry.center());
+    let layer = h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().clone();
+    let items: Vec<&str> = layer.props.sub("contents").unwrap().groups().map(|g| g.match_id.as_str()).collect();
+    assert_eq!(items, ["group", "wiggler"]);
+}
+
 /// A shape group's inner Contents is elided in After Effects: opening the group lists its items
 /// (here the path) directly, with no "Contents" row between them.
 #[test]
