@honeydeck/honeydeck 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -5,7 +5,7 @@ This package publishes the scoped public `@honeydeck/honeydeck` npm package. It
5
5
  ## Rules
6
6
 
7
7
  - Keep public import paths as `@honeydeck/honeydeck/...`.
8
- - `Readme.md` is the compact package README and links to the public docs site. Reader-facing docs live in `packages/docs/content/docs`.
8
+ - `Readme.md` is the compact package README and links to the public docs site. Reader-facing docs live in `packages/docs/content/docs`; update those canonical docs for user-facing behavior changes.
9
9
  - Built-in runtime reference pages cover project-specific theme tokens, active layouts, and built-in component docs. They do not render public docs in-deck.
10
10
  - Specs, `DEVELOPMENT.md`, and skills must remain included in npm package contents.
11
11
  - Use suffixed `lucide-react` icon exports.
@@ -16,7 +16,9 @@ Defined in the first frontmatter block of the deck entry file (before any slide
16
16
  | `colorMode` | `"system" \| "light" \| "dark"` | `"system"` | Browser color mode |
17
17
  | `pdfColorMode` | `"light" \| "dark"` | unset | Optional PDF color mode; when unset, falls back to pinned `colorMode`, then `light` |
18
18
  | `pdfSteps` | `"final" \| "all"` | `"final"` | PDF includes all steps or final state |
19
- | `transition` | `boolean` | `true` | Enable crossfade between slides |
19
+ | `transition` | `string \| boolean` | `fade` | Default named slide transition (`fade`, `none`, `slide-left`, or custom CSS name) |
20
+ | `transitionDuration` | `number` | `200` | Default slide transition duration in milliseconds |
21
+ | `transitionEasing` | `string` | `ease` | Default slide transition timing function |
20
22
  | `magicCodeDuration` | `number` | `800` | Default Magic Code animation duration in milliseconds |
21
23
  | `layouts` | `string` | `""` (built-in) | Layout map module path |
22
24
  | `defaultLayout` | `string` | `"Default"` | Layout used when slide has no `layout:` |
@@ -46,13 +48,17 @@ When pinned, the viewer cannot switch mode from navigation controls.
46
48
 
47
49
  ### Transitions
48
50
 
49
- A subtle crossfade (~200ms) is applied between slides by default:
51
+ A subtle named `fade` transition is applied between slides by default:
50
52
 
51
53
  ```yaml
52
- transition: true # default
53
- transition: false # disable
54
+ transition: fade # default
55
+ transition: none # disable
54
56
  ```
55
57
 
58
+ Legacy booleans still work: `transition: true` maps to `fade`, and `transition: false` maps to `none`.
59
+
60
+ For built-ins, custom CSS transitions, duration, and easing, see [Transitions](transitions.md).
61
+
56
62
  ### Magic Code Duration
57
63
 
58
64
  Magic Code animations default to 800ms. Set a deck-wide default with `magicCodeDuration`:
@@ -70,6 +76,9 @@ Per-slide frontmatter (after `---`):
70
76
  | Property | Type | Default | Description |
71
77
  |----------|------|---------|-------------|
72
78
  | `layout` | `string` | (uses `defaultLayout`) | Layout to use (PascalCase) |
79
+ | `transition` | `string \| boolean` | deck default | Named transition into this slide |
80
+ | `transitionDuration` | `number` | deck default | Transition duration into this slide in milliseconds |
81
+ | `transitionEasing` | `string` | deck default | Transition easing into this slide |
73
82
  | ...layout props | varies | — | Additional props the layout accepts |
74
83
 
75
84
  Example:
@@ -96,6 +105,9 @@ in the first frontmatter block alongside deck-level settings.
96
105
  ---
97
106
  title: "My First Deck"
98
107
  colorMode: system
108
+ transition: fade
109
+ transitionDuration: 200
110
+ transitionEasing: ease
99
111
  layouts: "@honeydeck/honeydeck/layouts"
100
112
  layout: Cover
101
113
  ---
@@ -111,19 +111,22 @@ Deck-level settings live in the first frontmatter block of the deck entry file.
111
111
  | `colorMode` | `"system" \| "light" \| "dark"` | `"system"` | Color mode |
112
112
  | `pdfColorMode` | `"light" \| "dark"` | unset | Optional PDF color mode; falls back to pinned `colorMode`, then `light` |
113
113
  | `pdfSteps` | `"final" \| "all"` | `"final"` | PDF step handling |
114
- | `transition` | `boolean` | `true` | Crossfade between slides |
114
+ | `transition` | `string \| boolean` | `fade` | Named slide transition |
115
115
  | `layouts` | `string` | `""` (built-in) | Custom layout map path |
116
116
  | `defaultLayout` | `string` | `"Default"` | Fallback layout |
117
117
  | `showSlideNumbers` | `boolean` | `false` | Show slide numbers |
118
118
 
119
- Slide-level frontmatter chooses the slide layout and passes layout-specific props.
119
+ Slide-level frontmatter chooses the slide layout, passes layout-specific props, and can override the transition into that slide.
120
120
 
121
121
  | Property | Type | Description |
122
122
  |----------|------|-------------|
123
123
  | `layout` | `string` | Layout name in PascalCase |
124
+ | `transition` | `string \| boolean` | Named transition into this slide |
125
+ | `transitionDuration` | `number` | Transition duration in milliseconds |
126
+ | `transitionEasing` | `string` | Transition timing function |
124
127
  | layout props | varies | Layout-specific fields |
125
128
 
126
- For the full reference, see [Configuration](configuration.md).
129
+ For the full reference, see [Configuration](configuration.md) and [Transitions](transitions.md).
127
130
 
128
131
  ## Core components
129
132
 
package/docs/index.json CHANGED
@@ -45,6 +45,17 @@
45
45
  "file": "configuration.md",
46
46
  "sourcePath": "packages/docs/content/docs/(core)/configuration.mdx"
47
47
  },
