diff --git a/ROADMAP.md b/ROADMAP.md
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -131,6 +131,7 @@ app's JSON control channel (`deckcraft --control PORT`).
 
 | Date | What landed |
 |---|---|
+| 2026-10-19 | Animations: Anvil, the Keynote-style entrance. The shape drops from above the slide, squashes on landing and kicks up a puff of dust; in the quick gallery. PPTX keeps it as preset 1001 with a drop and squash PowerPoint can play, and reopens it as Anvil |
 | 2026-10-18 | Native chart sidecar bytes move from JSON arrays into referenced ZIP entries, with shared media byte/item limits and bounded reads; older inline sidecars still load. Synthetic format regressions cover large workbooks and malformed members |
 | 2026-10-17 | Opening a file caps absurd effect sizes (glow, soft edges, shadows, reflections, on shapes, text and table text) at what Format Shape allows: a glow of 10^18 pt in a .pptx made rendering ask for 34 GB and abort |
 | 2026-10-17 | Opening a .deckcraft file whose shapes repeat an id gives the repeats fresh ids (groups, layouts and masters too), so the deck validates and edits by id find one shape |
diff --git a/crates/anim/src/effects.rs b/crates/anim/src/effects.rs
--- a/crates/anim/src/effects.rs
+++ b/crates/anim/src/effects.rs
@@ -239,6 +239,25 @@ impl EffectSpec {
                 st.scale_x = 0.3 + 0.7 * e;
                 st.opacity = e;
             }
