@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.
Files changed (131) hide show
  1. package/LICENSE +21 -0
  2. package/README.ko.md +282 -0
  3. package/README.md +282 -0
  4. package/adapters/build.ts +184 -0
  5. package/adapters/claude.ts +111 -0
  6. package/adapters/codex.ts +68 -0
  7. package/adapters/tokens.ts +21 -0
  8. package/adapters/tool-map.json +7 -0
  9. package/bin/omd-install.ts +76 -0
  10. package/bin/omd.ts +1774 -0
  11. package/core/asset-sourcing/index.ts +187 -0
  12. package/core/coach/index.ts +98 -0
  13. package/core/composition/app-shell-workbench.md +168 -0
  14. package/core/composition/asymmetric-diagonal-grid.md +178 -0
  15. package/core/composition/bento-grid.md +223 -0
  16. package/core/composition/editorial-index-labels.md +173 -0
  17. package/core/composition/form-wizard-stepper.md +132 -0
  18. package/core/composition/master-detail-flow.md +129 -0
  19. package/core/composition/section-inversion.md +182 -0
  20. package/core/composition/sidebar-margin-annotation.md +184 -0
  21. package/core/composition/split-screen-hero.md +224 -0
  22. package/core/composition/sticky-sidebar-scroll.md +219 -0
  23. package/core/composition/typographic-hero.md +100 -0
  24. package/core/composition-contract/index.ts +233 -0
  25. package/core/composition-contract/visual-richness.ts +101 -0
  26. package/core/config/index.ts +27 -0
  27. package/core/copy/index.ts +296 -0
  28. package/core/craft/finish-pass.md +356 -0
  29. package/core/craft/index.ts +33 -0
  30. package/core/design/index.ts +581 -0
  31. package/core/design/interaction-states.ts +189 -0
  32. package/core/eval-harness/index.ts +225 -0
  33. package/core/evidence/final.ts +444 -0
  34. package/core/evidence/task.ts +459 -0
  35. package/core/figma/client.ts +312 -0
  36. package/core/figma/diff.ts +261 -0
  37. package/core/figma/responsive.ts +310 -0
  38. package/core/figma/system.ts +466 -0
  39. package/core/figma/types.ts +185 -0
  40. package/core/frame/check-ux.ts +180 -0
  41. package/core/frame/index.ts +39 -0
  42. package/core/frame/write.ts +168 -0
  43. package/core/graphics/css-illustration-primitives.md +189 -0
  44. package/core/graphics/duotone-image-presets.md +173 -0
  45. package/core/graphics/gradient-mesh.md +178 -0
  46. package/core/graphics/noise-grain-texture.md +150 -0
  47. package/core/graphics/placeholder-policy.md +185 -0
  48. package/core/graphics/svg-geometric-patterns.md +145 -0
  49. package/core/history/index.ts +39 -0
  50. package/core/install/detect.ts +32 -0
  51. package/core/install/install.ts +321 -0
  52. package/core/install/patch-claude.ts +109 -0
  53. package/core/install/patch-codex.ts +82 -0
  54. package/core/interaction/index.ts +101 -0
  55. package/core/interaction/recipes/signature-lighting.md +203 -0
  56. package/core/ir/dom.ts +352 -0
  57. package/core/ir/normalize.ts +177 -0
  58. package/core/motion/easing.md +151 -0
  59. package/core/motion/energy.ts +188 -0
  60. package/core/motion/recipes/image-hover-distortion.md +198 -0
  61. package/core/motion/recipes/magnetic-hover.md +229 -0
  62. package/core/motion/recipes/marquee.md +202 -0
  63. package/core/motion/recipes/number-counter.md +242 -0
  64. package/core/motion/recipes/page-loader.md +240 -0
  65. package/core/motion/recipes/parallax.md +233 -0
  66. package/core/motion/recipes/scroll-reveal.md +210 -0
  67. package/core/motion/recipes/section-color-inversion.md +217 -0
  68. package/core/motion/recipes/split-text-entrance.md +222 -0
  69. package/core/motion/recipes/stagger-orchestrator.md +227 -0
  70. package/core/motion/recipes/sticky-scene-transition.md +246 -0
  71. package/core/motion/recipes/view-transitions.md +239 -0
  72. package/core/probe/index.ts +186 -0
  73. package/core/probe/schema.json +46 -0
  74. package/core/protocol/composition-contract.md +192 -0
  75. package/core/protocol/copy-deck.md +96 -0
  76. package/core/protocol/human-design-loop.md +447 -0
  77. package/core/protocol/slop-review.md +71 -0
  78. package/core/ref/batch.ts +94 -0
  79. package/core/ref/blueprint.ts +199 -0
  80. package/core/ref/distance.ts +225 -0
  81. package/core/ref/invariants.ts +162 -0
  82. package/core/ref/signal.ts +37 -0
  83. package/core/ref/store.ts +122 -0
  84. package/core/render/index.ts +607 -0
  85. package/core/rules/attribution.ts +159 -0
  86. package/core/rules/builtin/contrast.yaml +8 -0
  87. package/core/rules/builtin/focus.yaml +8 -0
  88. package/core/rules/builtin/hit-area.yaml +8 -0
  89. package/core/rules/builtin/ko.yaml +6 -0
  90. package/core/rules/builtin/motion.yaml +80 -0
  91. package/core/rules/builtin/slop.yaml +365 -0
  92. package/core/rules/builtin/spacing.yaml +9 -0
  93. package/core/rules/builtin/system.yaml +42 -0
  94. package/core/rules/builtin/token.yaml +8 -0
  95. package/core/rules/builtin/ux.yaml +125 -0
  96. package/core/rules/engine.ts +88 -0
  97. package/core/rules/leakage.ts +72 -0
  98. package/core/rules/motion-spec.ts +204 -0
  99. package/core/site/index.ts +100 -0
  100. package/core/slop/index.ts +427 -0
  101. package/core/slop/text-slop.ts +220 -0
  102. package/core/source-seal/index.ts +174 -0
  103. package/core/stack/index.ts +53 -0
  104. package/core/target/index.ts +183 -0
  105. package/core/theory/color.md +285 -0
  106. package/core/theory/components.md +272 -0
  107. package/core/theory/craft.md +270 -0
  108. package/core/theory/expressive.md +213 -0
  109. package/core/theory/imagegen.md +105 -0
  110. package/core/theory/layout.md +159 -0
  111. package/core/theory/motion.md +283 -0
  112. package/core/theory/typography.md +134 -0
  113. package/core/theory/ux.md +580 -0
  114. package/core/theory/voice.md +595 -0
  115. package/core/types.ts +684 -0
  116. package/package.json +40 -0
  117. package/src/agents/composer.agent.yaml +174 -0
  118. package/src/agents/eye.agent.yaml +208 -0
  119. package/src/agents/framer.agent.yaml +52 -0
  120. package/src/agents/glance.agent.yaml +19 -0
  121. package/src/agents/hand.agent.yaml +200 -0
  122. package/src/agents/scout.agent.yaml +71 -0
  123. package/src/agents/sketch.agent.yaml +64 -0
  124. package/src/agents/typesetter.agent.yaml +52 -0
  125. package/src/agents/writer.agent.yaml +63 -0
  126. package/src/skills/omd-coach/SKILL.md +40 -0
  127. package/src/skills/omd-critique/SKILL.md +77 -0
  128. package/src/skills/omd-figma/SKILL.md +256 -0
  129. package/src/skills/omd-humanize/SKILL.md +160 -0
  130. package/src/skills/omd-scout/SKILL.md +77 -0
  131. 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.