48
+ {
49
+ "slug": "transitions",
50
+ "title": "Transitions",
51
+ "description": "Configure built-in and custom slide transitions in Honeydeck.",
52
+ "breadcrumbs": [
53
+ "Core",
54
+ "Transitions"
55
+ ],
56
+ "file": "transitions.md",
57
+ "sourcePath": "packages/docs/content/docs/(core)/transitions.mdx"
58
+ },
48
59
  {
49
60
  "slug": "steps-and-reveals",
50
61
  "title": "Steps and reveals",
@@ -0,0 +1,126 @@
1
+ <!-- Generated from packages/docs/content/docs/(core)/transitions.mdx. Do not edit by hand. -->
2
+
3
+ # Transitions
4
+
5
+ Honeydeck uses named slide transitions. Set a deck-wide default in the first frontmatter block, then override individual slides when needed.
6
+
7
+ ## Deck defaults
8
+
9
+ ```yaml
10
+ ---
11
+ transition: fade
12
+ transitionDuration: 200
13
+ transitionEasing: ease
14
+ ---
15
+ ```
16
+
17
+ | Property | Type | Default | Description |
18
+ |----------|------|---------|-------------|
19
+ | `transition` | `string \| boolean` | `fade` | Named transition for slide changes |
20
+ | `transitionDuration` | `number` | `200` | Duration in milliseconds |
21
+ | `transitionEasing` | `string` | `ease` | CSS timing function |
22
+
23
+ Built-in transition names:
24
+
25
+ - `fade` — default crossfade
26
+ - `none` — no slide transition
27
+ - `slide-left` — horizontal slide, reverse-aware when navigating backward
28
+
29
+ ## Slide overrides
30
+
31
+ Slide frontmatter controls the transition **into that slide**:
32
+
33
+ ```mdx
34
+ ---
35
+ transition: slide-left
36
+ transitionDuration: 500
37
+ transitionEasing: cubic-bezier(0.22, 1, 0.36, 1)
38
+ ---
39
+
40
+ # A slide with a custom entrance
41
+ ```
42
+
43
+ Slides without transition frontmatter use the deck defaults.
44
+
45
+ ## Custom transitions
46
+
47
+ Any transition name that is not built in becomes a CSS hook. For example:
48
+
49
+ ```mdx
50
+ ---
51
+ transition: honey-spin
52
+ transitionDuration: 700
53
+ transitionEasing: cubic-bezier(0.22, 1, 0.36, 1)
54
+ ---
55
+
56
+ # Custom CSS Transition
57
+ ```
58
+
59
+ Honeydeck adds classes to only the entering and exiting slide layers:
60
+
61
+ ```html
62
+ <div class="honeydeck-slide-layer honeydeck-transition-honey-spin honeydeck-transition-enter">
63
+ ```
64
+
65
+ ```html
66
+ <div class="honeydeck-slide-layer honeydeck-transition-honey-spin honeydeck-transition-exit">
67
+ ```
68
+
69
+ Scope your CSS to both the transition name and enter/exit class:
70
+
71
+ ```css
72
+ .honeydeck-slide-layer.honeydeck-transition-honey-spin.honeydeck-transition-enter {
73
+ animation-name: honey-spin-enter;
74
+ animation-duration: var(--honeydeck-transition-duration);
75
+ animation-timing-function: var(--honeydeck-transition-easing);
76
+ animation-fill-mode: both;
77
+ }
78
+
79
+ .honeydeck-slide-layer.honeydeck-transition-honey-spin.honeydeck-transition-exit {
80
+ animation-name: honey-spin-exit;
81
+ animation-duration: var(--honeydeck-transition-duration);
82
+ animation-timing-function: var(--honeydeck-transition-easing);
83
+ animation-fill-mode: both;
84
+ }
85
+ ```
86
+
87
+ ## Reverse-aware CSS
88
+
89
+ Honeydeck provides a direction variable:
90
+
91
+ ```css
92
+ --honeydeck-transition-direction: 1; /* forward */
93
+ --honeydeck-transition-direction: -1; /* backward */
94
+ ```
95
+
96
+ Use it in transform math when your custom transition should reverse with navigation direction:
97
+
98
+ ```css
99
+ @keyframes custom-enter {
100
+ from {
101
+ opacity: 0;
102
+ transform: translateX(calc(40% * var(--honeydeck-transition-direction)));
103
+ }
104
+ to {
105
+ opacity: 1;
106
+ transform: translateX(0);
107
+ }
108
+ }
109
+
110
+ @keyframes custom-exit {
111
+ from {
112
+ opacity: 1;
113
+ transform: translateX(0);
114
+ }
115
+ to {
116
+ opacity: 0;
117
+ transform: translateX(calc(-20% * var(--honeydeck-transition-direction)));
118
+ }
119
+ }
120
+ ```
121
+
122
+ Honeydeck cannot safely invert arbitrary custom keyframes automatically, so reverse behavior is opt-in through CSS variables.
123
+
124
+ ## Reduced motion
125
+
126
+ If the viewer has `prefers-reduced-motion: reduce`, Honeydeck disables slide transition animations.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@honeydeck/honeydeck",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "MDX and React-based presentation framework for AI-friendly slide decks.",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -43,8 +43,9 @@ Generated starter tree includes `package.json`, `deck.mdx`, `styles.css`, `.giti
43
43
 
44
44
  Tutorial-style `deck.mdx` imports `./styles.css` so styling stays explicit and user-controlled. It demonstrates:
45
45
 
46
- - Deck frontmatter, including `colorMode: system`
46
+ - Deck frontmatter, including `colorMode: system`, named transition defaults (`transition`, `transitionDuration`, `transitionEasing`), and layout map hints
47
47
  - Slide separators
48
+ - Built-in slide transitions via deck-level defaults and at least one slide-level `transition:` override
48
49
  - Built-in layouts via per-slide `layout:` (`Default`, `Section`, `TwoCol`, `Cover`, `Blank`)
49
50
  - Code highlighting with step-through
50
51
  - Custom interactive component (`SparkleButton`)
@@ -3,6 +3,10 @@ title: demo-deck
3
3
  description: A clean Honeydeck starter deck
4
4
  # Define the color mode for your slides: light, dark, system.
5
5
  colorMode: system
6
+ # Configure slide transitions. Use "none" to disable.
7
+ transition: fade
8
+ transitionDuration: 200
9
+ transitionEasing: ease
6
10
  # Swap layouts for a custom layout map or the included bee theme (also update css).
7
11
  # - Custom layouts: layouts: "./layouts"
8
12
  # - Bee layouts: layouts: "@honeydeck/honeydeck/layouts/bee"
@@ -44,6 +48,24 @@ All you need is `---` for new slides.
44
48
  You can press <Keyboard>↓</Keyboard> to skip revealed content and jump to the next slide.
45
49
  </Reveal>
46
50
 
51
+ ---
52
+ transition: slide-left
53
+ transitionDuration: 500
54
+ transitionEasing: cubic-bezier(0.22, 1, 0.36, 1)
55
+ ---
56
+
57
+ # Choose slide transitions 🎬
58
+
59
+ Deck-level frontmatter sets the default transition. Slide frontmatter overrides the transition into that slide.
60
+
61
+ ```yaml
62
+ transition: fade
63
+ transitionDuration: 200
64
+ transitionEasing: ease
65
+ ```
66
+
67
+ Use `transition: none` when you want no slide animation.
68
+
47
69
  ---
48
70
 
49
71
  # Grow into React when needed ⚛️
@@ -27,6 +27,7 @@
27
27
 
28
28
  import { config } from "virtual:honeydeck/config";
29
29
  import {
30
+ type CSSProperties,
30
31
  useCallback,
31
32
  useEffect,
32
33
  useLayoutEffect,
@@ -82,6 +83,80 @@ function calcScaleFromElement(el: HTMLElement | null): number | null {
82
83
  return Math.min(el.clientWidth / BASE_WIDTH, el.clientHeight / BASE_HEIGHT);
83
84
  }
84
85
 
86
+ type SlideTransitionState = {
87
+ from: number;
88
+ to: number;
89
+ name: string;
90
+ className: string;
91
+ duration: number;
92
+ easing: string;
93
+ direction: 1 | -1;
94
+ enterFromOpacity: number;
95
+ exitFromOpacity: number;
96
+ };
97
+
98
+ function normalizeTransitionName(value: unknown): string {
99
+ if (value === false) return "none";
100
+ if (value === true || value == null) return "fade";
101
+ if (typeof value !== "string") return "fade";
102
+
103
+ const name = value.trim();
104
+ if (!name) return "fade";
105
+ if (name === "true") return "fade";
106
+ if (name === "false") return "none";
107
+ return name;
108
+ }
109
+
110
+ function normalizeTransitionDuration(value: unknown): number | null {
111
+ if (typeof value !== "number" || !Number.isFinite(value)) return null;
112
+ return Math.max(0, Math.round(value));
113
+ }
114
+
115
+ function normalizeTransitionEasing(value: unknown): string | null {
116
+ if (typeof value !== "string") return null;
117
+ const easing = value.trim();
118
+ return easing ? easing : null;
119
+ }
120
+
121
+ function transitionClassName(name: string): string {
122
+ return name.toLowerCase().replace(/[^a-z0-9_-]+/g, "-");
123
+ }
124
+
125
+ function readLayerOpacity(
126
+ element: HTMLElement | null | undefined,
127
+ ): number | null {
128
+ if (!element) return null;
129
+ const opacity = Number.parseFloat(window.getComputedStyle(element).opacity);
130
+ return Number.isFinite(opacity) ? opacity : null;
131
+ }
132
+
133
+ function getTransitionOptions(
134
+ slideIndex: number,
135
+ ): Omit<
136
+ SlideTransitionState,
137
+ "from" | "to" | "direction" | "enterFromOpacity" | "exitFromOpacity"
138
+ > {
139
+ const frontmatter = slideData[slideIndex]?.frontmatter ?? {};
140
+ const name = normalizeTransitionName(
141
+ frontmatter.transition ?? config.transition,
142
+ );
143
+ const duration =
144
+ normalizeTransitionDuration(frontmatter.transitionDuration) ??
145
+ normalizeTransitionDuration(config.transitionDuration) ??
146
+ 200;
147
+ const easing =
148
+ normalizeTransitionEasing(frontmatter.transitionEasing) ??
149
+ normalizeTransitionEasing(config.transitionEasing) ??
150
+ "ease";
151
+
152
+ return {
153
+ name,
154
+ className: transitionClassName(name),
155
+ duration,
156
+ easing,
157
+ };
158
+ }
159
+
85
160
  // ---------------------------------------------------------------------------
86
161
  // Component
87
162
  // ---------------------------------------------------------------------------
@@ -102,6 +177,7 @@ export function Deck() {
102
177
  });
103
178
  const [effectiveColorMode, setEffectiveColorMode] =
104
179
  useState<EffectiveColorMode>("light");
180
+ const [reducedMotion, setReducedMotion] = useState(false);
105
181
 
106
182
  const route = useRoute();
107
183
  const pointerLayout = usePointerLayout();
@@ -126,6 +202,17 @@ export function Deck() {
126
202
  return () => mq.removeEventListener("change", onChange);
127
203
  }, [colorMode]);
