@3xhaust/oh-my-design 0.16.1
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/LICENSE +21 -0
- package/README.ko.md +282 -0
- package/README.md +282 -0
- package/adapters/build.ts +184 -0
- package/adapters/claude.ts +111 -0
- package/adapters/codex.ts +68 -0
- package/adapters/tokens.ts +21 -0
- package/adapters/tool-map.json +7 -0
- package/bin/omd-install.ts +76 -0
- package/bin/omd.ts +1774 -0
- package/core/asset-sourcing/index.ts +187 -0
- package/core/coach/index.ts +98 -0
- package/core/composition/app-shell-workbench.md +168 -0
- package/core/composition/asymmetric-diagonal-grid.md +178 -0
- package/core/composition/bento-grid.md +223 -0
- package/core/composition/editorial-index-labels.md +173 -0
- package/core/composition/form-wizard-stepper.md +132 -0
- package/core/composition/master-detail-flow.md +129 -0
- package/core/composition/section-inversion.md +182 -0
- package/core/composition/sidebar-margin-annotation.md +184 -0
- package/core/composition/split-screen-hero.md +224 -0
- package/core/composition/sticky-sidebar-scroll.md +219 -0
- package/core/composition/typographic-hero.md +100 -0
- package/core/composition-contract/index.ts +233 -0
- package/core/composition-contract/visual-richness.ts +101 -0
- package/core/config/index.ts +27 -0
- package/core/copy/index.ts +296 -0
- package/core/craft/finish-pass.md +356 -0
- package/core/craft/index.ts +33 -0
- package/core/design/index.ts +581 -0
- package/core/design/interaction-states.ts +189 -0
- package/core/eval-harness/index.ts +225 -0
- package/core/evidence/final.ts +444 -0
- package/core/evidence/task.ts +459 -0
- package/core/figma/client.ts +312 -0
- package/core/figma/diff.ts +261 -0
- package/core/figma/responsive.ts +310 -0
- package/core/figma/system.ts +466 -0
- package/core/figma/types.ts +185 -0
- package/core/frame/check-ux.ts +180 -0
- package/core/frame/index.ts +39 -0
- package/core/frame/write.ts +168 -0
- package/core/graphics/css-illustration-primitives.md +189 -0
- package/core/graphics/duotone-image-presets.md +173 -0
- package/core/graphics/gradient-mesh.md +178 -0
- package/core/graphics/noise-grain-texture.md +150 -0
- package/core/graphics/placeholder-policy.md +185 -0
- package/core/graphics/svg-geometric-patterns.md +145 -0
- package/core/history/index.ts +39 -0
- package/core/install/detect.ts +32 -0
- package/core/install/install.ts +321 -0
- package/core/install/patch-claude.ts +109 -0
- package/core/install/patch-codex.ts +82 -0
- package/core/interaction/index.ts +101 -0
- package/core/interaction/recipes/signature-lighting.md +203 -0
- package/core/ir/dom.ts +352 -0
- package/core/ir/normalize.ts +177 -0
- package/core/motion/easing.md +151 -0
- package/core/motion/energy.ts +188 -0
- package/core/motion/recipes/image-hover-distortion.md +198 -0
- package/core/motion/recipes/magnetic-hover.md +229 -0
- package/core/motion/recipes/marquee.md +202 -0
- package/core/motion/recipes/number-counter.md +242 -0
- package/core/motion/recipes/page-loader.md +240 -0
- package/core/motion/recipes/parallax.md +233 -0
- package/core/motion/recipes/scroll-reveal.md +210 -0
- package/core/motion/recipes/section-color-inversion.md +217 -0
- package/core/motion/recipes/split-text-entrance.md +222 -0
- package/core/motion/recipes/stagger-orchestrator.md +227 -0
- package/core/motion/recipes/sticky-scene-transition.md +246 -0
- package/core/motion/recipes/view-transitions.md +239 -0
- package/core/probe/index.ts +186 -0
- package/core/probe/schema.json +46 -0
- package/core/protocol/composition-contract.md +192 -0
- package/core/protocol/copy-deck.md +96 -0
- package/core/protocol/human-design-loop.md +447 -0
- package/core/protocol/slop-review.md +71 -0
- package/core/ref/batch.ts +94 -0
- package/core/ref/blueprint.ts +199 -0
- package/core/ref/distance.ts +225 -0
- package/core/ref/invariants.ts +162 -0
- package/core/ref/signal.ts +37 -0
- package/core/ref/store.ts +122 -0
- package/core/render/index.ts +607 -0
- package/core/rules/attribution.ts +159 -0
- package/core/rules/builtin/contrast.yaml +8 -0
- package/core/rules/builtin/focus.yaml +8 -0
- package/core/rules/builtin/hit-area.yaml +8 -0
- package/core/rules/builtin/ko.yaml +6 -0
- package/core/rules/builtin/motion.yaml +80 -0
- package/core/rules/builtin/slop.yaml +365 -0
- package/core/rules/builtin/spacing.yaml +9 -0
- package/core/rules/builtin/system.yaml +42 -0
- package/core/rules/builtin/token.yaml +8 -0
- package/core/rules/builtin/ux.yaml +125 -0
- package/core/rules/engine.ts +88 -0
- package/core/rules/leakage.ts +72 -0
- package/core/rules/motion-spec.ts +204 -0
- package/core/site/index.ts +100 -0
- package/core/slop/index.ts +427 -0
- package/core/slop/text-slop.ts +220 -0
- package/core/source-seal/index.ts +174 -0
- package/core/stack/index.ts +53 -0
- package/core/target/index.ts +183 -0
- package/core/theory/color.md +285 -0
- package/core/theory/components.md +272 -0
- package/core/theory/craft.md +270 -0
- package/core/theory/expressive.md +213 -0
- package/core/theory/imagegen.md +105 -0
- package/core/theory/layout.md +159 -0
- package/core/theory/motion.md +283 -0
- package/core/theory/typography.md +134 -0
- package/core/theory/ux.md +580 -0
- package/core/theory/voice.md +595 -0
- package/core/types.ts +684 -0
- package/package.json +40 -0
- package/src/agents/composer.agent.yaml +174 -0
- package/src/agents/eye.agent.yaml +208 -0
- package/src/agents/framer.agent.yaml +52 -0
- package/src/agents/glance.agent.yaml +19 -0
- package/src/agents/hand.agent.yaml +200 -0
- package/src/agents/scout.agent.yaml +71 -0
- package/src/agents/sketch.agent.yaml +64 -0
- package/src/agents/typesetter.agent.yaml +52 -0
- package/src/agents/writer.agent.yaml +63 -0
- package/src/skills/omd-coach/SKILL.md +40 -0
- package/src/skills/omd-critique/SKILL.md +77 -0
- package/src/skills/omd-figma/SKILL.md +256 -0
- package/src/skills/omd-humanize/SKILL.md +160 -0
- package/src/skills/omd-scout/SKILL.md +77 -0
- package/src/skills/omd-ultradesign/SKILL.md +521 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
# Split-text entrance (character / word stagger)
|
|
2
|
+
|
|
3
|
+
Split-text is the technique of decomposing a heading into individual `<span>` elements —
|
|
4
|
+
one per character or one per word — then staggering their entrance via a CSS animation
|
|
5
|
+
delay cascade. Each fragment arrives fractionally after the last, so the text assembles
|
|
6
|
+
itself in front of the viewer rather than appearing complete.
|
|
7
|
+
|
|
8
|
+
## When it earns its place / When it does not
|
|
9
|
+
|
|
10
|
+
Condition: the hero type is the primary visual event and the words are part of the concept
|
|
11
|
+
— something is being said, not just displayed. A heading that reads "The infrastructure
|
|
12
|
+
that disappears" reveals meaning word by word; each word lands with intent. The reveal
|
|
13
|
+
enacts the concept.
|
|
14
|
+
|
|
15
|
+
Condition against: body copy, UI labels, secondary headings, navigation items, any text
|
|
16
|
+
the user needs to skim or operate. Split-text on body copy converts reading into waiting
|
|
17
|
+
— the user must watch the paragraph assemble itself before they can begin. It is the most
|
|
18
|
+
common technique deployed too broadly. The `expressive.md` technique catalogue states
|
|
19
|
+
this directly: "Split-text on body copy turns reading into waiting." The stagger budget
|
|
20
|
+
is consumed by the entrance; there is nothing left for the content. Apply this technique
|
|
21
|
+
to one element per page — the one element whose arrival is the event.
|
|
22
|
+
|
|
23
|
+
Character-level stagger is more dramatic than word-level; use character-level only when
|
|
24
|
+
the display type is large enough (roughly 60px+) that individual characters read as
|
|
25
|
+
distinct forms. At smaller sizes, word-level stagger is the correct granularity.
|
|
26
|
+
|
|
27
|
+
## Parameters
|
|
28
|
+
|
|
29
|
+
```css
|
|
30
|
+
:root {
|
|
31
|
+
/* Duration for each character/word span's fade-translate entrance. */
|
|
32
|
+
--duration-split: 480ms;
|
|
33
|
+
|
|
34
|
+
/* Easing for each span. ease-out-expo reads as authoritative; ease-out-quint
|
|
35
|
+
slightly softer. Both from core/motion/easing.md. */
|
|
36
|
+
--ease-split: var(--ease-out-expo);
|
|
37
|
+
|
|
38
|
+
/* Delay between each successive span. 40ms minimum (below this the stagger
|
|
39
|
+
is imperceptible); 80ms maximum (above this it becomes a waterfall).
|
|
40
|
+
core/theory/motion.md: "Sibling stagger: 40–80ms offset." */
|
|
41
|
+
--stagger-split: 55ms;
|
|
42
|
+
|
|
43
|
+
/* Vertical offset the span travels from on entrance. Keep small — 16–24px.
|
|
44
|
+
Larger values read as falling rather than arriving. */
|
|
45
|
+
--translate-split: 20px;
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Implementation
|
|
50
|
+
|
|
51
|
+
```html
|
|
52
|
+
<!-- The heading must have complete text content in the HTML. The JS adds spans;
|
|
53
|
+
if it fails or is disabled, the text reads normally. -->
|
|
54
|
+
<h1 class="split-hero" aria-label="The infrastructure that disappears">
|
|
55
|
+
The infrastructure that disappears
|
|
56
|
+
</h1>
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
```css
|
|
60
|
+
.split-hero .split-char,
|
|
61
|
+
.split-hero .split-word {
|
|
62
|
+
display: inline-block; /* required: transform does not apply to inline */
|
|
63
|
+
opacity: 0;
|
|
64
|
+
transform: translateY(var(--translate-split));
|
|
65
|
+
animation: split-enter var(--duration-split) var(--ease-split) forwards;
|
|
66
|
+
animation-play-state: paused; /* JS un-pauses after splitting */
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
@keyframes split-enter {
|
|
70
|
+
to {
|
|
71
|
+
opacity: 1;
|
|
72
|
+
transform: translateY(0);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/* Space characters must be preserved as visible spans or words gap wrong */
|
|
77
|
+
.split-hero .split-space {
|
|
78
|
+
display: inline-block;
|
|
79
|
+
white-space: pre;
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
```js
|
|
84
|
+
// Split at word level (swap 'word' for 'char' for character-level stagger).
|
|
85
|
+
// Run once on DOMContentLoaded — not on scroll, not on re-render.
|
|
86
|
+
function splitTextEntrance(el, mode = 'word') {
|
|
87
|
+
const text = el.textContent ?? '';
|
|
88
|
+
const units = mode === 'char'
|
|
89
|
+
? [...text].map(ch => ({ text: ch, isSpace: ch === ' ' }))
|
|
90
|
+
: text.split(' ').map((w, i, arr) => ({ text: w, isSpace: false, addSpace: i < arr.length - 1 }));
|
|
91
|
+
|
|
92
|
+
el.textContent = '';
|
|
93
|
+
units.forEach(({ text, isSpace, addSpace }, i) => {
|
|
94
|
+
if (isSpace) {
|
|
95
|
+
const sp = document.createElement('span');
|
|
96
|
+
sp.className = 'split-space';
|
|
97
|
+
sp.textContent = ' ';
|
|
98
|
+
el.appendChild(sp);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const span = document.createElement('span');
|
|
102
|
+
span.className = mode === 'char' ? 'split-char' : 'split-word';
|
|
103
|
+
span.textContent = text;
|
|
104
|
+
// Each span's delay is its index × the stagger custom property value.
|
|
105
|
+
// Read the value from the root so the hand's token wiring is respected.
|
|
106
|
+
const stagger = parseFloat(getComputedStyle(document.documentElement)
|
|
107
|
+
.getPropertyValue('--stagger-split')) || 55;
|
|
108
|
+
span.style.animationDelay = `${i * stagger}ms`;
|
|
109
|
+
span.style.animationPlayState = 'running';
|
|
110
|
+
el.appendChild(span);
|
|
111
|
+
if (addSpace) {
|
|
112
|
+
const sp = document.createElement('span');
|
|
113
|
+
sp.className = 'split-space';
|
|
114
|
+
sp.textContent = ' ';
|
|
115
|
+
el.appendChild(sp);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
121
|
+
document.querySelectorAll('.split-hero').forEach(el => splitTextEntrance(el, 'word'));
|
|
122
|
+
});
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## React
|
|
126
|
+
|
|
127
|
+
```tsx
|
|
128
|
+
import { motion } from 'framer-motion';
|
|
129
|
+
|
|
130
|
+
// Framer Motion handles the span decomposition via staggerChildren.
|
|
131
|
+
// variants.container drives the orchestration; variants.item drives each word.
|
|
132
|
+
const container = {
|
|
133
|
+
hidden: {},
|
|
134
|
+
visible: {
|
|
135
|
+
transition: {
|
|
136
|
+
staggerChildren: 0.055, // --stagger-split equivalent
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const item = {
|
|
142
|
+
hidden: { opacity: 0, y: 20 },
|
|
143
|
+
visible: {
|
|
144
|
+
opacity: 1,
|
|
145
|
+
y: 0,
|
|
146
|
+
transition: {
|
|
147
|
+
duration: 0.48, // --duration-split equivalent
|
|
148
|
+
ease: [0.16, 1, 0.3, 1], // --ease-out-expo
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
function SplitHero({ text }: { text: string }) {
|
|
154
|
+
const words = text.split(' ');
|
|
155
|
+
return (
|
|
156
|
+
<motion.h1
|
|
157
|
+
aria-label={text}
|
|
158
|
+
variants={container}
|
|
159
|
+
initial="hidden"
|
|
160
|
+
animate="visible"
|
|
161
|
+
style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25em' }}
|
|
162
|
+
>
|
|
163
|
+
{words.map((word, i) => (
|
|
164
|
+
<motion.span key={i} variants={item} style={{ display: 'inline-block' }}>
|
|
165
|
+
{word}
|
|
166
|
+
</motion.span>
|
|
167
|
+
))}
|
|
168
|
+
</motion.h1>
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
Framer Motion respects `prefers-reduced-motion` automatically when you pass
|
|
174
|
+
`reducedMotion="user"` to `<MotionConfig>` at the app root. Add this once:
|
|
175
|
+
|
|
176
|
+
```tsx
|
|
177
|
+
import { MotionConfig } from 'framer-motion';
|
|
178
|
+
// In your root layout:
|
|
179
|
+
<MotionConfig reducedMotion="user">{children}</MotionConfig>
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
## Reduced-motion variant
|
|
183
|
+
|
|
184
|
+
When `prefers-reduced-motion: reduce` is set, the text must be complete and legible —
|
|
185
|
+
not an empty space, not a deferred render. The split spans are not created; the heading
|
|
186
|
+
renders as normal text with a single short opacity fade.
|
|
187
|
+
|
|
188
|
+
```css
|
|
189
|
+
@media (prefers-reduced-motion: reduce) {
|
|
190
|
+
.split-hero .split-char,
|
|
191
|
+
.split-hero .split-word {
|
|
192
|
+
/* Collapse all spans to visible immediately — no transform, no stagger. */
|
|
193
|
+
opacity: 1;
|
|
194
|
+
transform: none;
|
|
195
|
+
animation: none;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
In the JS split function, check before splitting:
|
|
201
|
+
|
|
202
|
+
```js
|
|
203
|
+
const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
204
|
+
if (!prefersReduced) {
|
|
205
|
+
document.querySelectorAll('.split-hero').forEach(el => splitTextEntrance(el, 'word'));
|
|
206
|
+
}
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
## Performance note
|
|
210
|
+
|
|
211
|
+
Each span animates only `opacity` and `transform: translateY()` — both compositor-only
|
|
212
|
+
properties. No layout is triggered on any frame. The GPU handles each span independently;
|
|
213
|
+
the main thread is not involved after the initial DOM construction.
|
|
214
|
+
|
|
215
|
+
`will-change: transform` on every span would promote each to its own compositor layer,
|
|
216
|
+
consuming GPU memory proportional to the number of characters. On a 40-character heading
|
|
217
|
+
at character-level stagger, this is forty compositor layers. Do not apply `will-change`
|
|
218
|
+
here. The browser's compositor schedules the animations correctly without it.
|
|
219
|
+
|
|
220
|
+
The JS runs once on `DOMContentLoaded`. It does not run on scroll, on re-render, or
|
|
221
|
+
on visibility change. The spans are DOM elements for the lifetime of the page; there is
|
|
222
|
+
no teardown cost.
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
# Stagger orchestrator
|
|
2
|
+
|
|
3
|
+
A stagger orchestrator drives a group of sibling elements — cards, list items, nav links,
|
|
4
|
+
feature blocks — so each child enters slightly after the previous, reading the group as a
|
|
5
|
+
sequence rather than a simultaneous burst. The recipe handles both static lists (CSS
|
|
6
|
+
nth-child delay cascade) and dynamic lists (JS-computed delays) and centralises the
|
|
7
|
+
stagger math so it is filled from the board's motion invariants rather than hardcoded
|
|
8
|
+
per-component.
|
|
9
|
+
|
|
10
|
+
## When it earns its place / When it does not
|
|
11
|
+
|
|
12
|
+
Condition: the elements have an implied order — a feature list whose items have a
|
|
13
|
+
sequence, a card grid whose items are ranked, a navigation whose items flow from primary
|
|
14
|
+
to secondary. The stagger communicates that order; the eye follows the arrival sequence
|
|
15
|
+
and reads a hierarchy. `core/theory/motion.md`: "staggering their arrivals reads the
|
|
16
|
+
group as a sequence — semantically correct when the elements have an implied order."
|
|
17
|
+
|
|
18
|
+
Condition against: elements with no meaningful order — a tag cloud, a random mosaic, a
|
|
19
|
+
grid of equally-weighted items. Staggering unordered items implies a sequence that does
|
|
20
|
+
not exist, which is misleading choreography. Also: long lists. A 30-item list staggered
|
|
21
|
+
at 60ms arrives over 1.8 seconds — the last card enters nearly two seconds after the
|
|
22
|
+
first, by which point the user has scrolled past it. Cap staggered groups at eight to
|
|
23
|
+
ten items; beyond that, consider grouping (reveal the first row together, then the second)
|
|
24
|
+
or collapsing the stagger to 20ms so the total remains under 600ms.
|
|
25
|
+
|
|
26
|
+
The stagger budget calculation: `--stagger-base × (n - 1)` must stay under 600ms.
|
|
27
|
+
For a group of eight at 55ms, the total span is 385ms — within budget. For fifteen items
|
|
28
|
+
at 55ms, the span is 770ms — over budget. Either reduce the group or reduce the delay.
|
|
29
|
+
|
|
30
|
+
## Parameters
|
|
31
|
+
|
|
32
|
+
```css
|
|
33
|
+
:root {
|
|
34
|
+
/* Delay between each successive element. 40–80ms from core/theory/motion.md.
|
|
35
|
+
Below 40ms: imperceptible, wasted. Above 80ms: waterfall. */
|
|
36
|
+
--stagger-base: 55ms;
|
|
37
|
+
|
|
38
|
+
/* Duration for each element's entrance animation. Independent of stagger;
|
|
39
|
+
each element animates for its full duration from its delayed start. */
|
|
40
|
+
--stagger-duration: 320ms;
|
|
41
|
+
|
|
42
|
+
/* Easing for each element's entrance. */
|
|
43
|
+
--stagger-ease: var(--ease-out-quint);
|
|
44
|
+
|
|
45
|
+
/* Vertical translate offset on entrance. */
|
|
46
|
+
--stagger-translate-y: 20px;
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Implementation
|
|
51
|
+
|
|
52
|
+
```css
|
|
53
|
+
/* ── CSS nth-child cascade (for static, known-count lists) ─────────────────── */
|
|
54
|
+
|
|
55
|
+
.stagger-group > * {
|
|
56
|
+
opacity: 0;
|
|
57
|
+
transform: translateY(var(--stagger-translate-y));
|
|
58
|
+
animation: stagger-in var(--stagger-duration) var(--stagger-ease) forwards;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
@keyframes stagger-in {
|
|
62
|
+
to {
|
|
63
|
+
opacity: 1;
|
|
64
|
+
transform: translateY(0);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/* Generate nth-child delays via CSS. For a group of up to 10 items: */
|
|
69
|
+
.stagger-group > *:nth-child(1) { animation-delay: calc(var(--stagger-base) * 0); }
|
|
70
|
+
.stagger-group > *:nth-child(2) { animation-delay: calc(var(--stagger-base) * 1); }
|
|
71
|
+
.stagger-group > *:nth-child(3) { animation-delay: calc(var(--stagger-base) * 2); }
|
|
72
|
+
.stagger-group > *:nth-child(4) { animation-delay: calc(var(--stagger-base) * 3); }
|
|
73
|
+
.stagger-group > *:nth-child(5) { animation-delay: calc(var(--stagger-base) * 4); }
|
|
74
|
+
.stagger-group > *:nth-child(6) { animation-delay: calc(var(--stagger-base) * 5); }
|
|
75
|
+
.stagger-group > *:nth-child(7) { animation-delay: calc(var(--stagger-base) * 6); }
|
|
76
|
+
.stagger-group > *:nth-child(8) { animation-delay: calc(var(--stagger-base) * 7); }
|
|
77
|
+
.stagger-group > *:nth-child(9) { animation-delay: calc(var(--stagger-base) * 8); }
|
|
78
|
+
.stagger-group > *:nth-child(10) { animation-delay: calc(var(--stagger-base) * 9); }
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
```js
|
|
82
|
+
// ── JS version for dynamic lists or when scroll-triggered stagger is needed ──
|
|
83
|
+
// Reads the custom property from :root so the board-wired token is respected.
|
|
84
|
+
|
|
85
|
+
function applyStagger(container) {
|
|
86
|
+
const base = parseFloat(
|
|
87
|
+
getComputedStyle(document.documentElement).getPropertyValue('--stagger-base')
|
|
88
|
+
) || 55;
|
|
89
|
+
|
|
90
|
+
const children = Array.from(container.children);
|
|
91
|
+
children.forEach((child, i) => {
|
|
92
|
+
child.style.animationDelay = `${i * base}ms`;
|
|
93
|
+
child.style.animationPlayState = 'running';
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// For scroll-triggered stagger: observe the container, apply delays when it enters view.
|
|
98
|
+
const observer = new IntersectionObserver(
|
|
99
|
+
(entries) => {
|
|
100
|
+
entries.forEach((entry) => {
|
|
101
|
+
if (entry.isIntersecting) {
|
|
102
|
+
applyStagger(entry.target);
|
|
103
|
+
observer.unobserve(entry.target);
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
},
|
|
107
|
+
{ threshold: 0.1 }
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
document.querySelectorAll('.stagger-group').forEach((el) => observer.observe(el));
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## React
|
|
114
|
+
|
|
115
|
+
```tsx
|
|
116
|
+
import { motion } from 'framer-motion';
|
|
117
|
+
|
|
118
|
+
const containerVariants = {
|
|
119
|
+
hidden: {},
|
|
120
|
+
visible: {
|
|
121
|
+
transition: {
|
|
122
|
+
staggerChildren: 0.055, // --stagger-base
|
|
123
|
+
delayChildren: 0,
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const itemVariants = {
|
|
129
|
+
hidden: { opacity: 0, y: 20 },
|
|
130
|
+
visible: {
|
|
131
|
+
opacity: 1,
|
|
132
|
+
y: 0,
|
|
133
|
+
transition: {
|
|
134
|
+
duration: 0.32, // --stagger-duration
|
|
135
|
+
ease: [0.22, 1, 0.36, 1], // --ease-out-quint
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
function StaggerGroup<T>({
|
|
141
|
+
items,
|
|
142
|
+
renderItem,
|
|
143
|
+
}: {
|
|
144
|
+
items: T[];
|
|
145
|
+
renderItem: (item: T, i: number) => React.ReactNode;
|
|
146
|
+
}) {
|
|
147
|
+
return (
|
|
148
|
+
<motion.ul
|
|
149
|
+
variants={containerVariants}
|
|
150
|
+
initial="hidden"
|
|
151
|
+
animate="visible"
|
|
152
|
+
>
|
|
153
|
+
{items.map((item, i) => (
|
|
154
|
+
<motion.li key={i} variants={itemVariants}>
|
|
155
|
+
{renderItem(item, i)}
|
|
156
|
+
</motion.li>
|
|
157
|
+
))}
|
|
158
|
+
</motion.ul>
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
For scroll-triggered stagger in React, combine with `useInView`:
|
|
164
|
+
|
|
165
|
+
```tsx
|
|
166
|
+
import { useInView } from 'framer-motion';
|
|
167
|
+
import { useRef } from 'react';
|
|
168
|
+
|
|
169
|
+
function ScrollStaggerGroup({ items, renderItem }) {
|
|
170
|
+
const ref = useRef(null);
|
|
171
|
+
const isInView = useInView(ref, { once: true, margin: '-15% 0px' });
|
|
172
|
+
|
|
173
|
+
return (
|
|
174
|
+
<motion.ul
|
|
175
|
+
ref={ref}
|
|
176
|
+
variants={containerVariants}
|
|
177
|
+
initial="hidden"
|
|
178
|
+
animate={isInView ? 'visible' : 'hidden'}
|
|
179
|
+
>
|
|
180
|
+
{items.map((item, i) => (
|
|
181
|
+
<motion.li key={i} variants={itemVariants}>
|
|
182
|
+
{renderItem(item, i)}
|
|
183
|
+
</motion.li>
|
|
184
|
+
))}
|
|
185
|
+
</motion.ul>
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
## Reduced-motion variant
|
|
191
|
+
|
|
192
|
+
In reduced-motion context, all group members are visible simultaneously — no stagger,
|
|
193
|
+
no translate, no fade sequence. The content is available immediately.
|
|
194
|
+
|
|
195
|
+
```css
|
|
196
|
+
@media (prefers-reduced-motion: reduce) {
|
|
197
|
+
.stagger-group > * {
|
|
198
|
+
opacity: 1;
|
|
199
|
+
transform: none;
|
|
200
|
+
animation: none !important;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
For the JS path, check before applying delays:
|
|
206
|
+
|
|
207
|
+
```js
|
|
208
|
+
const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
209
|
+
if (!prefersReduced) {
|
|
210
|
+
document.querySelectorAll('.stagger-group').forEach((el) => observer.observe(el));
|
|
211
|
+
}
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
## Performance note
|
|
215
|
+
|
|
216
|
+
Each child animates only `opacity` and `transform: translateY()` — compositor-only
|
|
217
|
+
properties. At 60fps on mid-range hardware, ten simultaneous opacity + transform
|
|
218
|
+
animations present no measurable cost. The browser composites them independently on
|
|
219
|
+
the GPU.
|
|
220
|
+
|
|
221
|
+
The CSS nth-child approach requires no JavaScript and is therefore zero-overhead at
|
|
222
|
+
runtime. Prefer it for static lists. The JS approach adds one IntersectionObserver
|
|
223
|
+
callback per group; the callback fires once and is immediately disconnected.
|
|
224
|
+
|
|
225
|
+
Do not generate more than ten nth-child rules in CSS for staggered groups — beyond ten
|
|
226
|
+
items, the total stagger duration at 55ms/item (550ms+) exceeds the perceivable range
|
|
227
|
+
and the JS approach with a capped delay is preferable.
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
# Sticky scene transition
|
|
2
|
+
|
|
3
|
+
A sticky scene pins the viewport at a fixed scroll position while the user continues
|
|
4
|
+
scrolling, advancing an animation timeline inside the pinned area instead of moving the
|
|
5
|
+
page. The result: sections that behave as discrete scenes rather than continuous columns.
|
|
6
|
+
The scroll gesture becomes a time axis — the user controls playback by scrolling, the
|
|
7
|
+
interface responds frame by frame.
|
|
8
|
+
|
|
9
|
+
## When it earns its place / When it does not
|
|
10
|
+
|
|
11
|
+
Condition: a multi-scene showpiece where each scene requires the user's full attention
|
|
12
|
+
before they advance — a product demo that teaches a feature sequence, a narrative
|
|
13
|
+
microsite with distinct chapters, a brand campaign where each scroll beat corresponds
|
|
14
|
+
to a concept moment. The scene boundary is earned: the user has finished one scene and
|
|
15
|
+
is deliberately entering the next. `expressive.md`: "The stage pins the viewport while
|
|
16
|
+
scroll advances the timeline inside the scene."
|
|
17
|
+
|
|
18
|
+
Condition against: sites where the user is goal-oriented and scrolling to find specific
|
|
19
|
+
content. Any product with return visits — a sticky stage the user has already watched
|
|
20
|
+
becomes pure friction; they must scroll through the entire scene again to reach content
|
|
21
|
+
below it. If there is any chance of return visits, a skip control is required. Also:
|
|
22
|
+
sites with more than three or four scenes — beyond that, the user's sense of scroll
|
|
23
|
+
distance breaks down and they feel trapped rather than guided. `expressive.md`: "Sticky
|
|
24
|
+
stages are navigation debt — the user cannot scroll past to scan; they must watch the
|
|
25
|
+
scene play."
|
|
26
|
+
|
|
27
|
+
## Parameters
|
|
28
|
+
|
|
29
|
+
```css
|
|
30
|
+
:root {
|
|
31
|
+
/* Height of the sticky container — how much scroll distance each scene consumes.
|
|
32
|
+
Larger values give more scroll range per scene (smoother progress on trackpad);
|
|
33
|
+
smaller values feel more abrupt. 200–300vh per scene is typical. */
|
|
34
|
+
--scene-height: 250vh;
|
|
35
|
+
|
|
36
|
+
/* Duration of the transition between scenes when using CSS transitions on
|
|
37
|
+
scene-specific elements. */
|
|
38
|
+
--scene-transition-duration: 480ms;
|
|
39
|
+
|
|
40
|
+
/* Easing for scene element transitions. */
|
|
41
|
+
--scene-ease: var(--ease-in-out-quint);
|
|
42
|
+
|
|
43
|
+
/* Easing for elements entering during a scene. */
|
|
44
|
+
--scene-enter-ease: var(--ease-out-expo);
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Implementation
|
|
49
|
+
|
|
50
|
+
```html
|
|
51
|
+
<!-- Each .scene-stage is one scene. The inner .scene-content is the pinned viewport.
|
|
52
|
+
The outer .scene-stage provides the scroll track. -->
|
|
53
|
+
<div class="scene-stage">
|
|
54
|
+
<div class="scene-content" data-scene="1">
|
|
55
|
+
<div class="scene-panel panel-a">First beat content</div>
|
|
56
|
+
<div class="scene-panel panel-b">Second beat content</div>
|
|
57
|
+
</div>
|
|
58
|
+
</div>
|
|
59
|
+
|
|
60
|
+
<div class="scene-stage">
|
|
61
|
+
<div class="scene-content" data-scene="2">
|
|
62
|
+
<!-- ... -->
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
```css
|
|
68
|
+
.scene-stage {
|
|
69
|
+
/* The scroll track: tall enough for the scene's full timeline. */
|
|
70
|
+
height: var(--scene-height);
|
|
71
|
+
position: relative;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.scene-content {
|
|
75
|
+
/* Pin the content area for the height of its parent track. */
|
|
76
|
+
position: sticky;
|
|
77
|
+
top: 0;
|
|
78
|
+
height: 100vh;
|
|
79
|
+
overflow: hidden;
|
|
80
|
+
display: flex;
|
|
81
|
+
align-items: center;
|
|
82
|
+
justify-content: center;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/* ── CSS scroll-driven: advance panels via scroll progress ────────────────── */
|
|
86
|
+
.scene-panel {
|
|
87
|
+
position: absolute;
|
|
88
|
+
inset: 0;
|
|
89
|
+
display: flex;
|
|
90
|
+
align-items: center;
|
|
91
|
+
justify-content: center;
|
|
92
|
+
opacity: 0;
|
|
93
|
+
transform: translateY(40px);
|
|
94
|
+
animation: scene-enter var(--scene-transition-duration) var(--scene-enter-ease) forwards;
|
|
95
|
+
animation-timeline: view(block);
|
|
96
|
+
animation-range: entry 10% entry 40%;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
.scene-panel.panel-b {
|
|
100
|
+
/* Second panel enters at a later point in the scroll range. */
|
|
101
|
+
animation-range: entry 50% entry 80%;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
@keyframes scene-enter {
|
|
105
|
+
to {
|
|
106
|
+
opacity: 1;
|
|
107
|
+
transform: translateY(0);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
```js
|
|
113
|
+
// JS-driven alternative when precise scroll-progress control is needed
|
|
114
|
+
// (e.g., driving a canvas, SVG, or WebGL scene with scroll position).
|
|
115
|
+
|
|
116
|
+
function SceneController(stageEl) {
|
|
117
|
+
const contentEl = stageEl.querySelector('.scene-content');
|
|
118
|
+
|
|
119
|
+
function update() {
|
|
120
|
+
const rect = stageEl.getBoundingClientRect();
|
|
121
|
+
const trackHeight = stageEl.offsetHeight - window.innerHeight;
|
|
122
|
+
// progress: 0 = scene just pinned, 1 = scene about to unpin
|
|
123
|
+
const progress = Math.max(0, Math.min(1, -rect.top / trackHeight));
|
|
124
|
+
|
|
125
|
+
// Distribute progress to child panels
|
|
126
|
+
const panels = contentEl.querySelectorAll('.scene-panel');
|
|
127
|
+
const segmentSize = 1 / panels.length;
|
|
128
|
+
panels.forEach((panel, i) => {
|
|
129
|
+
const panelProgress = Math.max(0, Math.min(1,
|
|
130
|
+
(progress - i * segmentSize) / segmentSize
|
|
131
|
+
));
|
|
132
|
+
// Apply panelProgress to panel's opacity and transform
|
|
133
|
+
panel.style.opacity = panelProgress;
|
|
134
|
+
panel.style.transform = `translateY(${(1 - panelProgress) * 40}px)`;
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
window.addEventListener('scroll', update, { passive: true });
|
|
139
|
+
update();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
document.querySelectorAll('.scene-stage').forEach((el) => SceneController(el));
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## React
|
|
146
|
+
|
|
147
|
+
```tsx
|
|
148
|
+
import { motion, useScroll, useTransform } from 'framer-motion';
|
|
149
|
+
import { useRef } from 'react';
|
|
150
|
+
|
|
151
|
+
function StickyScene({ children }: { children: React.ReactNode }) {
|
|
152
|
+
const trackRef = useRef<HTMLDivElement>(null);
|
|
153
|
+
|
|
154
|
+
// scrollYProgress: 0 = scene pinned, 1 = scene unpinning
|
|
155
|
+
const { scrollYProgress } = useScroll({
|
|
156
|
+
target: trackRef,
|
|
157
|
+
offset: ['start start', 'end end'],
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
return (
|
|
161
|
+
// The track provides scroll height; the sticky wrapper pins content.
|
|
162
|
+
<div ref={trackRef} style={{ height: 'var(--scene-height, 250vh)' }}>
|
|
163
|
+
<div style={{ position: 'sticky', top: 0, height: '100vh' }}>
|
|
164
|
+
{/* Pass scrollYProgress to children via context or props
|
|
165
|
+
for per-element transform wiring */}
|
|
166
|
+
{children}
|
|
167
|
+
</div>
|
|
168
|
+
</div>
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// A single panel driven by scroll progress:
|
|
173
|
+
function ScenePanel({
|
|
174
|
+
enter, // [0..1] range at which panel enters
|
|
175
|
+
leave, // [0..1] range at which panel leaves
|
|
176
|
+
scrollProgress,
|
|
177
|
+
children,
|
|
178
|
+
}: {
|
|
179
|
+
enter: [number, number];
|
|
180
|
+
leave: [number, number];
|
|
181
|
+
scrollProgress: import('framer-motion').MotionValue<number>;
|
|
182
|
+
children: React.ReactNode;
|
|
183
|
+
}) {
|
|
184
|
+
const opacity = useTransform(scrollProgress, [enter[0], enter[1], leave[0], leave[1]], [0, 1, 1, 0]);
|
|
185
|
+
const y = useTransform(scrollProgress, [enter[0], enter[1]], [40, 0]);
|
|
186
|
+
|
|
187
|
+
return (
|
|
188
|
+
<motion.div style={{ opacity, y, position: 'absolute', inset: 0 }}>
|
|
189
|
+
{children}
|
|
190
|
+
</motion.div>
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
## Reduced-motion variant
|
|
196
|
+
|
|
197
|
+
In reduced-motion context, the sticky pinning is retained (it is a layout choice, not
|
|
198
|
+
a motion effect) but all animated transitions within the scene are removed. Each panel
|
|
199
|
+
is visible and the scroll track does not drive any per-frame style change.
|
|
200
|
+
|
|
201
|
+
```css
|
|
202
|
+
@media (prefers-reduced-motion: reduce) {
|
|
203
|
+
.scene-panel {
|
|
204
|
+
opacity: 1;
|
|
205
|
+
transform: none;
|
|
206
|
+
animation: none !important;
|
|
207
|
+
/* Stack panels and let the user read them in document flow. */
|
|
208
|
+
position: relative;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/* Disable the sticky pinning entirely in reduced-motion to restore normal scroll. */
|
|
212
|
+
.scene-content {
|
|
213
|
+
position: relative;
|
|
214
|
+
height: auto;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
.scene-stage {
|
|
218
|
+
height: auto;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
The JS controller should check `prefers-reduced-motion` and skip scroll-driven updates:
|
|
224
|
+
|
|
225
|
+
```js
|
|
226
|
+
const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
227
|
+
if (!prefersReduced) {
|
|
228
|
+
document.querySelectorAll('.scene-stage').forEach((el) => SceneController(el));
|
|
229
|
+
}
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
## Performance note
|
|
233
|
+
|
|
234
|
+
The CSS scroll-driven approach runs off the main thread in Chromium — zero JavaScript
|
|
235
|
+
per frame. The JS controller uses `{ passive: true }` on the scroll listener and updates
|
|
236
|
+
only `opacity` and `transform`, both compositor properties. Neither path touches layout
|
|
237
|
+
on scroll.
|
|
238
|
+
|
|
239
|
+
`will-change: transform` on pinned panels is appropriate here because these elements are
|
|
240
|
+
actively animated for the duration of the scene. Apply it at most to two or three panels
|
|
241
|
+
simultaneously; panels that have exited can have `will-change` removed.
|
|
242
|
+
|
|
243
|
+
The scroll track height (`--scene-height: 250vh`) determines how much scroll travel
|
|
244
|
+
each scene consumes. Longer tracks feel smoother on high-resolution trackpads; shorter
|
|
245
|
+
tracks feel abrupt. Test on both trackpad and scroll wheel — the scroll granularity
|
|
246
|
+
differs and a scene that feels cinematic on a MacBook may stutter on a mouse.
|