Files
2026-07-13 12:28:53 +08:00

300 lines
10 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
title: SlideTransition
description: Per-page enter/exit animations and shared element transitions.
---
The framework can run an enter/exit animation between every page change. There
is **no default** — pages snap unless you declare a `SlideTransition`. Snap-swap
is a perfectly tasteful default; only opt in when motion adds something.
```tsx title="slides/q2-launch/index.tsx"
import type { Page, SlideTransition } from '@open-slide/core';
const Cover: Page = () => <section>…</section>;
const Body: Page = () => <section>…</section>;
const EASE_OUT = 'cubic-bezier(0, 0, 0.2, 1)';
const EASE_IN = 'cubic-bezier(0.4, 0, 1, 1)';
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)' },
] },
};
export default [Cover, Body];
```
`prefers-reduced-motion: reduce` is honored automatically — you don't write a
fallback.
## Schema
```ts
type TransitionPhase = {
/** WAAPI keyframes — either an array of Keyframe objects
* or a PropertyIndexedKeyframes object. */
keyframes: Keyframe[] | PropertyIndexedKeyframes;
/** Phase duration in ms. Falls back to the top-level duration. */
duration?: number;
/** CSS easing string. Falls back to the top-level easing. */
easing?: string;
/** Delay in ms before the phase starts.
* Use on enter to overlap with the outgoing exit. */
delay?: number;
};
type SlideTransition = {
/** Top-level duration fallback (ms). Required. */
duration: number;
/** Top-level easing fallback. */
easing?: string;
/** Plays on the incoming page. */
enter?: TransitionPhase;
/** Plays on the outgoing page. */
exit?: TransitionPhase;
/** Matches unstable_SharedElement pairs and fades unmatched shared elements. */
sharedElements?: boolean | SharedElementTransition;
};
type SharedElementTransition = {
/** Shared element duration in ms. Falls back to the top-level duration. */
duration?: number;
/** CSS easing string. Falls back to the top-level easing. */
easing?: string;
/** Delay in ms before shared elements start moving. */
delay?: number;
};
```
## Where to declare it
Two surfaces, both optional:
- **Module-level** — `export const transition` from the slide module. Every
page in the deck inherits it.
- **Per-page** — assign `Page.transition` directly. Overrides the module
default for that page.
```tsx
const Cover: Page = () => <section>…</section>;
const Body: Page = () => <section>…</section>;
// Module default — applies to both pages.
export const transition: SlideTransition = { /* … */ };
// Per-page override — only Cover uses this.
Cover.transition = { /* … */ };
export default [Cover, Body];
```
### Which one wins
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 instead. This keeps the "next thought" feeling
attached to the slide you're heading toward, not the one you're leaving.
## Direction hook
The framework writes two values on the transition wrapper so a single keyframe
can mirror itself on backward navigation:
| Surface | Forward | Backward |
| --------------- | ------- | -------- |
| `--osd-dir` | `1` | `-1` |
| `data-osd-dir` | `forward` | `backward` |
Use `--osd-dir` inside `calc()` when you genuinely need to mirror motion:
```tsx
{ transform: 'translateX(calc(var(--osd-dir, 1) * 8px))' },
{ transform: 'translateX(0)' },
```
Most tasteful tools don't mirror on backward navigation — forward = backward
reads as more refined. Reach for the direction hook only when the page's motion
has a literal direction (a horizontal advance, a chapter sweep).
## Shared elements
<Callout type="warn">
This feature is currently unstable and subject to change. It is not
recommended for production. Try it out and share your feedback on
[GitHub](https://github.com/1weiho/open-slide/issues).
</Callout>
For Keynote-style "Magic Move" continuity, wrap the same visual object on two
pages with `unstable_SharedElement` and give both instances the same `id`. The transition
layer measures the outgoing and incoming DOM, hides the originals, animates a
clone across the canvas, then restores the incoming element.
```tsx
import {
unstable_SharedElement as SharedElement,
type Page,
type SlideTransition,
} from '@open-slide/core';
const Logo = ({ x, y, size }: { x: number; y: number; size: number }) => (
<SharedElement id="logo">
<div
style={{
position: 'absolute',
left: x,
top: y,
width: size,
height: size,
borderRadius: size * 0.25,
background: 'var(--osd-accent)',
}}
/>
</SharedElement>
);
const Start: Page = () => <Logo x={160} y={180} size={180} />;
const End: Page = () => <Logo x={1420} y={720} size={96} />;
export const transition: SlideTransition = {
duration: 900,
enter: { keyframes: [{ opacity: 0.35 }, { opacity: 1 }] },
exit: { keyframes: [{ opacity: 1 }, { opacity: 0.35 }] },
sharedElements: {
duration: 900,
easing: 'cubic-bezier(0.22, 1, 0.36, 1)',
},
};
export default [Start, End];
```
Use shared elements only for the same object in a new position or size. If the
content changes, keep that changing copy outside `unstable_SharedElement` and let it fade
as normal page content. When `sharedElements` is enabled, marked elements that
only exist on the outgoing page fade out automatically, and marked elements that
only exist on the incoming page fade in automatically. The runtime does not
guess similar objects; the `id` is the contract.
## Design principles
The 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: 140280 ms.** Exit 140180 ms, enter 200280 ms, enter delayed
~80 ms so they overlap but don't fight. Past 350 ms is video-editor
territory.
- **Magnitude ceiling: 12 px or 3% scale.** A 6 px Y-rise reads as "next
thought." A 1920 px translateX reads as "different document."
- **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`.
## A tasteful family
Six members, one DNA. Pick one as the deck's house transition; optionally
reserve a second for cover slides and a third for genuine section breaks.
```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. Good 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.
const settle: SlideTransition = {
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 12× per deck at most.
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.