128
204
 
205
+ // ── Reduced motion: disable slide transition animations ────────────────
206
+ useEffect(() => {
207
+ const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
208
+ setReducedMotion(mq.matches);
209
+
210
+ const onChange = (event: MediaQueryListEvent) =>
211
+ setReducedMotion(event.matches);
212
+ mq.addEventListener("change", onChange);
213
+ return () => mq.removeEventListener("change", onChange);
214
+ }, []);
215
+
129
216
  // ── Observe stage size → recalculate scale ─────────────────────────────
130
217
  useEffect(() => {
131
218
  if (route.view !== "slide" && route.view !== "overview") return;
@@ -247,6 +334,73 @@ export function Deck() {
247
334
  });
248
335
  }, [scale, slideZoom]);
249
336
 
337
+ const currentSlide = Math.max(
338
+ 1,
339
+ Math.min(route.slide, slideData.length || 1),
340
+ );
341
+ const previousSlideRef = useRef<number | null>(null);
342
+ const slideLayerRefs = useRef<Record<number, HTMLDivElement | null>>({});
343
+ const [slideTransition, setSlideTransition] =
344
+ useState<SlideTransitionState | null>(null);
345
+ const slideTransitionRef = useRef<SlideTransitionState | null>(null);
346
+ slideTransitionRef.current = slideTransition;
347
+
348
+ useLayoutEffect(() => {
349
+ if (route.view !== "slide") {
350
+ previousSlideRef.current = currentSlide;
351
+ setSlideTransition(null);
352
+ return;
353
+ }
354
+
355
+ if (reducedMotion) {
356
+ previousSlideRef.current = currentSlide;
357
+ setSlideTransition(null);
358
+ return;
359
+ }
360
+
361
+ const previousSlide = previousSlideRef.current;
362
+ if (previousSlide === null) {
363
+ previousSlideRef.current = currentSlide;
364
+ return;
365
+ }
366
+ if (previousSlide === currentSlide) return;
367
+
368
+ const direction: 1 | -1 = currentSlide > previousSlide ? 1 : -1;
369
+ const options = getTransitionOptions(currentSlide - 1);
370
+ previousSlideRef.current = currentSlide;
371
+
372
+ if (options.name === "none" || options.duration === 0) {
373
+ setSlideTransition(null);
374
+ return;
375
+ }
376
+
377
+ const activeTransition = slideTransitionRef.current;
378
+ const isInterruptingFade = activeTransition?.name === "fade";
379
+ const nextTransition = {
380
+ ...options,
381
+ from: previousSlide,
382
+ to: currentSlide,
383
+ direction,
384
+ enterFromOpacity: isInterruptingFade
385
+ ? (readLayerOpacity(slideLayerRefs.current[currentSlide]) ?? 0)
386
+ : 0,
387
+ exitFromOpacity: isInterruptingFade
388
+ ? (readLayerOpacity(slideLayerRefs.current[previousSlide]) ?? 1)
389
+ : 1,
390
+ };
391
+ setSlideTransition(nextTransition);
392
+
393
+ const timeout = window.setTimeout(() => {
394
+ setSlideTransition((active) =>
395
+ active?.from === nextTransition.from && active.to === nextTransition.to
396
+ ? null
397
+ : active,
398
+ );
399
+ }, options.duration);
400
+
401
+ return () => window.clearTimeout(timeout);
402
+ }, [currentSlide, reducedMotion, route.view]);
403
+
250
404
  // ── Reference mode: delegate to DocsView ─────────────────────────────
