diff --git a/.changeset/slide-transition-api.md b/.changeset/slide-transition-api.md
new file mode 100644
index 00000000..4f83ea06
--- /dev/null
+++ b/.changeset/slide-transition-api.md
@@ -0,0 +1,6 @@
+---
+'@open-slide/core': minor
+'@open-slide/cli': patch
+---
+
+Add SlideTransition API for declaring per-page page-transition animations, plus a transitions section in the bundled slide-authoring skill with tasteful few-shot examples.
diff --git a/apps/demo/slides/slide-transitions-maximal/index.tsx b/apps/demo/slides/slide-transitions-maximal/index.tsx
new file mode 100644
index 00000000..b633de1d
--- /dev/null
+++ b/apps/demo/slides/slide-transitions-maximal/index.tsx
@@ -0,0 +1,558 @@
+import type { DesignSystem, Page, SlideMeta, SlideTransition } from '@open-slide/core';
+import type { CSSProperties } from 'react';
+
+export const design: DesignSystem = {
+ palette: { bg: '#08080a', text: '#fafaf5', accent: '#ff2d4a' },
+ fonts: {
+ display: '"Inter Tight", "Inter", -apple-system, BlinkMacSystemFont, system-ui, sans-serif',
+ body: '-apple-system, BlinkMacSystemFont, "Inter", system-ui, sans-serif',
+ },
+ typeScale: { hero: 200, body: 32 },
+ radius: 4,
+};
+
+const muted = 'rgba(250, 250, 245, 0.46)';
+const hairline = 'rgba(250, 250, 245, 0.12)';
+
+const fill = {
+ width: '100%',
+ height: '100%',
+ fontFamily: 'var(--osd-font-body)',
+ background: 'var(--osd-bg)',
+ color: 'var(--osd-text)',
+} as const;
+
+const EYEBROW: CSSProperties = {
+ fontSize: 18,
+ letterSpacing: '0.32em',
+ color: 'var(--osd-accent)',
+ textTransform: 'uppercase',
+ fontWeight: 600,
+};
+
+const FOOT: CSSProperties = {
+ fontSize: 17,
+ letterSpacing: '0.28em',
+ color: muted,
+ textTransform: 'uppercase',
+ fontVariantNumeric: 'tabular-nums',
+ fontWeight: 500,
+};
+
+const Cover: Page = () => (
+
+
+
open-slide · field notes · vol. ii
+
showcase
+
+
+
+ Maximal.
+
+
+ Six effects you can’t draw in a binary slide format — every one in under twenty lines
+ of code, every one rendered by your browser, every frame still vector.
+
+
+
+ 01 · iris
+ arrow keys ⇆
+
+
+);
+
+const Show = ({
+ n,
+ label,
+ heading,
+ pull,
+ body,
+ glyph,
+}: {
+ n: string;
+ label: string;
+ heading: string;
+ pull: string;
+ body: string;
+ glyph: React.ReactNode;
+}) => (
+
+
+
{`§ ${n} · ${label}`}
+
effect
+
+
+
+
{glyph}
+
+ {heading}
+
+
+
+
+ “{pull}”
+
+
+ {body}
+
+
+
+
+
+ {n} · {label}
+
+ transition · {label}
+
+
+);
+
+const FlipGlyph = (
+
+
+
+
+
+);
+
+const GlitchGlyph = (
+
+
+ R/G/B
+
+
+);
+
+const WarpGlyph = (
+
+ {Array.from({ length: 7 }).map((_, i) => (
+
+ ))}
+
+);
+
+const ShearGlyph = (
+
+
+
+
+
+);
+
+const PortalGlyph = (
+
+
+
+
+
+
+);
+
+const Flip: Page = () => (
+
+);
+
+const Glitch: Page = () => (
+
+);
+
+const Warp: Page = () => (
+
+);
+
+const Sweep: Page = () => (
+
+);
+
+const Closing: Page = () => (
+
+
+
+
{PortalGlyph}
+
+ All possible.
+
+ None recommended
+ .
+
+
+ Every effect in this deck is a few keyframes — clip-path, perspective, filter, skew, the
+ whole CSS animation surface, exposed verbatim. Use them when the moment earns it. Then head
+ back to On Tasteful Transitions and remember why you didn’t.
+
+
+
+ 06 · portal
+ ← to revisit
+
+
+);
+
+const EASE_OUT = 'cubic-bezier(0.16, 1, 0.3, 1)';
+const EASE_IN = 'cubic-bezier(0.7, 0, 0.84, 0)';
+
+// 1 · IRIS — clip-path circle collapses to a point, then expands.
+// Round-trip dimensions: 80% radius covers a 16:9 reference box corner-to-corner.
+Cover.transition = {
+ duration: 700,
+ exit: {
+ duration: 320,
+ easing: EASE_IN,
+ keyframes: [
+ { clipPath: 'circle(80% at 50% 50%)', opacity: 1 },
+ { clipPath: 'circle(0% at 50% 50%)', opacity: 1 },
+ ],
+ },
+ enter: {
+ duration: 520,
+ delay: 220,
+ easing: EASE_OUT,
+ keyframes: [
+ { clipPath: 'circle(0% at 50% 50%)', opacity: 1 },
+ { clipPath: 'circle(80% at 50% 50%)', opacity: 1 },
+ ],
+ },
+};
+
+// 2 · FLIP — perspective + rotateY. Genuine 3D, not a sprite sheet.
+Flip.transition = {
+ duration: 760,
+ exit: {
+ duration: 380,
+ easing: EASE_IN,
+ keyframes: [
+ {
+ opacity: 1,
+ transform: 'perspective(1600px) rotateY(0deg) translateZ(0)',
+ transformOrigin: '50% 50%',
+ },
+ {
+ opacity: 0,
+ transform: 'perspective(1600px) rotateY(-22deg) translateZ(-260px)',
+ transformOrigin: '50% 50%',
+ },
+ ],
+ },
+ enter: {
+ duration: 520,
+ delay: 240,
+ easing: EASE_OUT,
+ keyframes: [
+ {
+ opacity: 0,
+ transform: 'perspective(1600px) rotateY(22deg) translateZ(-260px)',
+ transformOrigin: '50% 50%',
+ },
+ {
+ opacity: 1,
+ transform: 'perspective(1600px) rotateY(0deg) translateZ(0)',
+ transformOrigin: '50% 50%',
+ },
+ ],
+ },
+};
+
+// 3 · GLITCH — chromatic-aberration via stacked drop-shadows, stepped easing
+// fakes a dropped framerate. The kind of motion a binary slide format can't even describe.
+Glitch.transition = {
+ duration: 560,
+ exit: {
+ duration: 260,
+ easing: 'steps(5, end)',
+ keyframes: [
+ {
+ opacity: 1,
+ filter: 'drop-shadow(0 0 0 transparent) drop-shadow(0 0 0 transparent)',
+ transform: 'translate(0, 0)',
+ },
+ {
+ opacity: 0.85,
+ filter: 'drop-shadow(6px 0 0 #00ffff) drop-shadow(-6px 0 0 #ff0044)',
+ transform: 'translate(-3px, 1px)',
+ },
+ {
+ opacity: 0.55,
+ filter: 'drop-shadow(-12px 0 0 #00ffff) drop-shadow(12px 0 0 #ff0044)',
+ transform: 'translate(4px, -2px)',
+ },
+ {
+ opacity: 0.25,
+ filter: 'drop-shadow(16px 0 0 #00ffff) drop-shadow(-16px 0 0 #ff0044)',
+ transform: 'translate(-2px, 2px)',
+ },
+ {
+ opacity: 0,
+ filter: 'drop-shadow(0 0 0 transparent) drop-shadow(0 0 0 transparent)',
+ transform: 'translate(0, 0)',
+ },
+ ],
+ },
+ enter: {
+ duration: 340,
+ delay: 220,
+ easing: 'steps(6, end)',
+ keyframes: [
+ {
+ opacity: 0,
+ filter: 'drop-shadow(-16px 0 0 #00ffff) drop-shadow(16px 0 0 #ff0044)',
+ transform: 'translate(6px, -2px)',
+ },
+ {
+ opacity: 0.4,
+ filter: 'drop-shadow(10px 0 0 #00ffff) drop-shadow(-10px 0 0 #ff0044)',
+ transform: 'translate(-4px, 2px)',
+ },
+ {
+ opacity: 0.75,
+ filter: 'drop-shadow(-4px 0 0 #00ffff) drop-shadow(4px 0 0 #ff0044)',
+ transform: 'translate(2px, -1px)',
+ },
+ {
+ opacity: 1,
+ filter: 'drop-shadow(0 0 0 transparent) drop-shadow(0 0 0 transparent)',
+ transform: 'translate(0, 0)',
+ },
+ ],
+ },
+};
+
+// 4 · WARP — blur burst + scale. Reads as velocity; renders as filter+transform.
+// Module default; anything not overridden inherits this.
+export const transition: SlideTransition = {
+ duration: 700,
+ exit: {
+ duration: 300,
+ easing: 'cubic-bezier(0.55, 0, 1, 0.45)',
+ keyframes: [
+ { opacity: 1, transform: 'scale(1)', filter: 'blur(0) saturate(1)' },
+ {
+ opacity: 0,
+ transform: 'scale(1.4)',
+ filter: 'blur(30px) saturate(1.6)',
+ },
+ ],
+ },
+ enter: {
+ duration: 480,
+ delay: 220,
+ easing: EASE_OUT,
+ keyframes: [
+ {
+ opacity: 0,
+ transform: 'scale(0.6)',
+ filter: 'blur(36px) saturate(1.4)',
+ },
+ { opacity: 1, transform: 'scale(1)', filter: 'blur(0) saturate(1)' },
+ ],
+ },
+};
+
+Warp.transition = transition;
+
+// 5 · SWEEP — skewX + translateX. The outgoing card swipes past the camera
+// at a tilt; the new one arrives from the other side, also tilted, then squares up.
+Sweep.transition = {
+ duration: 760,
+ exit: {
+ duration: 360,
+ easing: EASE_IN,
+ keyframes: [
+ { opacity: 1, transform: 'translateX(0) skewX(0deg)' },
+ { opacity: 0, transform: 'translateX(-118%) skewX(-14deg)' },
+ ],
+ },
+ enter: {
+ duration: 480,
+ delay: 240,
+ easing: EASE_OUT,
+ keyframes: [
+ { opacity: 0, transform: 'translateX(118%) skewX(-14deg)' },
+ { opacity: 1, transform: 'translateX(0) skewX(0deg)' },
+ ],
+ },
+};
+
+// 6 · PORTAL — rotate + scale collapse, mirrored on entry. The outgoing page
+// spirals into a point; the inbound unfurls back out from the other direction.
+Closing.transition = {
+ duration: 820,
+ exit: {
+ duration: 400,
+ easing: EASE_IN,
+ keyframes: [
+ { opacity: 1, transform: 'rotate(0deg) scale(1)' },
+ { opacity: 0, transform: 'rotate(-110deg) scale(0)' },
+ ],
+ },
+ enter: {
+ duration: 560,
+ delay: 260,
+ easing: EASE_OUT,
+ keyframes: [
+ { opacity: 0, transform: 'rotate(110deg) scale(0)' },
+ { opacity: 1, transform: 'rotate(0deg) scale(1)' },
+ ],
+ },
+};
+
+export const meta: SlideMeta = {
+ title: 'Maximal — Six Transitions',
+ createdAt: '2026-05-24T00:00:00.000Z',
+};
+
+export default [Cover, Flip, Glitch, Warp, Sweep, Closing] satisfies Page[];
diff --git a/apps/demo/slides/slide-transitions/index.tsx b/apps/demo/slides/slide-transitions/index.tsx
new file mode 100644
index 00000000..ce51a8a6
--- /dev/null
+++ b/apps/demo/slides/slide-transitions/index.tsx
@@ -0,0 +1,434 @@
+import type { DesignSystem, Page, SlideMeta, SlideTransition } from '@open-slide/core';
+import type { CSSProperties } from 'react';
+
+export const design: DesignSystem = {
+ palette: { bg: '#0c0c0d', text: '#f3f1ea', accent: '#d6d2c4' },
+ fonts: {
+ display: 'ui-serif, Georgia, "Times New Roman", serif',
+ body: '-apple-system, BlinkMacSystemFont, "Inter", system-ui, sans-serif',
+ },
+ typeScale: { hero: 168, body: 34 },
+ radius: 6,
+};
+
+const muted = 'rgba(243, 241, 234, 0.42)';
+const hairline = 'rgba(243, 241, 234, 0.10)';
+
+const fill = {
+ width: '100%',
+ height: '100%',
+ fontFamily: 'var(--osd-font-body)',
+ background: 'var(--osd-bg)',
+ color: 'var(--osd-text)',
+} as const;
+
+const EYEBROW: CSSProperties = {
+ fontSize: 20,
+ letterSpacing: '0.28em',
+ color: 'var(--osd-accent)',
+ textTransform: 'uppercase',
+ fontWeight: 500,
+};
+
+const FOOT: CSSProperties = {
+ fontSize: 18,
+ letterSpacing: '0.22em',
+ color: muted,
+ textTransform: 'uppercase',
+ fontVariantNumeric: 'tabular-nums',
+};
+
+const Cover: Page = () => (
+
+
open-slide · field notes
+
+
+ On tasteful
+
+ transitions.
+
+
+ Six pages, six transitions, one quiet family of motion.
+
+
+
+ 01 · settle
+ arrow keys ⇆
+
+
+);
+
+const Lesson = ({
+ n,
+ label,
+ heading,
+ body,
+ pull,
+}: {
+ n: string;
+ label: string;
+ heading: string;
+ body: string;
+ pull: string;
+}) => (
+
+
{`§ ${n}`}
+
+
+ {heading}
+
+
+
+ “{pull}”
+
+
+ {body}
+
+
+
+
+
+ {n} · {label}
+
+ transition · {label}
+
+
+);
+
+const Family: Page = () => (
+
+);
+
+const ShortDurations: Page = () => (
+
+);
+
+const Pause: Page = () => (
+
+
§ intermission
+
+ Restraint.
+
+
+ A chapter deserves a breath — exit, hold, then return.
+
+
+ 04 · breath
+ transition · breath
+
+
+);
+
+const SmallMagnitudes: Page = () => (
+
+);
+
+const Closing: Page = () => (
+
+
fin
+
+
+ Good motion is
+
+ invisible .
+
+
+ Six different moves, none of which announce themselves. The reader perceives variety; the
+ eye still reads one consistent hand.
+
+
+
+ 06 · fall
+ ← to revisit
+
+
+);
+
+// Shared DNA across all six transitions:
+// - Out-then-in with 80 ms overlap (exit starts immediately, enter delays).
+// - Exit ~140-180 ms · ease-in. Enter ~200-280 ms · ease-out.
+// - Opacity is always one of the animated properties.
+// - Translate magnitude never exceeds 12px. Scale never exceeds 3%.
+const EASE_OUT = 'cubic-bezier(0, 0, 0.2, 1)';
+const EASE_IN = 'cubic-bezier(0.4, 0, 1, 1)';
+
+// 1 · SETTLE — cover-grade. Rise + soft blur falloff on enter.
+Cover.transition = {
+ duration: 280,
+ easing: 'cubic-bezier(0.32, 0.72, 0, 1)',
+ exit: {
+ duration: 160,
+ easing: EASE_IN,
+ keyframes: [
+ { opacity: 1, transform: 'translateY(0)' },
+ { opacity: 0, transform: 'translateY(-6px)' },
+ ],
+ },
+ enter: {
+ duration: 280,
+ delay: 100,
+ easing: EASE_OUT,
+ keyframes: [
+ { opacity: 0, transform: 'translateY(12px)', filter: 'blur(4px)' },
+ { opacity: 1, transform: 'translateY(0)', filter: 'blur(0)' },
+ ],
+ },
+};
+
+// 2 · DISSOLVE — pure opacity. Apple's safe default. Quietest possible.
+Family.transition = {
+ duration: 240,
+ exit: {
+ duration: 200,
+ easing: EASE_IN,
+ keyframes: [{ opacity: 1 }, { opacity: 0 }],
+ },
+ enter: {
+ duration: 240,
+ delay: 40,
+ easing: EASE_OUT,
+ keyframes: [{ opacity: 0 }, { opacity: 1 }],
+ },
+};
+
+// 3 · RISE — the house quiet. 6 px of Y, exit-then-enter overlap.
+// Exported as the module default so future pages inherit it.
+export const transition: SlideTransition = {
+ duration: 200,
+ exit: {
+ duration: 140,
+ easing: EASE_IN,
+ keyframes: [
+ { opacity: 1, transform: 'translateY(0)' },
+ { opacity: 0, transform: 'translateY(-4px)' },
+ ],
+ },
+ enter: {
+ duration: 200,
+ delay: 80,
+ easing: EASE_OUT,
+ keyframes: [
+ { opacity: 0, transform: 'translateY(6px)' },
+ { opacity: 1, transform: 'translateY(0)' },
+ ],
+ },
+};
+
+// 4 · BREATH — section divider. Exit fully, hold 120 ms, then enter.
+Pause.transition = {
+ duration: 460,
+ exit: {
+ duration: 180,
+ easing: EASE_IN,
+ keyframes: [{ opacity: 1 }, { opacity: 0 }],
+ },
+ enter: {
+ duration: 240,
+ delay: 300,
+ easing: EASE_OUT,
+ keyframes: [
+ { opacity: 0, transform: 'translateY(8px)' },
+ { opacity: 1, transform: 'translateY(0)' },
+ ],
+ },
+};
+
+// 5 · BLOOM — scale only. 0.97 → 1, no translate. Materializes in place.
+SmallMagnitudes.transition = {
+ duration: 240,
+ exit: {
+ duration: 160,
+ easing: EASE_IN,
+ keyframes: [
+ { opacity: 1, transform: 'scale(1)' },
+ { opacity: 0, transform: 'scale(1.01)' },
+ ],
+ },
+ enter: {
+ duration: 240,
+ delay: 80,
+ easing: EASE_OUT,
+ keyframes: [
+ { opacity: 0, transform: 'scale(0.97)' },
+ { opacity: 1, transform: 'scale(1)' },
+ ],
+ },
+};
+
+// 6 · FALL — mirrored Rise. Enters from above; the deck settles to a stop.
+Closing.transition = {
+ duration: 200,
+ exit: {
+ duration: 140,
+ easing: EASE_IN,
+ keyframes: [
+ { opacity: 1, transform: 'translateY(0)' },
+ { opacity: 0, transform: 'translateY(4px)' },
+ ],
+ },
+ enter: {
+ duration: 200,
+ delay: 80,
+ easing: EASE_OUT,
+ keyframes: [
+ { opacity: 0, transform: 'translateY(-6px)' },
+ { opacity: 1, transform: 'translateY(0)' },
+ ],
+ },
+};
+
+export const meta: SlideMeta = {
+ title: 'On Tasteful Transitions',
+ createdAt: '2026-05-20T06:12:31.353Z',
+};
+
+export default [Cover, Family, ShortDurations, Pause, SmallMagnitudes, Closing] satisfies Page[];
diff --git a/packages/cli/template/.agents/skills/slide-authoring/SKILL.md b/packages/cli/template/.agents/skills/slide-authoring/SKILL.md
index e6b472f7..e0edd272 100644
--- a/packages/cli/template/.agents/skills/slide-authoring/SKILL.md
+++ b/packages/cli/template/.agents/skills/slide-authoring/SKILL.md
@@ -297,6 +297,174 @@ const Footer = () => {
`current` is 1-indexed (matches what readers see) and `total` is the slide's page count. The hook works in every render context (main viewer, thumbnails, overview grid, present mode, presenter window, HTML/PDF export) — the same `` JSX is correct everywhere. Call the hook inside a component that's used **per page**; don't try to call it at module top level.
+## Page transitions
+
+The framework can run an enter/exit animation between every slide change. There's **no default** — pages snap unless you declare a `SlideTransition`. Snap-swap is a perfectly tasteful default; only opt in when motion adds something.
+
+`prefers-reduced-motion: reduce` is honored automatically. You don't write a fallback.
+
+### Contract
+
+Module-level for the whole deck; per-page to override. The **incoming page wins**: navigating A → B uses `pages[B].transition ?? module.transition`. Its `exit` plays on A, its `enter` plays on B. Going back B → A uses A's transition.
+
+```tsx
+import type { Page, SlideTransition } from '@open-slide/core';
+
+const Cover: Page = () => ;
+const Body: Page = () => ;
+
+// Module-level default — every page inherits unless it overrides.
+export const transition: SlideTransition = { /* … */ };
+
+// Per-page override.
+Cover.transition = { /* … */ };
+
+export default [Cover, Body];
+```
+
+```ts
+type TransitionPhase = {
+ keyframes: Keyframe[] | PropertyIndexedKeyframes; // WAAPI keyframes
+ duration?: number; // ms (falls back to top-level duration)
+ easing?: string; // CSS easing
+ delay?: number; // ms — use to overlap exit + enter
+};
+type SlideTransition = {
+ duration: number; // top-level fallback
+ easing?: string; // top-level fallback
+ enter?: TransitionPhase; // runs on incoming page
+ exit?: TransitionPhase; // runs on outgoing page
+};
+```
+
+The framework also exposes `--osd-dir` (`1` forward, `-1` backward) and `data-osd-dir` on the wrapper, so a single keyframe can mirror direction without a JS callback.
+
+### Design principles (hold the line)
+
+The single loudest signal of "made in PowerPoint" is six different transitions in one deck. Restraint is the rhythm.
+
+- **Pick one DNA, hold it across the deck.** Same duration band, same easing pair, same out-then-in stagger. Variation lives only in *which property* gets the small nudge — Y, X, opacity, scale, blur.
+- **Duration: 140–280 ms.** Exit 140–180 ms, enter 200–280 ms, enter delayed ~80 ms so they overlap but don't fight. Past 350 ms is video-editor territory; reserve for genuine state changes.
+- **Magnitude ceiling: 12 px or 3% scale.** A 6 px Y-rise reads as "next thought." A 1920 px translateX reads as "different document." Premium tools move barely enough to register.
+- **Opacity is always part of it.** Pure-transform transitions look stiff; pure-opacity transitions are the safest possible default.
+- **Easing: ease-in for exit, ease-out for enter.** `cubic-bezier(0.4, 0, 1, 1)` going out, `cubic-bezier(0, 0, 0.2, 1)` coming in. Never `linear` (feels like a slideshow). Reserve symmetric `ease-in-out` for state-anchored morphs only.
+
+### Tasteful family — six members, one DNA
+
+Use this set as a starting point. Pick one as the deck's house transition; optionally reserve a second for hero/cover slides and a third for genuine section breaks. The CSS-`calc` + `--osd-dir` trick lets a single definition mirror itself on backward navigation when needed.
+
+```tsx
+const EASE_OUT = 'cubic-bezier(0, 0, 0.2, 1)';
+const EASE_IN = 'cubic-bezier(0.4, 0, 1, 1)';
+
+// RISE — house quiet. 6 px Y. Use as module default.
+export const transition: SlideTransition = {
+ duration: 200,
+ exit: { duration: 140, easing: EASE_IN,
+ keyframes: [
+ { opacity: 1, transform: 'translateY(0)' },
+ { opacity: 0, transform: 'translateY(-4px)' },
+ ] },
+ enter: { duration: 200, delay: 80, easing: EASE_OUT,
+ keyframes: [
+ { opacity: 0, transform: 'translateY(6px)' },
+ { opacity: 1, transform: 'translateY(0)' },
+ ] },
+};
+
+// DISSOLVE — pure opacity. The quietest possible.
+const dissolve: SlideTransition = {
+ duration: 240,
+ exit: { duration: 200, easing: EASE_IN,
+ keyframes: [{ opacity: 1 }, { opacity: 0 }] },
+ enter: { duration: 240, delay: 40, easing: EASE_OUT,
+ keyframes: [{ opacity: 0 }, { opacity: 1 }] },
+};
+
+// SETTLE — cover-grade. Rise + a hair of blur on enter only.
+Cover.transition = {
+ duration: 280,
+ exit: { duration: 160, easing: EASE_IN,
+ keyframes: [
+ { opacity: 1, transform: 'translateY(0)' },
+ { opacity: 0, transform: 'translateY(-6px)' },
+ ] },
+ enter: { duration: 280, delay: 100, easing: EASE_OUT,
+ keyframes: [
+ { opacity: 0, transform: 'translateY(12px)', filter: 'blur(4px)' },
+ { opacity: 1, transform: 'translateY(0)', filter: 'blur(0)' },
+ ] },
+};
+
+// BLOOM — scale 0.97 → 1, no translate. Materializes in place.
+const bloom: SlideTransition = {
+ duration: 240,
+ exit: { duration: 160, easing: EASE_IN,
+ keyframes: [
+ { opacity: 1, transform: 'scale(1)' },
+ { opacity: 0, transform: 'scale(1.01)' },
+ ] },
+ enter: { duration: 240, delay: 80, easing: EASE_OUT,
+ keyframes: [
+ { opacity: 0, transform: 'scale(0.97)' },
+ { opacity: 1, transform: 'scale(1)' },
+ ] },
+};
+
+// FALL — mirrored Rise. Incoming page comes down from above.
+const fall: SlideTransition = {
+ duration: 200,
+ exit: { duration: 140, easing: EASE_IN,
+ keyframes: [
+ { opacity: 1, transform: 'translateY(0)' },
+ { opacity: 0, transform: 'translateY(4px)' },
+ ] },
+ enter: { duration: 200, delay: 80, easing: EASE_OUT,
+ keyframes: [
+ { opacity: 0, transform: 'translateY(-6px)' },
+ { opacity: 1, transform: 'translateY(0)' },
+ ] },
+};
+
+// BREATH — section break. Exit fully, hold 120 ms, then enter.
+// Reserve for genuine chapter dividers; use at most 1–2× per deck.
+const breath: SlideTransition = {
+ duration: 460,
+ exit: { duration: 180, easing: EASE_IN,
+ keyframes: [{ opacity: 1 }, { opacity: 0 }] },
+ enter: { duration: 240, delay: 300, easing: EASE_OUT,
+ keyframes: [
+ { opacity: 0, transform: 'translateY(8px)' },
+ { opacity: 1, transform: 'translateY(0)' },
+ ] },
+};
+```
+
+All six share the same DNA — they only differ in which property carries the small nudge. The reader perceives variety; the eye still reads one consistent hand.
+
+### Direction-aware keyframes (use sparingly)
+
+Most tasteful tools don't mirror on backward navigation. When you genuinely need to — e.g. a horizontal slide that should reverse — use `--osd-dir` inside `calc()`:
+
+```tsx
+{ transform: 'translateX(calc(var(--osd-dir, 1) * 8px))' },
+{ transform: 'translateX(0)' },
+```
+
+If you find yourself reaching for this on every transition, you're probably over-designing. Forward = backward is the more refined default.
+
+### Transition anti-patterns
+
+- ❌ Six different transitions across six pages — the single loudest "made in PowerPoint" tell.
+- ❌ `translateX(100%)` slide-from-side — iOS modal / PowerPoint Push; not a slide change.
+- ❌ Aggressive scale-pop (e.g. `0.85 → 1`) + blur — lightbox / photo-viewer vocabulary; implies zooming *into* something.
+- ❌ `clip-path: inset(…)` reveals — After Effects vocabulary; theatrical.
+- ❌ Parallel blur on both layers at once — visual mush; the eye can't fixate.
+- ❌ Duration > 350 ms for a standard slide change — drags.
+- ❌ Translate > 12 px or scale > 3% — reads as rupture, not continuity.
+- ❌ `linear` easing — feels like a slideshow, not a product.
+- ❌ Declaring a transition on every deck. **If you don't have a clear reason, omit it.** Snap-swap is fine.
+
## Repeated elements: component, not `map`
When a page has visually repeated items — cards, logo rows, gallery tiles, list rows, step indicators — **define a small component and instantiate it once per item**. Do **not** render the group with `array.map` over a data array.
@@ -362,6 +530,7 @@ This applies whenever the *visual element* repeats, not whenever the *data* does
- [ ] Visually repeated elements (cards, tiles, logo rows) are rendered as explicit ` ` instances, not via `array.map` over a data list.
- [ ] All imported assets exist on disk — slide-local under `slides//assets/`, or global under `assets/` (imported via `@assets/...`).
- [ ] Every `` corresponds to a real image the user must supply — not decorative filler. If it could be replaced by typography or layout, it should be.
+- [ ] If a `SlideTransition` is declared, every page sits in one family — same duration band (140–280 ms), same easing pair, same out-then-in stagger, magnitude under 12 px / 3%. No six-different-vocabularies decks. When in doubt, omit transitions entirely.
- [ ] Nothing outside `slides//` was edited.
## Anti-patterns
diff --git a/packages/core/skills/slide-authoring/SKILL.md b/packages/core/skills/slide-authoring/SKILL.md
index 86985220..1b7a1819 100644
--- a/packages/core/skills/slide-authoring/SKILL.md
+++ b/packages/core/skills/slide-authoring/SKILL.md
@@ -308,6 +308,174 @@ const Footer = () => {
`current` is 1-indexed (matches what readers see) and `total` is the slide's page count. The hook works in every render context (main viewer, thumbnails, overview grid, present mode, presenter window, HTML/PDF export) — the same `` JSX is correct everywhere. Call the hook inside a component that's used **per page**; don't try to call it at module top level.
+## Page transitions
+
+The framework can run an enter/exit animation between every slide change. There's **no default** — pages snap unless you declare a `SlideTransition`. Snap-swap is a perfectly tasteful default; only opt in when motion adds something.
+
+`prefers-reduced-motion: reduce` is honored automatically. You don't write a fallback.
+
+### Contract
+
+Module-level for the whole deck; per-page to override. The **incoming page wins**: navigating A → B uses `pages[B].transition ?? module.transition`. Its `exit` plays on A, its `enter` plays on B. Going back B → A uses A's transition.
+
+```tsx
+import type { Page, SlideTransition } from '@open-slide/core';
+
+const Cover: Page = () => ;
+const Body: Page = () => ;
+
+// Module-level default — every page inherits unless it overrides.
+export const transition: SlideTransition = { /* … */ };
+
+// Per-page override.
+Cover.transition = { /* … */ };
+
+export default [Cover, Body];
+```
+
+```ts
+type TransitionPhase = {
+ keyframes: Keyframe[] | PropertyIndexedKeyframes; // WAAPI keyframes
+ duration?: number; // ms (falls back to top-level duration)
+ easing?: string; // CSS easing
+ delay?: number; // ms — use to overlap exit + enter
+};
+type SlideTransition = {
+ duration: number; // top-level fallback
+ easing?: string; // top-level fallback
+ enter?: TransitionPhase; // runs on incoming page
+ exit?: TransitionPhase; // runs on outgoing page
+};
+```
+
+The framework also exposes `--osd-dir` (`1` forward, `-1` backward) and `data-osd-dir` on the wrapper, so a single keyframe can mirror direction without a JS callback.
+
+### Design principles (hold the line)
+
+The single loudest signal of "made in PowerPoint" is six different transitions in one deck. Restraint is the rhythm.
+
+- **Pick one DNA, hold it across the deck.** Same duration band, same easing pair, same out-then-in stagger. Variation lives only in *which property* gets the small nudge — Y, X, opacity, scale, blur.
+- **Duration: 140–280 ms.** Exit 140–180 ms, enter 200–280 ms, enter delayed ~80 ms so they overlap but don't fight. Past 350 ms is video-editor territory; reserve for genuine state changes.
+- **Magnitude ceiling: 12 px or 3% scale.** A 6 px Y-rise reads as "next thought." A 1920 px translateX reads as "different document." Premium tools move barely enough to register.
+- **Opacity is always part of it.** Pure-transform transitions look stiff; pure-opacity transitions are the safest possible default.
+- **Easing: ease-in for exit, ease-out for enter.** `cubic-bezier(0.4, 0, 1, 1)` going out, `cubic-bezier(0, 0, 0.2, 1)` coming in. Never `linear` (feels like a slideshow). Reserve symmetric `ease-in-out` for state-anchored morphs only.
+
+### Tasteful family — six members, one DNA
+
+Use this set as a starting point. Pick one as the deck's house transition; optionally reserve a second for hero/cover slides and a third for genuine section breaks. The CSS-`calc` + `--osd-dir` trick lets a single definition mirror itself on backward navigation when needed.
+
+```tsx
+const EASE_OUT = 'cubic-bezier(0, 0, 0.2, 1)';
+const EASE_IN = 'cubic-bezier(0.4, 0, 1, 1)';
+
+// RISE — house quiet. 6 px Y. Use as module default.
+export const transition: SlideTransition = {
+ duration: 200,
+ exit: { duration: 140, easing: EASE_IN,
+ keyframes: [
+ { opacity: 1, transform: 'translateY(0)' },
+ { opacity: 0, transform: 'translateY(-4px)' },
+ ] },
+ enter: { duration: 200, delay: 80, easing: EASE_OUT,
+ keyframes: [
+ { opacity: 0, transform: 'translateY(6px)' },
+ { opacity: 1, transform: 'translateY(0)' },
+ ] },
+};
+
+// DISSOLVE — pure opacity. The quietest possible.
+const dissolve: SlideTransition = {
+ duration: 240,
+ exit: { duration: 200, easing: EASE_IN,
+ keyframes: [{ opacity: 1 }, { opacity: 0 }] },
+ enter: { duration: 240, delay: 40, easing: EASE_OUT,
+ keyframes: [{ opacity: 0 }, { opacity: 1 }] },
+};
+
+// SETTLE — cover-grade. Rise + a hair of blur on enter only.
+Cover.transition = {
+ duration: 280,
+ exit: { duration: 160, easing: EASE_IN,
+ keyframes: [
+ { opacity: 1, transform: 'translateY(0)' },
+ { opacity: 0, transform: 'translateY(-6px)' },
+ ] },
+ enter: { duration: 280, delay: 100, easing: EASE_OUT,
+ keyframes: [
+ { opacity: 0, transform: 'translateY(12px)', filter: 'blur(4px)' },
+ { opacity: 1, transform: 'translateY(0)', filter: 'blur(0)' },
+ ] },
+};
+
+// BLOOM — scale 0.97 → 1, no translate. Materializes in place.
+const bloom: SlideTransition = {
+ duration: 240,
+ exit: { duration: 160, easing: EASE_IN,
+ keyframes: [
+ { opacity: 1, transform: 'scale(1)' },
+ { opacity: 0, transform: 'scale(1.01)' },
+ ] },
+ enter: { duration: 240, delay: 80, easing: EASE_OUT,
+ keyframes: [
+ { opacity: 0, transform: 'scale(0.97)' },
+ { opacity: 1, transform: 'scale(1)' },
+ ] },
+};
+
+// FALL — mirrored Rise. Incoming page comes down from above.
+const fall: SlideTransition = {
+ duration: 200,
+ exit: { duration: 140, easing: EASE_IN,
+ keyframes: [
+ { opacity: 1, transform: 'translateY(0)' },
+ { opacity: 0, transform: 'translateY(4px)' },
+ ] },
+ enter: { duration: 200, delay: 80, easing: EASE_OUT,
+ keyframes: [
+ { opacity: 0, transform: 'translateY(-6px)' },
+ { opacity: 1, transform: 'translateY(0)' },
+ ] },
+};
+
+// BREATH — section break. Exit fully, hold 120 ms, then enter.
+// Reserve for genuine chapter dividers; use at most 1–2× per deck.
+const breath: SlideTransition = {
+ duration: 460,
+ exit: { duration: 180, easing: EASE_IN,
+ keyframes: [{ opacity: 1 }, { opacity: 0 }] },
+ enter: { duration: 240, delay: 300, easing: EASE_OUT,
+ keyframes: [
+ { opacity: 0, transform: 'translateY(8px)' },
+ { opacity: 1, transform: 'translateY(0)' },
+ ] },
+};
+```
+
+All six share the same DNA — they only differ in which property carries the small nudge. The reader perceives variety; the eye still reads one consistent hand.
+
+### Direction-aware keyframes (use sparingly)
+
+Most tasteful tools don't mirror on backward navigation. When you genuinely need to — e.g. a horizontal slide that should reverse — use `--osd-dir` inside `calc()`:
+
+```tsx
+{ transform: 'translateX(calc(var(--osd-dir, 1) * 8px))' },
+{ transform: 'translateX(0)' },
+```
+
+If you find yourself reaching for this on every transition, you're probably over-designing. Forward = backward is the more refined default.
+
+### Transition anti-patterns
+
+- ❌ Six different transitions across six pages — the single loudest "made in PowerPoint" tell.
+- ❌ `translateX(100%)` slide-from-side — iOS modal / PowerPoint Push; not a slide change.
+- ❌ Aggressive scale-pop (e.g. `0.85 → 1`) + blur — lightbox / photo-viewer vocabulary; implies zooming *into* something.
+- ❌ `clip-path: inset(…)` reveals — After Effects vocabulary; theatrical.
+- ❌ Parallel blur on both layers at once — visual mush; the eye can't fixate.
+- ❌ Duration > 350 ms for a standard slide change — drags.
+- ❌ Translate > 12 px or scale > 3% — reads as rupture, not continuity.
+- ❌ `linear` easing — feels like a slideshow, not a product.
+- ❌ Declaring a transition on every deck. **If you don't have a clear reason, omit it.** Snap-swap is fine.
+
## Repeated elements: component, not `map`
When a page has visually repeated items — cards, logo rows, gallery tiles, list rows, step indicators — **define a small component and instantiate it once per item**. Do **not** render the group with `array.map` over a data array.
@@ -373,6 +541,7 @@ This applies whenever the *visual element* repeats, not whenever the *data* does
- [ ] Visually repeated elements (cards, tiles, logo rows) are rendered as explicit ` ` instances, not via `array.map` over a data list.
- [ ] All imported assets exist on disk — slide-local under `slides//assets/`, or global under `assets/` (imported via `@assets/...`).
- [ ] Every `` corresponds to a real image the user must supply — not decorative filler. If it could be replaced by typography or layout, it should be.
+- [ ] If a `SlideTransition` is declared, every page sits in one family — same duration band (140–280 ms), same easing pair, same out-then-in stagger, magnitude under 12 px / 3%. No six-different-vocabularies decks. When in doubt, omit transitions entirely.
- [ ] Nothing outside `slides//` was edited.
## Anti-patterns
diff --git a/packages/core/src/app/components/player.tsx b/packages/core/src/app/components/player.tsx
index 5a894b60..ea70abc8 100644
--- a/packages/core/src/app/components/player.tsx
+++ b/packages/core/src/app/components/player.tsx
@@ -2,8 +2,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useWheelPageNavigation } from '@/lib/use-wheel-page-navigation';
import { cn } from '@/lib/utils';
import type { DesignSystem } from '../lib/design';
-import { SlidePageProvider } from '../lib/page-context';
import type { Page } from '../lib/sdk';
+import type { SlideTransition } from '../lib/transition';
+import { usePrefersReducedMotion } from '../lib/use-prefers-reduced-motion';
import { PresentBlackoutOverlay } from './present/blackout-overlay';
import { PresentControlBar } from './present/control-bar';
import { PresentHelpOverlay } from './present/help-overlay';
@@ -20,6 +21,7 @@ import {
} from './present/use-presenter-channel';
import { useTouchSwipe } from './present/use-touch-swipe';
import { SlideCanvas } from './slide-canvas';
+import { SlideTransitionLayer } from './slide-transition-layer';
const IDLE_HIDE_MS = 2000;
const BAR_HOTZONE_PX = 160;
@@ -27,6 +29,7 @@ const BAR_HOTZONE_PX = 160;
type Props = {
pages: Page[];
design?: DesignSystem;
+ transition?: SlideTransition;
index: number;
onIndexChange: (index: number) => void;
onExit: () => void;
@@ -44,6 +47,7 @@ type Props = {
export function Player({
pages,
design,
+ transition,
index,
onIndexChange,
onExit,
@@ -52,6 +56,7 @@ export function Player({
slideId,
fullscreen = true,
}: Props) {
+ const prefersReducedMotion = usePrefersReducedMotion();
const rootRef = useRef(null);
// Mirrored as state so descendants portaling *into* the player subtree
// (tooltips, popovers — the body is outside the fullscreen tree) re-render
@@ -284,8 +289,6 @@ export function Player({
const hideCursor =
controls && (laser || keyboardDriven || (idle && !overlayActive && !pointerNearBottom));
- const PageComp = pages[index];
-
return (
- {PageComp ? (
-
-
-
- ) : null}
+
(null);
+ const [direction, setDirection] = useState('forward');
+
+ const wrapperRef = useRef(null);
+ const outgoingLayerRef = useRef(null);
+ const incomingLayerRef = useRef(null);
+ const animsRef = useRef([]);
+ const currentRef = useRef(current);
+ currentRef.current = current;
+
+ useEffect(() => {
+ if (index === currentRef.current) return;
+
+ const prev = currentRef.current;
+ const next = index;
+
+ // Interrupt: cancel in-flight animations. The previously-incoming page
+ // (currentRef) becomes the new outgoing; React reuses its DOM slot.
+ for (const a of animsRef.current) {
+ try {
+ a.cancel();
+ } catch {}
+ }
+ animsRef.current = [];
+
+ const transition = resolveTransition(pages, next, moduleTransition);
+ if (disabled || !transition) {
+ setCurrent(next);
+ setOutgoing(null);
+ return;
+ }
+
+ setDirection(next > prev ? 'forward' : 'backward');
+ setOutgoing(prev);
+ setCurrent(next);
+ }, [index, pages, moduleTransition, disabled]);
+
+ useEffect(() => {
+ if (outgoing === null) return;
+
+ const transition = resolveTransition(pages, current, moduleTransition);
+ const wrapper = wrapperRef.current;
+ const out = outgoingLayerRef.current;
+ const inc = incomingLayerRef.current;
+ if (!transition || !wrapper || !out || !inc) {
+ setOutgoing(null);
+ return;
+ }
+
+ wrapper.dataset.osdDir = direction;
+ wrapper.style.setProperty('--osd-dir', direction === 'forward' ? '1' : '-1');
+
+ const easing = transition.easing ?? DEFAULT_EASING;
+ const duration = transition.duration;
+
+ const anims: Animation[] = [];
+ const exitAnim = runPhase(out, transition.exit, duration, easing);
+ const enterAnim = runPhase(inc, transition.enter, duration, easing);
+ if (exitAnim) anims.push(exitAnim);
+ if (enterAnim) anims.push(enterAnim);
+ animsRef.current = anims;
+
+ if (anims.length === 0) {
+ setOutgoing(null);
+ return;
+ }
+
+ let cancelled = false;
+ Promise.all(anims.map((a) => a.finished))
+ .then(() => {
+ if (cancelled) return;
+ animsRef.current = [];
+ setOutgoing(null);
+ })
+ .catch(() => {
+ // AbortError fires when we cancel mid-flight on an interrupt.
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [outgoing, current, direction, pages, moduleTransition]);
+
+ useEffect(() => {
+ return () => {
+ for (const a of animsRef.current) {
+ try {
+ a.cancel();
+ } catch {}
+ }
+ animsRef.current = [];
+ };
+ }, []);
+
+ const CurrentPage = pages[current];
+ const OutgoingPage = outgoing !== null ? pages[outgoing] : null;
+
+ return (
+
+ {OutgoingPage && outgoing !== null ? (
+
+
+
+
+
+ ) : null}
+ {CurrentPage ? (
+
+
+
+
+
+ ) : null}
+
+ );
+}
diff --git a/packages/core/src/app/lib/sdk.ts b/packages/core/src/app/lib/sdk.ts
index 58347b72..082e3c46 100644
--- a/packages/core/src/app/lib/sdk.ts
+++ b/packages/core/src/app/lib/sdk.ts
@@ -1,7 +1,8 @@
import type { ComponentType } from 'react';
import type { DesignSystem } from './design.ts';
+import type { SlideTransition } from './transition.ts';
-export type Page = ComponentType;
+export type Page = ComponentType & { transition?: SlideTransition };
export type SlideMeta = {
title?: string;
@@ -16,6 +17,7 @@ export type SlideModule = {
design?: DesignSystem;
// Index-aligned with `default`.
notes?: (string | undefined)[];
+ transition?: SlideTransition;
};
export type FolderIcon = { type: 'emoji'; value: string } | { type: 'color'; value: string };
diff --git a/packages/core/src/app/lib/transition.ts b/packages/core/src/app/lib/transition.ts
new file mode 100644
index 00000000..7f35baf0
--- /dev/null
+++ b/packages/core/src/app/lib/transition.ts
@@ -0,0 +1,23 @@
+import type { Page } from './sdk';
+
+export type TransitionPhase = {
+ keyframes: Keyframe[] | PropertyIndexedKeyframes;
+ easing?: string;
+ duration?: number;
+ delay?: number;
+};
+
+export type SlideTransition = {
+ duration: number;
+ easing?: string;
+ enter?: TransitionPhase;
+ exit?: TransitionPhase;
+};
+
+export function resolveTransition(
+ pages: Page[],
+ index: number,
+ moduleDefault?: SlideTransition,
+): SlideTransition | undefined {
+ return pages[index]?.transition ?? moduleDefault;
+}
diff --git a/packages/core/src/app/lib/use-prefers-reduced-motion.ts b/packages/core/src/app/lib/use-prefers-reduced-motion.ts
new file mode 100644
index 00000000..40161b23
--- /dev/null
+++ b/packages/core/src/app/lib/use-prefers-reduced-motion.ts
@@ -0,0 +1,19 @@
+import { useEffect, useState } from 'react';
+
+const QUERY = '(prefers-reduced-motion: reduce)';
+
+export function usePrefersReducedMotion(): boolean {
+ const [reduce, setReduce] = useState(() => {
+ if (typeof window === 'undefined') return false;
+ return window.matchMedia(QUERY).matches;
+ });
+
+ useEffect(() => {
+ const mql = window.matchMedia(QUERY);
+ const onChange = (e: MediaQueryListEvent) => setReduce(e.matches);
+ mql.addEventListener('change', onChange);
+ return () => mql.removeEventListener('change', onChange);
+ }, []);
+
+ return reduce;
+}
diff --git a/packages/core/src/app/routes/slide.tsx b/packages/core/src/app/routes/slide.tsx
index a699c302..e7a7b508 100644
--- a/packages/core/src/app/routes/slide.tsx
+++ b/packages/core/src/app/routes/slide.tsx
@@ -48,12 +48,13 @@ import { NotesDrawer } from '../components/notes-drawer';
import { PdfProgressToast } from '../components/pdf-progress-toast';
import { openPresenterWindow, Player } from '../components/player';
import { SlideCanvas } from '../components/slide-canvas';
+import { SlideTransitionLayer } from '../components/slide-transition-layer';
import { type ThumbnailActions, ThumbnailRail } from '../components/thumbnail-rail';
import { exportSlideAsHtml } from '../lib/export-html';
import { exportSlideAsPdf, isSafari } from '../lib/export-pdf';
import { remapNotesSessionCacheAfterReorder } from '../lib/inspector/use-notes';
-import { SlidePageProvider } from '../lib/page-context';
import type { SlideModule } from '../lib/sdk';
+import { usePrefersReducedMotion } from '../lib/use-prefers-reduced-motion';
import { useSlideModule } from '../lib/use-slide-module';
const { showSlideUi, showSlideBrowser, allowHtmlDownload } = config.build;
@@ -76,6 +77,7 @@ export function Slide() {
const { renameSlide } = useFolders();
const slideViewportRef = useRef(null);
const t = useLocale();
+ const prefersReducedMotion = usePrefersReducedMotion();
const modulePages = useMemo(() => slide?.default ?? [], [slide]);
const [pages, setPages] = useState(modulePages);
@@ -325,6 +327,7 @@ export function Slide() {
setPlayMode(null)}
@@ -335,7 +338,6 @@ export function Slide() {
);
}
- const CurrentPage = pages[index];
const title = slide.meta?.title ?? slideId;
return (
@@ -585,9 +587,13 @@ export function Slide() {
canNext={index < pageCount - 1}
/>
-
-
-
+
goTo(index - 1)}
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index d4610d6c..77d77fee 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -10,5 +10,6 @@ export { cssVarsToString, defaultDesign, designToCssVars } from './app/lib/desig
export { useSlidePageNumber } from './app/lib/page-context.tsx';
export type { Page, SlideMeta, SlideModule } from './app/lib/sdk.ts';
export { CANVAS_HEIGHT, CANVAS_WIDTH } from './app/lib/sdk.ts';
+export type { SlideTransition, TransitionPhase } from './app/lib/transition.ts';
export type { OpenSlideConfig } from './config.ts';
export type { Locale, Plural } from './locale/types.ts';