+            "anvil" => {
+                // Keynote's Anvil: a free fall from above the slide (gravity: distance grows with t²),
+                // then a squash on impact that rings out, with a puff of dust at the base. The curve is
+                // the effect itself, so it ignores smoothing. Squashing keeps the base on the ground.
+                const LAND: f64 = 0.45;
+                if p < LAND {
+                    let t = p / LAND;
+                    st.offset_y = -(y + h) * (1.0 - t * t);
+                } else {
+                    let u = ((p - LAND) / (1.0 - LAND)).clamp(0.0, 1.0);
+                    let squash = 0.14 * (u / 0.05).min(1.0) * (-4.5 * u).exp() * (2.5 * PI * u).cos();
+                    st.scale_y = 1.0 - squash;
+                    st.scale_x = 1.0 + 0.6 * squash;
+                    st.offset_y = h * squash / 2.0;
+                    if !exit && u < 1.0 {
+                        st.dust = u.max(1e-3);
+                    }
+                }
+            }
             "spinner" => {
                 st.rotate = 360.0 * q;
                 st.scale_x = 0.5 + 0.5 * e;
diff --git a/crates/anim/src/lib.rs b/crates/anim/src/lib.rs
--- a/crates/anim/src/lib.rs
+++ b/crates/anim/src/lib.rs
@@ -60,11 +60,25 @@ pub struct AnimState {
     pub rotate: f64,
     pub clip: Option<[f64; 4]>,
     pub tint: Option<Rgba>,
+    /// Progress (0..1) of a dust puff at the shape's base (Anvil's landing); 0 = none.
+    #[serde(default)]
+    pub dust: f64,
 }
 
 impl Default for AnimState {
     fn default() -> Self {
-        AnimState { visible: true, opacity: 1.0, offset_x: 0.0, offset_y: 0.0, scale_x: 1.0, scale_y: 1.0, rotate: 0.0, clip: None, tint: None }
+        AnimState {
+            visible: true,
+            opacity: 1.0,
+            offset_x: 0.0,
+            offset_y: 0.0,
+            scale_x: 1.0,
+            scale_y: 1.0,
+            rotate: 0.0,
+            clip: None,
+            tint: None,
+            dust: 0.0,
+        }
     }
 }
 
@@ -78,7 +92,7 @@ impl AnimState {
         *self == AnimState::default()
     }
     /// Apply `d` on top of `self`: opacities and scales multiply, offsets and rotations add, clips
-    /// intersect, a tint replaces.
+    /// intersect, a tint replaces, the larger dust puff wins.
     pub fn compose(&self, d: &AnimState) -> AnimState {
         let clip = match (self.clip, d.clip) {
             (Some(a), Some(b)) => Some([a[0].max(b[0]), a[1].max(b[1]), a[2].min(b[2]), a[3].min(b[3])]),
@@ -95,6 +109,7 @@ impl AnimState {
             rotate: self.rotate + d.rotate,
             clip,
             tint: d.tint.or(self.tint),
+            dust: self.dust.max(d.dust),
         }
     }
     /// Replace any non-finite number with its neutral value and clamp opacity and clip.
@@ -106,6 +121,7 @@ impl AnimState {
         self.scale_x = f(self.scale_x, 1.0);
         self.scale_y = f(self.scale_y, 1.0);
         self.rotate = f(self.rotate, 0.0);
+        self.dust = f(self.dust, 0.0).clamp(0.0, 1.0);
         self.clip = self.clip.map(|c| {
             let l = f(c[0], 0.0).clamp(0.0, 1.0);
             let t = f(c[1], 0.0).clamp(0.0, 1.0);
diff --git a/crates/anim/src/tests.rs b/crates/anim/src/tests.rs
--- a/crates/anim/src/tests.rs
+++ b/crates/anim/src/tests.rs
@@ -191,6 +191,25 @@ fn every_animation_evaluates_finite() {
     }
 }
 
+#[test]
+fn anvil_drops_from_above_squashes_and_puffs_dust() {
+    let tl = Timeline::new(&slide_with(vec![anim(1, AnimClass::Entrance, "anvil", AnimStart::OnClick)]), W, H);
+    // Shape at y=100, h=100: starts fully above the slide, falling faster as it goes.
+    let s0 = tl.state(ShapeId(1), None, 0, 1e-6);
+    assert!(s0.visible && s0.offset_y <= -199.0, "{s0:?}");
+    let (a, b, c) = (tl.state(ShapeId(1), None, 0, 0.1), tl.state(ShapeId(1), None, 0, 0.2), tl.state(ShapeId(1), None, 0, 0.3));
+    assert!(b.offset_y - a.offset_y < c.offset_y - b.offset_y, "accelerates: {a:?} {b:?} {c:?}");
+    assert_eq!(a.dust, 0.0);
+    // Just after landing (45 %): flattened and widened with the base kept on the ground, dust rising.
+    let hit = tl.state(ShapeId(1), None, 0, 0.5);
+    assert!(hit.scale_y < 0.95 && hit.scale_x > 1.02, "{hit:?}");
+    assert!((hit.offset_y - 100.0 * (1.0 - hit.scale_y) / 2.0).abs() < 1e-9, "{hit:?}");
+    assert!(hit.dust > 0.0 && hit.dust < 1.0);
+    // Done: at rest, no dust.
+    let end = tl.state(ShapeId(1), None, 1, 0.0);
+    assert!(end.is_identity(), "{end:?}");
+}
+
 #[test]
 fn fly_starts_off_slide() {
     let mut a = anim(1, AnimClass::Entrance, "fly", AnimStart::OnClick);
diff --git a/crates/model/src/anim.rs b/crates/model/src/anim.rs
--- a/crates/model/src/anim.rs
+++ b/crates/model/src/anim.rs
@@ -342,6 +342,10 @@ pub static ANIMATIONS: &[(&str, &str, AnimClass, u32, u32, &[&str])] = &[
     ("expand", "Expand", AnimClass::Entrance, 55, 500, &[]),
     ("rise", "Rise Up", AnimClass::Entrance, 37, 1000, &[]),
     ("spinner", "Spinner", AnimClass::Entrance, 49, 1000, &[]),
+    // Keynote-style Anvil: drops from above the slide, lands with a squash and a puff of dust.
+    // PowerPoint has no such preset; 1001 is a DeckCraft id outside PowerPoint's range, and the file
+    // carries a drop-and-squash PowerPoint can play.
+    ("anvil", "Anvil", AnimClass::Entrance, 1001, 1000, &[]),
     ("pulse", "Pulse", AnimClass::Emphasis, 26, 500, &[]),
     ("colorPulse", "Color Pulse", AnimClass::Emphasis, 27, 500, &[]),
     ("teeter", "Teeter", AnimClass::Emphasis, 32, 1000, &[]),
diff --git a/crates/pptx/src/write/timing.rs b/crates/pptx/src/write/timing.rs
--- a/crates/pptx/src/write/timing.rs
+++ b/crates/pptx/src/write/timing.rs
@@ -192,6 +192,48 @@ fn anim_prop(w: &mut W, ids: &mut Ids, e: &Eff, dur: u32, attr: &str, from: &str
     w.close("p:anim");
 }
 
+/// Anvil as PowerPoint can play it: a gravity drop from above the slide sampled as key times
+/// (distance grows with t²), landing at 45 % of the effect, then a squash flattened against the
+/// ground (the box shifted down while it shrinks) that springs back. No dust: PowerPoint has none.
+fn anvil(w: &mut W, ids: &mut Ids, e: &Eff, dur: u32) {
+    let mut keys: Vec<(u32, String)> = (0..=6)
+        .map(|k| {
+            let t = k as f64 / 6.0;
+            let tm = (t * 45_000.0).round() as u32;
+            let rest = 1.0 - t * t;
+            let v = if k == 6 { "#ppt_y".to_string() } else { format!("#ppt_y-(#ppt_y+#ppt_h/2)*{rest:.4}") };
+            (tm, v)
+        })
+        .collect();
+    keys.extend([(52_000, "#ppt_y+#ppt_h*0.06".to_string()), (60_000, "#ppt_y".to_string()), (100_000, "#ppt_y".to_string())]);
+    w.open("p:anim", A::new().a("calcmode", "lin").a("valueType", "num"));
+    cbhvr(w, ids, e, dur, None, &["ppt_y"], Some("base"));
+    w.open0("p:tavLst");
+    for (tm, v) in &keys {
+        w.open("p:tav", A::new().a("tm", *tm));
+        w.open0("p:val");
+        w.val("p:strVal", v);
+        w.close("p:val");
+        w.close("p:tav");
+    }
+    w.close("p:tavLst");
+    w.close("p:anim");
+    // The squash: from the landing, flatten and widen, then spring back (autoRev).
+    let at = (u64::from(dur) * 45 / 100) as u32;
+    let half = ((u64::from(dur) * 7 / 100) as u32).max(1);
+    w.open0("p:animScale");
+    w.open0("p:cBhvr");
+    w.open("p:cTn", A::new().a("id", ids.next()).a("dur", half).a("autoRev", "1").a("fill", "hold"));
+    w.open0("p:stCondLst");
+    w.empty("p:cond", A::new().a("delay", at));
+    w.close("p:stCondLst");
+    w.close("p:cTn");
+    tgt(w, e.spid, e.paragraph);
+    w.close("p:cBhvr");
+    w.empty("p:by", A::new().a("x", 108_000).a("y", 88_000));
+    w.close("p:animScale");
+}
+
 fn filter_for(effect: &str, option: &str) -> &'static str {
     match effect {
         "wipe" | "wipeOut" => match option {
@@ -318,6 +360,7 @@ fn behaviors(w: &mut W, x: &mut Exp, ids: &mut Ids, e: &Eff) {
                     anim_prop(w, ids, e, dur, "ppt_h", "0", "#ppt_h");
                     anim_effect(w, ids, e, dur, "fade", false);
                 }
+                "anvil" => anvil(w, ids, e, dur),
                 "float" | "rise" => {
                     anim_prop(w, ids, e, dur, "ppt_y", if a.option == "d" { "#ppt_y-.1" } else { "#ppt_y+.1" }, "#ppt_y");
                     anim_effect(w, ids, e, dur, "fade", false);
diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs
--- a/crates/render/src/lib.rs
+++ b/crates/render/src/lib.rs
@@ -184,13 +184,25 @@ pub struct ShapeState {
     pub clip: Option<[f64; 4]>,
     /// Colour override for emphasis effects (fill).
     pub tint: Option<Rgba>,
+    /// Progress (0..1) of a dust puff at the shape's base (Anvil's landing); 0 = none.
+    pub dust: f64,
     /// Per-paragraph states of by-paragraph text builds (paragraph index, state).
     pub paras: Vec<(usize, ParaState)>,
 }
 
 impl Default for ShapeState {
     fn default() -> Self {
-        ShapeState { visible: true, opacity: 1.0, offset: Vec2::ZERO, scale: (1.0, 1.0), rotate: 0.0, clip: None, tint: None, paras: vec![] }
+        ShapeState {
+            visible: true,
+            opacity: 1.0,
+            offset: Vec2::ZERO,
+            scale: (1.0, 1.0),
+            rotate: 0.0,
+            clip: None,
+            tint: None,
+            dust: 0.0,
+            paras: vec![],
+        }
     }
 }
 
@@ -504,6 +516,55 @@ fn output_size(pres: &Presentation, opts: &RenderOpts) -> (u16, u16) {
     (w.clamp(1, MAX_SIDE) as u16, h.clamp(1, MAX_SIDE) as u16)
 }
 
+/// Anvil's landing dust: soft grey clouds billowing out sideways from the base of the shape's box
+/// (`m`: shape-local points to output pixels), `u` the puff's progress 0..1. Deterministic, so
+/// every frame and export of the same moment match.
+fn dust_puff(ctx: &mut RenderContext, m: Affine, w: f64, h: f64, u: f64) {
+    if !(u.is_finite() && w.is_finite() && h.is_finite()) || w <= 0.0 || h <= 0.0 {
+        return;
+    }
+    // The ground: the lowest edge of the (possibly rotated) box, in slide space.
+    let corners = [Point::new(0.0, 0.0), Point::new(w, 0.0), Point::new(0.0, h), Point::new(w, h)].map(|p| m * p);
+    let (mut l, mut r, mut b) = (f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
+    for p in corners {
+        l = l.min(p.x);
+        r = r.max(p.x);
+        b = b.max(p.y);
+    }
+    let bw = r - l;
+    if !(bw.is_finite() && b.is_finite()) || bw <= 0.0 {
+        return;
+    }
+    let cx = (l + r) / 2.0;
+    let out = 1.0 - (1.0 - u).powi(3);
+    let alpha = 0.6 * (u / 0.08).min(1.0) * (1.0 - u).powf(1.6);
+    // Size the clouds from the box width in output pixels, within sensible bounds.
+    let base = (bw * 0.09).clamp(4.0, 120.0);
+    let tone = Rgba::rgb(0xB9, 0xB4, 0xAC);
+    ctx.set_transform(Affine::IDENTITY);
+    const N: usize = 12;
+    for k in 0..N {
+        // Fixed pseudo-random spread per puff.
+        let a = ((k as f64 * 0.618_034) % 1.0 + 0.13 * (k % 3) as f64) % 1.0;
+        let side = if k % 2 == 0 { -1.0 } else { 1.0 };
+        let start = cx + side * bw / 2.0 * (0.25 + 0.75 * a);
+        let px = start + side * bw * (0.12 + 0.38 * a) * out;
+        let py = b - base * (0.2 + 0.9 * a) * out;
+        let rad = base * (0.8 + 0.9 * out) * (0.75 + 0.5 * ((k * 7 % 5) as f64 / 4.0));
+        if !(px.is_finite() && py.is_finite() && rad.is_finite()) || rad <= 0.0 {
+            continue;
+        }
+        let c = Point::new(px, py);
+        let stops = [
+            peniko::ColorStop { offset: 0.0, color: color(tone, alpha).into() },
+            peniko::ColorStop { offset: 0.55, color: color(tone, alpha * 0.55).into() },
+            peniko::ColorStop { offset: 1.0, color: color(tone, 0.0).into() },
+        ];
+        ctx.set_paint(peniko::Gradient::new_radial(c, rad as f32).with_stops(stops.as_slice()));
+        ctx.fill_rect(&Rect::new(px - rad, py - rad, px + rad, py + rad));
+    }
+}
+
 pub(crate) fn color(c: Rgba, alpha: f64) -> peniko::Color {
     let a = (c.a as f64 * alpha.clamp(0.0, 1.0)).round().clamp(0.0, 255.0) as u8;
     peniko::Color::from_rgba8(c.r, c.g, c.b, a)
@@ -990,6 +1051,9 @@ impl Renderer {
         if layered {
             ctx.pop_layer();
         }
+        if st.dust > 0.0 && st.dust < 1.0 {
+            dust_puff(ctx, parent * x.affine(), x.w, x.h, st.dust);
+        }
     }
 
     #[allow(clippy::too_many_arguments)]
diff --git a/crates/ui-egui/src/preview.rs b/crates/ui-egui/src/preview.rs
--- a/crates/ui-egui/src/preview.rs
+++ b/crates/ui-egui/src/preview.rs
@@ -20,6 +20,7 @@ pub fn shape_state(tl: &Timeline, id: ShapeId, step: usize, t: f64) -> ShapeStat
         rotate: a.rotate,
         clip: a.clip,
         tint: a.tint,
+        dust: a.dust,
         paras: tl
             .paragraph_targets(id)
             .into_iter()
diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs
--- a/crates/ui-egui/src/ribbon.rs
+++ b/crates/ui-egui/src/ribbon.rs
@@ -1901,6 +1901,7 @@ fn animations(app: &mut SlideApp, ui: &mut Ui) {
                         | "split"
                         | "wipe"
                         | "zoom"
+                        | "anvil"
                         | "pulse"
                         | "spin"
                         | "growShrink"
diff --git a/crates/ui-egui/src/show.rs b/crates/ui-egui/src/show.rs
--- a/crates/ui-egui/src/show.rs
+++ b/crates/ui-egui/src/show.rs
@@ -356,6 +356,7 @@ fn state_for(tl: &Timeline, id: ShapeId, step: usize, t: f64, plays: &[TriggerPl
         rotate: a.rotate,
         clip: a.clip,
         tint: a.tint,
+        dust: a.dust,
         paras: tl
             .paragraph_targets(id)
             .into_iter()
diff --git a/docs/animation-parity.md b/docs/animation-parity.md
--- a/docs/animation-parity.md
+++ b/docs/animation-parity.md
@@ -1,6 +1,6 @@
 # Transitions and animations: parity with PowerPoint
 
-> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (community PR wave 2026-10-14: interactive-deck triggers, canvas preview, the Animation Pane timeline, repeat and bounce end round-trip; duplicated rows and headers folded; previous: major, transitions measured frame by frame against PowerPoint for Windows' video export) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
+> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-19 · **Change:** trivial (Anvil, a Keynote-style entrance beyond PowerPoint; previous: minor, community PR wave 2026-10-14: interactive-deck triggers, canvas preview, the Animation Pane timeline, repeat and bounce end round-trip; duplicated rows and headers folded; previous: major, transitions measured frame by frame against PowerPoint for Windows' video export) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
 
 Transitions and animations are what make a deck a presentation, and the part of PowerPoint people
 notice first when it's missing. Scores roll up into [target-app-parity.md](target-app-parity.md)
@@ -57,6 +57,14 @@ perspective-correct subdivided meshes.
 | Motion paths | 64 presets + custom | 40 + Custom Path (Lines, Arcs, Turns, Loops, Basic shapes such as stars, heart, moon, teardrop; Lines & Curves such as zigzag, waves, S curves, spirals, bounces; Special such as figure 8, bean, peanut, neutron) | Remaining ~24 presets (some Basic shapes, Lines & Curves variants) |
 | Media (Play, Pause, Stop) | 3 (+ Seek) | 3 | Seek, bookmarks as triggers |
 
+Beyond PowerPoint: **Anvil** (entrance, in the ribbon's quick gallery), the effect Keynote users
+know. The shape free-falls from above the slide, squashes on landing with its base kept on the
+ground, and a puff of dust billows out sideways (`AnimState::dust`, drawn by the renderer). PowerPoint
+has no such preset, so PPTX stores it under DeckCraft's preset id 1001 with behaviours PowerPoint
+can play: the gravity drop as `ppt_y` key times and an `animScale` squash that springs back (no
+dust). DeckCraft reads id 1001 back as Anvil (`anvil_drops_from_above_squashes_and_puffs_dust`,
+`every_animation_effect_survives`). It is not counted in the PowerPoint scores above.
+
 By how often people use them, the effects present cover nearly all real decks (Appear, Fade, Fly
 In, Wipe, Zoom, Float In, Grow/Shrink, Pulse, Spin, motion lines): weighted, ~85% of use; by count,
 ~34%.
@@ -94,6 +102,7 @@ In, Wipe, Zoom, Float In, Grow/Shrink, Pulse, Spin, motion lines): weighted, ~85
 
 | Date | Change | Summary |
 |---|---|---|
+| 2026-10-19 | trivial | Anvil: a Keynote-style entrance beyond PowerPoint (drop, squash, dust), saved to PPTX as a drop PowerPoint can play; not counted in the scores |
 | 2026-10-15 | trivial | PPTX interactive sequences preserve later On Click effects and one click per paragraph; no change to playback or feature scores |
 | 2026-10-14 | trivial | Community PR wave folded: the Animation Pane timeline row says yes (it landed), preview split into canvas (yes) and from the selected effect (no); estimates drop the timeline and transition sounds (~15–27 h) |
 | 2026-10-14 | trivial | Repeat and bounce end round-trip through PPTX; rewind is still lost (#77) |