251
405
  if (route.view === "kit") {
252
406
  return (
@@ -265,9 +419,6 @@ export function Deck() {
265
419
  );
266
420
  }
267
421
 
268
- // Whether slide transitions are enabled (can be disabled via deck frontmatter)
269
- const enableTransition = config.transition !== false;
270
-
271
422
  // ─────────────────────────────────────────────────────────────────────────
272
423
  // Guard: no slides
273
424
  // ─────────────────────────────────────────────────────────────────────────
@@ -280,14 +431,15 @@ export function Deck() {
280
431
  );
281
432
  }
282
433
 
283
- const currentSlide = Math.max(1, Math.min(route.slide, slideData.length));
284
434
  const currentStep = Math.max(0, route.step);
285
435
  const controlRoute =
286
436
  route.view === "slide" || route.view === "overview"
287
437
  ? { ...route, slide: currentSlide, step: currentStep }
288
438
  : route;
289
439
  const activeSlideScale = scale * slideZoom;
440
+ const viewportScale = scale || 1;
290
441
  const slideTransform = `translate(${slidePan.x}px, ${slidePan.y}px) scale(${activeSlideScale})`;
442
+ const zoomedSlideTransform = `translate(${slidePan.x / viewportScale}px, ${slidePan.y / viewportScale}px) scale(${slideZoom})`;
291
443
  const showSlideNumbers = config.showSlideNumbers === true;
