From 78d7f54f0762f787a8cefe5b7d9a7341a026c971 Mon Sep 17 00:00:00 2001
From: Claude
Date: Wed, 20 May 2026 05:55:34 +0000
Subject: [PATCH 1/7] feat(core): add SlideTransition API for page-transition
animations
Lets slide authors declare per-page enter/exit animations as WAAPI
keyframes, with an optional module-level default. The framework owns
the transition lifecycle (two-layer stack, direction-aware --osd-dir,
interrupt cancellation, prefers-reduced-motion fallback) so authors
can focus on the visual. Internal SlideTransitionLayer drives both
the Player and the editor viewer.
---
.changeset/slide-transition-api.md | 5 +
packages/core/src/app/components/player.tsx | 21 ++-
.../app/components/slide-transition-layer.tsx | 149 ++++++++++++++++++
packages/core/src/app/lib/sdk.ts | 4 +-
packages/core/src/app/lib/transition.ts | 22 +++
.../src/app/lib/use-prefers-reduced-motion.ts | 19 +++
packages/core/src/app/routes/slide.tsx | 16 +-
packages/core/src/index.ts | 1 +
8 files changed, 223 insertions(+), 14 deletions(-)
create mode 100644 .changeset/slide-transition-api.md
create mode 100644 packages/core/src/app/components/slide-transition-layer.tsx
create mode 100644 packages/core/src/app/lib/transition.ts
create mode 100644 packages/core/src/app/lib/use-prefers-reduced-motion.ts
diff --git a/.changeset/slide-transition-api.md b/.changeset/slide-transition-api.md
new file mode 100644
index 00000000..255dd5ff
--- /dev/null
+++ b/.changeset/slide-transition-api.md
@@ -0,0 +1,5 @@
+---
+'@open-slide/core': minor
+---
+
+Add SlideTransition API for declaring per-page page-transition animations.
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}
+
);
const SmallMagnitudes: Page = () => (
);
@@ -261,26 +282,71 @@ const Closing: Page = () => (
paddingTop: 32,
}}
>
- The framework gives you the canvas and the lifecycle. Pick one quiet transition, and let the
- writing carry the deck.
+ Six different moves, none of which announce themselves. The reader perceives variety; the
+ eye still reads one consistent hand.
- 06 / 06
+ 06 · fall← to revisit
);
-// House transition — applied to every page that doesn't override.
-// Out-then-in with overlap: exit starts immediately, enter delays 80ms.
-// Tiny rise (4-6px), short durations, asymmetric easing per direction.
+// 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,
- easing: 'cubic-bezier(0, 0, 0.2, 1)',
exit: {
duration: 140,
- easing: 'cubic-bezier(0.4, 0, 1, 1)',
+ easing: EASE_IN,
keyframes: [
{ opacity: 1, transform: 'translateY(0)' },
{ opacity: 0, transform: 'translateY(-4px)' },
@@ -289,6 +355,7 @@ export const transition: SlideTransition = {
enter: {
duration: 200,
delay: 80,
+ easing: EASE_OUT,
keyframes: [
{ opacity: 0, transform: 'translateY(6px)' },
{ opacity: 1, transform: 'translateY(0)' },
@@ -296,43 +363,64 @@ export const transition: SlideTransition = {
},
};
-// Cover variant — slightly more generous rise + a touch of blur on enter only.
-// Reserved for hero/title pages.
-Cover.transition = {
- duration: 280,
- easing: 'cubic-bezier(0.32, 0.72, 0, 1)',
+// 4 · BREATH — section divider. Exit fully, hold 120 ms, then enter.
+Pause.transition = {
+ duration: 460,
exit: {
- duration: 160,
- easing: 'cubic-bezier(0.4, 0, 1, 1)',
+ 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)' },
- { opacity: 0, transform: 'translateY(-6px)' },
+ ],
+ },
+};
+
+// 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: 280,
- delay: 100,
+ duration: 240,
+ delay: 80,
+ easing: EASE_OUT,
keyframes: [
- { opacity: 0, transform: 'translateY(12px)', filter: 'blur(4px)' },
- { opacity: 1, transform: 'translateY(0)', filter: 'blur(0)' },
+ { opacity: 0, transform: 'scale(0.97)' },
+ { opacity: 1, transform: 'scale(1)' },
],
},
};
-// Section-break variant — exit fully, hold for a beat, then enter.
-// Reserved for genuine chapter changes. Used once in this deck.
-Pause.transition = {
- duration: 460,
- easing: 'cubic-bezier(0.4, 0, 0.2, 1)',
+// 6 · FALL — mirrored Rise. Enters from above; the deck settles to a stop.
+Closing.transition = {
+ duration: 200,
exit: {
- duration: 180,
- keyframes: [{ opacity: 1 }, { opacity: 0 }],
+ duration: 140,
+ easing: EASE_IN,
+ keyframes: [
+ { opacity: 1, transform: 'translateY(0)' },
+ { opacity: 0, transform: 'translateY(4px)' },
+ ],
},
enter: {
- duration: 240,
- delay: 300,
+ duration: 200,
+ delay: 80,
+ easing: EASE_OUT,
keyframes: [
- { opacity: 0, transform: 'translateY(8px)' },
+ { opacity: 0, transform: 'translateY(-6px)' },
{ opacity: 1, transform: 'translateY(0)' },
],
},
@@ -343,4 +431,4 @@ export const meta: SlideMeta = {
createdAt: '2026-05-20T06:12:31.353Z',
};
-export default [Cover, OneCurve, ShortDurations, Pause, SmallMagnitudes, Closing] satisfies Page[];
+export default [Cover, Family, ShortDurations, Pause, SmallMagnitudes, Closing] satisfies Page[];
From fcf1ff399248d09e2a38cb6768c82ab59bdd0c9c Mon Sep 17 00:00:00 2001
From: Claude
Date: Wed, 20 May 2026 07:55:56 +0000
Subject: [PATCH 5/7] docs(skills): document SlideTransition API with tasteful
examples
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Add a Page Transitions section to the slide-authoring skill in both
the core source and the CLI template snapshot. Covers the API
contract (per-page vs module-level, incoming-page-wins, --osd-dir
direction hook), the design principles (one DNA per deck, 140-280ms
duration band, 12px / 3% magnitude ceiling, ease-in exit / ease-out
enter, out-then-in stagger), and a six-member family of tasteful
defaults — rise, dissolve, settle, bloom, fall, breath — as paste-
ready few-shot examples.
Adds a self-review checklist item and a dedicated anti-pattern list
(translateX 100%, scale-pop, clip-path wipe, parallel blur, linear
easing, durations past 350ms, six-different-transitions decks).
---
.changeset/slide-transition-skill-docs.md | 5 +
.../.agents/skills/slide-authoring/SKILL.md | 169 ++++++++++++++++++
packages/core/skills/slide-authoring/SKILL.md | 169 ++++++++++++++++++
3 files changed, 343 insertions(+)
create mode 100644 .changeset/slide-transition-skill-docs.md
diff --git a/.changeset/slide-transition-skill-docs.md b/.changeset/slide-transition-skill-docs.md
new file mode 100644
index 00000000..704266a3
--- /dev/null
+++ b/.changeset/slide-transition-skill-docs.md
@@ -0,0 +1,5 @@
+---
+'@open-slide/cli': patch
+---
+
+Update bundled slide-authoring skill template with page-transition guidance.
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
From c6861c07e0d172cab43c14f896b372532d1c6175 Mon Sep 17 00:00:00 2001
From: Claude
Date: Wed, 20 May 2026 08:01:09 +0000
Subject: [PATCH 6/7] chore: combine SlideTransition changesets into one
---
.changeset/slide-transition-api.md | 3 ++-
.changeset/slide-transition-skill-docs.md | 5 -----
2 files changed, 2 insertions(+), 6 deletions(-)
delete mode 100644 .changeset/slide-transition-skill-docs.md
diff --git a/.changeset/slide-transition-api.md b/.changeset/slide-transition-api.md
index 255dd5ff..4f83ea06 100644
--- a/.changeset/slide-transition-api.md
+++ b/.changeset/slide-transition-api.md
@@ -1,5 +1,6 @@
---
'@open-slide/core': minor
+'@open-slide/cli': patch
---
-Add SlideTransition API for declaring per-page page-transition animations.
+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/.changeset/slide-transition-skill-docs.md b/.changeset/slide-transition-skill-docs.md
deleted file mode 100644
index 704266a3..00000000
--- a/.changeset/slide-transition-skill-docs.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'@open-slide/cli': patch
----
-
-Update bundled slide-authoring skill template with page-transition guidance.
From 5bb535becbc0b380eb47f723d3bcc1fdd26053da Mon Sep 17 00:00:00 2001
From: Yiwei Ho
Date: Sun, 24 May 2026 19:01:45 +0800
Subject: [PATCH 7/7] demo: add maximal transitions deck showcasing CSS-only
effects
Iris (clip-path), 3D flip (perspective + rotateY), chromatic glitch
(stacked drop-shadow + steps easing), warp (blur + scale), shear sweep
(translate + skewX), and portal (rotate + scale collapse). Sits opposite
the restrained "On Tasteful Transitions" deck.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
.../slide-transitions-maximal/index.tsx | 558 ++++++++++++++++++
1 file changed, 558 insertions(+)
create mode 100644 apps/demo/slides/slide-transitions-maximal/index.tsx
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.
+
+ 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.
+