292
444
  const disableSlideTextSelection =
293
445
  route.view === "slide" &&
@@ -326,6 +478,31 @@ export function Deck() {
326
478
  {slideData.map((data, i) => {
327
479
  const slideNumber = i + 1;
328
480
  const isCurrent = slideNumber === currentSlide;
481
+ const activeTransition = slideTransition;
482
+ const transitionRole =
483
+ activeTransition?.to === slideNumber
484
+ ? "enter"
485
+ : activeTransition?.from === slideNumber
486
+ ? "exit"
487
+ : null;
488
+ const isVisible = isCurrent || transitionRole !== null;
489
+ const transitionLayerClass =
490
+ transitionRole && activeTransition
491
+ ? `honeydeck-transition-${activeTransition.className} honeydeck-transition-${transitionRole}`
492
+ : "";
493
+ const layerStyle =
494
+ transitionRole && activeTransition
495
+ ? ({
496
+ "--honeydeck-transition-duration": `${activeTransition.duration}ms`,
497
+ "--honeydeck-transition-easing": activeTransition.easing,
498
+ "--honeydeck-transition-direction":
499
+ activeTransition.direction,
500
+ "--honeydeck-transition-enter-from-opacity":
501
+ activeTransition.enterFromOpacity,
502
+ "--honeydeck-transition-exit-from-opacity":
503
+ activeTransition.exitFromOpacity,
504
+ } as CSSProperties)
505
+ : undefined;
329
506
  const { Component, stepCount, title, frontmatter, layoutName } =
330
507
  data;
331
508
  const LayoutComponent = resolveLayout(layoutName);
@@ -335,42 +512,67 @@ export function Deck() {
335
512
  key={data.id}
336
513
  aria-hidden={!isCurrent}
337
514
  className={`absolute inset-0 flex items-center justify-center ${
338
- isCurrent
339
- ? "opacity-100 visible pointer-events-auto z-1"
340
- : "opacity-0 invisible pointer-events-none z-0"
515
+ isVisible ? "visible" : "invisible"
516
+ } ${isCurrent ? "pointer-events-auto" : "pointer-events-none"} ${
517
+ transitionRole === "enter"
518
+ ? "z-2"
519
+ : transitionRole === "exit"
520
+ ? "z-1"
521
+ : isCurrent
522
+ ? "z-1"
523
+ : "z-0"
341
524
  }`}
342
- style={{
343
- transition: enableTransition
344
- ? `opacity 200ms ease, visibility 0s ${isCurrent ? "0s" : "200ms"}`
345
- : "none",
346
- }}
347
525
  >
348
- <TimelineProvider
349
- stepIndex={isCurrent ? currentStep : 0}
350
- stepCount={stepCount}
526
+ <div
527
+ className="shrink-0 overflow-hidden"
528
+ style={{
529
+ width: BASE_WIDTH,
530
+ height: BASE_HEIGHT,
531
+ transform: `scale(${scale})`,
532
+ transformOrigin: "center center",
533
+ }}
351
534
  >
352
- <SlideScaleProvider scale={activeSlideScale}>
353
- <div
354
- className="honeydeck-slide-canvas shrink-0 relative overflow-hidden box-border"
355
- style={{
356
- width: BASE_WIDTH,
357
- height: BASE_HEIGHT,
358
- transform: slideTransform,
359
- transformOrigin: "center center",
360
- }}
535
+ <div
536
+ ref={(element) => {
537
+ slideLayerRefs.current[slideNumber] = element;
538
+ }}
539
+ className={`honeydeck-slide-layer relative ${
540
+ isCurrent ? "opacity-100" : "opacity-0"
541
+ } ${transitionLayerClass}`}
542
+ style={{
543
+ width: BASE_WIDTH,
544
+ height: BASE_HEIGHT,
545
+ ...layerStyle,
546
+ }}
547
+ >
548
+ <TimelineProvider
549
+ stepIndex={isCurrent ? currentStep : 0}
550
+ stepCount={stepCount}
361
551
  >
362
- <ErrorBoundary slideNumber={slideNumber}>
363
- <LayoutComponent
364
- title={title || null}
365
- frontmatter={frontmatter}
366
- rawChildren={<Component />}
552
+ <SlideScaleProvider scale={activeSlideScale}>
553
+ <div
554
+ className="honeydeck-slide-canvas shrink-0 relative overflow-hidden box-border"
555
+ style={{
556
+ width: BASE_WIDTH,
557
+ height: BASE_HEIGHT,
558
+ transform: zoomedSlideTransform,
559
+ transformOrigin: "center center",
560
+ }}
367
561
  >
368
- <Component />
369
- </LayoutComponent>
370
- </ErrorBoundary>
371
- </div>
372
- </SlideScaleProvider>
373
- </TimelineProvider>
562
+ <ErrorBoundary slideNumber={slideNumber}>
563
+ <LayoutComponent
564
+ title={title || null}
565
+ frontmatter={frontmatter}
566
+ rawChildren={<Component />}
567
+ >
568
+ <Component />
569
+ </LayoutComponent>
570
+ </ErrorBoundary>
571
+ </div>
572
+ </SlideScaleProvider>
573
+ </TimelineProvider>
574
+ </div>
575
+ </div>
374
576
  </div>
375
577
  );
376
578
  })}
@@ -223,12 +223,20 @@ Build output is a single-page application. The app preserves client-side slide t
223
223
 
224
224
  ### Slide Transitions
225
225
 
226
- Honeydeck includes a single subtle crossfade (~200ms) between slides. Disabled with:
226
+ Honeydeck uses a named slide transition system. Deck-level frontmatter sets defaults, and slide-level frontmatter overrides the transition **into that slide**:
227
227
 
228
228
  ```yaml
229
- transition: false
229
+ transition: fade
230
+ transitionDuration: 200
231
+ transitionEasing: ease
230
232
  ```
231
233
 
234
+ Built-in transition names are `fade`, `none`, and `slide-left`. Any other string is exposed as a custom CSS hook on the participating slide layers. Legacy `transition: true` maps to `fade`, and `transition: false` maps to `none`.
235
+
236
+ During slide navigation, Honeydeck keeps the outgoing and incoming slide layers mounted inside a scaled slide-sized clipping viewport, applies `honeydeck-slide-layer`, `honeydeck-transition-{name}`, and either `honeydeck-transition-enter` or `honeydeck-transition-exit` only to those two layers, then clears transition state after the configured duration. Transition visuals are clipped to the slide canvas area and must not animate into letterbox or pillarbox bars around the slide. If the next transition is `none` or navigation is interrupted, stale transition state is cleared/replaced so old slides do not remain visible. Outgoing layers are visible during the transition but have pointer events disabled. The built-in `fade` transition uses keyframes and, when a fade is interrupted, starts the next fade from the participating layers' current computed opacity so quick back-and-forth navigation stays close to the old opacity-transition behavior.
237
+
238
+ Participating slide layers receive CSS variables: `--honeydeck-transition-duration`, `--honeydeck-transition-easing`, and `--honeydeck-transition-direction` (`1` forward, `-1` backward). Built-in `slide-left` uses the direction variable so backward navigation reverses direction. Custom transition CSS can use the same variable for opt-in reverse awareness. Reduced-motion preferences disable slide transition animations.
239
+
232
240
  ### Aspect Ratio
233
241
 
234
242
  Slides render at a fixed 1920px logical width. Height is derived from deck-level `aspectRatio` when it is a string ratio matching `N:N` (default `16:9` → `1080`, `4:3` → `1440`, etc.). Invalid or missing ratios fall back to `16:9`.
@@ -531,6 +531,91 @@
531
531
  font-size: var(--honeydeck-font-size-code);
532
532
  }
533
533
 
534
+ /* ── Slide transitions ───────────────────────────────────────────────────── */
535
+
536
+ .honeydeck-slide-layer.honeydeck-transition-fade.honeydeck-transition-enter {
537
+ animation-name: honeydeck-transition-fade-enter;
538
+ animation-duration: var(--honeydeck-transition-duration, 200ms);
539
+ animation-timing-function: var(--honeydeck-transition-easing, ease);
540
+ animation-fill-mode: both;
541
+ }
542
+
543
+ .honeydeck-slide-layer.honeydeck-transition-fade.honeydeck-transition-exit {
544
+ animation-name: honeydeck-transition-fade-exit;
545
+ animation-duration: var(--honeydeck-transition-duration, 200ms);
546
+ animation-timing-function: var(--honeydeck-transition-easing, ease);
547
+ animation-fill-mode: both;
548
+ }
549
+
550
+ @keyframes honeydeck-transition-fade-enter {
551
+ from {
552
+ opacity: var(--honeydeck-transition-enter-from-opacity, 0);
553
+ }
554
+
555
+ to {
556
+ opacity: 1;
557
+ }
558
+ }
559
+
560
+ @keyframes honeydeck-transition-fade-exit {
561
+ from {
562
+ opacity: var(--honeydeck-transition-exit-from-opacity, 1);
563
+ }
564
+
565
+ to {
566
+ opacity: 0;
567
+ }
568
+ }
569
+
570
+ .honeydeck-slide-layer.honeydeck-transition-slide-left.honeydeck-transition-enter {
571
+ animation-name: honeydeck-transition-slide-left-enter;
572
+ animation-duration: var(--honeydeck-transition-duration, 200ms);
573
+ animation-timing-function: var(--honeydeck-transition-easing, ease);
574
+ animation-fill-mode: both;
575
+ }
576
+
577
+ .honeydeck-slide-layer.honeydeck-transition-slide-left.honeydeck-transition-exit {
578
+ animation-name: honeydeck-transition-slide-left-exit;
579
+ animation-duration: var(--honeydeck-transition-duration, 200ms);
580
+ animation-timing-function: var(--honeydeck-transition-easing, ease);
581
+ animation-fill-mode: both;
582
+ }
583
+
584
+ @keyframes honeydeck-transition-slide-left-enter {
585
+ from {
586
+ opacity: 1;
587
+ transform: translateX(
588
+ calc(100% * var(--honeydeck-transition-direction, 1))
589
+ );
590
+ }
591
+
592
+ to {
593
+ opacity: 1;
594
+ transform: translateX(0);
595
+ }
596
+ }
597
+
598
+ @keyframes honeydeck-transition-slide-left-exit {
599
+ from {
600
+ opacity: 1;
601
+ transform: translateX(0);
602
+ }
603
+
604
+ to {
605
+ opacity: 1;
606
+ transform: translateX(
607
+ calc(-100% * var(--honeydeck-transition-direction, 1))
608
+ );
609
+ }
610
+ }
611
+
612
+ @media (prefers-reduced-motion: reduce) {
613
+ .honeydeck-slide-layer.honeydeck-transition-enter,
614
+ .honeydeck-slide-layer.honeydeck-transition-exit {
615
+ animation: none;
616
+ }
617
+ }
618
+
534
619
  /* ── Documentation Markdown typography ──────────────────────────────────────
535
620
  Rendered README/docs pages use plain MDX elements. Keep their typography
536
621
  here instead of in DocsView so the React view stays structural. */
@@ -108,7 +108,9 @@ All settings use **camelCase**. No separate config file exists. Frontmatter pars
108
108
  | `colorMode` | `"system" \| "light" \| "dark"` | `"system"` | Browser color mode |
109
109
  | `pdfColorMode` | `"light" \| "dark"` | unset | Optional explicit PDF color mode; when unset, PDF falls back to pinned deck `colorMode`, then `light` |
110
110
  | `pdfSteps` | `"final" \| "all"` | `"final"` | Whether PDF includes all steps or final state |
111
- | `transition` | `boolean` | `true` | Enable crossfade transition between slides |
111
+ | `transition` | `string \| boolean` | `fade` | Default named slide transition (`fade`, `none`, `slide-left`, or a custom CSS name); legacy `true` maps to `fade` and `false` maps to `none` |
112
+ | `transitionDuration` | `number` | `200` | Default slide transition duration in milliseconds |
113
+ | `transitionEasing` | `string` | `ease` | Default slide transition timing function |
112
114
  | `magicCodeDuration` | `number` | `800` | Default Magic Code animation duration in milliseconds |
113
115
  | `layouts` | `string` | built-in `@honeydeck/honeydeck/layouts` | Layout map module path |
114
116
  | `defaultLayout` | `string` | `"Default"` | Layout used when slide has no `layout:` |
@@ -119,6 +121,9 @@ All settings use **camelCase**. No separate config file exists. Frontmatter pars
119
121
  | Property | Type | Default | Description |
120
122
  |----------|------|---------|-------------|
121
123
  | `layout` | `string` | (uses `defaultLayout`) | Layout map key to use (PascalCase by convention, not validated) |
124
+ | `transition` | `string \| boolean` | deck default | Named transition into this slide; legacy booleans map to `fade`/`none` |
125
+ | `transitionDuration` | `number` | deck default | Transition duration into this slide in milliseconds |
126
+ | `transitionEasing` | `string` | deck default | Transition easing into this slide |
122
127
  | ...layout-specific props | varies | — | Any additional props the layout accepts |
123
128
 
124
129
  ### Root frontmatter semantics
@@ -127,6 +132,6 @@ The first frontmatter block in the deck entry file is parsed as deck config. Dec
127
132
 
128
133
  Slide-level frontmatter is a frontmatter-only block after a slide separator and applies to the following slide. Imported MDX files are normal MDX modules and cannot set deck-level properties. `magicCodeDuration` is deck-level only; the same key in slide-level frontmatter is treated as a normal layout prop and does not configure Magic Code.
129
134
 
130
- Invalid `aspectRatio`, `colorMode`, and `pdfSteps` values fall back to defaults. Invalid `pdfColorMode` is ignored as unset, allowing the pinned `colorMode` fallback. `showSlideNumbers` is enabled only by literal `true`; `transition` is enabled unless literal `false`. Invalid explicit Magic Code block `duration` values are compile errors; invalid deck-level `magicCodeDuration` falls back to the default Magic Code duration.
135
+ Invalid `aspectRatio`, `colorMode`, and `pdfSteps` values fall back to defaults. Invalid `pdfColorMode` is ignored as unset, allowing the pinned `colorMode` fallback. `showSlideNumbers` is enabled only by literal `true`; slide transition values normalize at runtime, with non-empty strings treated as named built-ins or custom CSS hooks. Invalid explicit Magic Code block `duration` values are compile errors; invalid deck-level `magicCodeDuration` falls back to the default Magic Code duration.
131
136
 
132
137
  During development, changes to deck-level frontmatter invalidate the virtual config and every compiled virtual slide module, because slide compilation can depend on deck settings such as `magicCodeDuration`. Layout-related virtual modules are invalidated as before so layout map and demo previews stay current.
@@ -65,6 +65,8 @@ const DECK_FRONTMATTER_KEYS = new Set([
65
65
  "pdfColorMode",
66
66
  "pdfSteps",
67
67
  "transition",
68
+ "transitionDuration",
69
+ "transitionEasing",
68
70
  "magicCodeDuration",
69
71
  "layouts",
70
72
  "defaultLayout",