@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,233 @@
|
|
|
1
|
+
# Parallax
|
|
2
|
+
|
|
3
|
+
A parallax effect moves background and foreground elements at different rates as the
|
|
4
|
+
user scrolls, creating an illusion of depth. The CSS scroll-driven implementation
|
|
5
|
+
(`animation-timeline: scroll()`) moves an element by a fixed offset over the full
|
|
6
|
+
scroll range of its container, running off the main thread with no JavaScript per frame.
|
|
7
|
+
|
|
8
|
+
Read the restraint clause below before deciding whether to use this recipe. The evidence
|
|
9
|
+
for not using parallax is substantial, and the recipe's conditions are narrow.
|
|
10
|
+
|
|
11
|
+
## When it earns its place / When it does not
|
|
12
|
+
|
|
13
|
+
Condition: the content is a scroll-narrative — a case study, a product demo page, a
|
|
14
|
+
campaign microsite — and the concept explicitly requires a sense of depth or layering.
|
|
15
|
+
The parallax element contributes to the narrative; removing it would remove meaning, not
|
|
16
|
+
just decoration. The movement is subtle: the `--parallax-offset` is small (20–40px over
|
|
17
|
+
the full scroll range), producing depth rather than motion-sickness. `core/theory/
|
|
18
|
+
motion.md`: "use scroll-linked animation only when the content is itself a
|
|
19
|
+
scroll-narrative and the concept explicitly requires it."
|
|
20
|
+
|
|
21
|
+
Condition against: product UI. Navigation elements. Any page where the user is scanning
|
|
22
|
+
for content rather than experiencing a narrative. `core/theory/motion.md` cites the
|
|
23
|
+
NN/g evidence directly: users scroll quickly to scan for keywords; parallax requires slow
|
|
24
|
+
scrolling to read; users who scroll at normal speed miss the effect entirely, and users
|
|
25
|
+
with vestibular disorders are harmed by it. The NN/g research ("What Parallax Lacks",
|
|
26
|
+
2013) documents three failures — invisible to fast scrollers, classified as decorative
|
|
27
|
+
noise by experienced users, and a vestibular trigger for sensitive users. The 2024
|
|
28
|
+
performance data adds a fourth: parallax-heavy pages showed LCP exceeding 8 seconds and
|
|
29
|
+
poor INP scores, constituting Core Web Vitals failures.
|
|
30
|
+
|
|
31
|
+
If you are reaching for parallax because the page needs more visual interest, the correct
|
|
32
|
+
answer is a better layout or a stronger type decision — not depth simulation. Apply this
|
|
33
|
+
recipe only when the brief explicitly names the concept as depth-dependent and the scroll
|
|
34
|
+
narrative test is met.
|
|
35
|
+
|
|
36
|
+
## Parameters
|
|
37
|
+
|
|
38
|
+
```css
|
|
39
|
+
:root {
|
|
40
|
+
/* How far the element moves over the full scroll range of its container.
|
|
41
|
+
Keep small. 20–40px creates depth without visible motion-sickness.
|
|
42
|
+
Negative values move the element against the scroll direction (classic parallax).
|
|
43
|
+
Positive values move it with scroll (subtle, less disorienting). */
|
|
44
|
+
--parallax-offset: -30px;
|
|
45
|
+
|
|
46
|
+
/* Whether to apply the effect. Set to 'none' to disable without removing CSS. */
|
|
47
|
+
--parallax-enabled: block; /* unused in current implementation; see JS approach */
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Implementation
|
|
52
|
+
|
|
53
|
+
```html
|
|
54
|
+
<section class="parallax-section">
|
|
55
|
+
<!-- The background element moves at the slower rate. -->
|
|
56
|
+
<div class="parallax-bg" aria-hidden="true">
|
|
57
|
+
<!-- decorative image or gradient -->
|
|
58
|
+
</div>
|
|
59
|
+
|
|
60
|
+
<!-- Foreground content scrolls at normal speed. -->
|
|
61
|
+
<div class="parallax-content">
|
|
62
|
+
<h2>The infrastructure layer</h2>
|
|
63
|
+
</div>
|
|
64
|
+
</section>
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
```css
|
|
68
|
+
/* ── CSS scroll-driven (preferred; off-thread) ────────────────────────────── */
|
|
69
|
+
|
|
70
|
+
.parallax-section {
|
|
71
|
+
/* Establish a scroll timeline scoped to this container. */
|
|
72
|
+
overflow: hidden; /* contain the background overflow */
|
|
73
|
+
position: relative;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
.parallax-bg {
|
|
77
|
+
position: absolute;
|
|
78
|
+
inset: calc(var(--parallax-offset) * -1) 0; /* compensate so bg fills section at start */
|
|
79
|
+
background-image: var(--parallax-bg-image);
|
|
80
|
+
background-size: cover;
|
|
81
|
+
background-position: center;
|
|
82
|
+
|
|
83
|
+
/* The animation moves translateY from 0 to --parallax-offset over the scroll range. */
|
|
84
|
+
animation: parallax-drift linear both;
|
|
85
|
+
animation-timeline: scroll(root block); /* tracks document scroll, not section scroll */
|
|
86
|
+
animation-range: contain 0% contain 100%;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
@keyframes parallax-drift {
|
|
90
|
+
from { transform: translateY(0); }
|
|
91
|
+
to { transform: translateY(var(--parallax-offset)); }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/* ── IntersectionObserver + requestAnimationFrame fallback ───────────────── */
|
|
95
|
+
/* For browsers without scroll-driven support; also gives per-frame control. */
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
```js
|
|
99
|
+
// Only use the JS path when CSS scroll-driven is unavailable.
|
|
100
|
+
if (!CSS.supports('animation-timeline', 'scroll()')) {
|
|
101
|
+
document.querySelectorAll('.parallax-bg').forEach(attachParallax);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function attachParallax(el) {
|
|
105
|
+
const section = el.closest('.parallax-section');
|
|
106
|
+
if (!section) return;
|
|
107
|
+
|
|
108
|
+
const offset = parseFloat(
|
|
109
|
+
getComputedStyle(document.documentElement).getPropertyValue('--parallax-offset')
|
|
110
|
+
) || -30;
|
|
111
|
+
|
|
112
|
+
let ticking = false;
|
|
113
|
+
|
|
114
|
+
function update() {
|
|
115
|
+
const rect = section.getBoundingClientRect();
|
|
116
|
+
const viewH = window.innerHeight;
|
|
117
|
+
|
|
118
|
+
// Progress: 0 = section top at bottom of viewport, 1 = section bottom at top.
|
|
119
|
+
const progress = (viewH - rect.top) / (viewH + rect.height);
|
|
120
|
+
const clamped = Math.max(0, Math.min(1, progress));
|
|
121
|
+
|
|
122
|
+
// Apply only transform — compositor-only property.
|
|
123
|
+
el.style.transform = `translateY(${clamped * offset}px)`;
|
|
124
|
+
ticking = false;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
window.addEventListener('scroll', () => {
|
|
128
|
+
if (!ticking) {
|
|
129
|
+
requestAnimationFrame(update);
|
|
130
|
+
ticking = true;
|
|
131
|
+
}
|
|
132
|
+
}, { passive: true });
|
|
133
|
+
}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## React
|
|
137
|
+
|
|
138
|
+
```tsx
|
|
139
|
+
import { motion, useScroll, useTransform } from 'framer-motion';
|
|
140
|
+
import { useRef } from 'react';
|
|
141
|
+
|
|
142
|
+
function ParallaxSection({
|
|
143
|
+
children,
|
|
144
|
+
bgContent,
|
|
145
|
+
offset = -30,
|
|
146
|
+
}: {
|
|
147
|
+
children: React.ReactNode;
|
|
148
|
+
bgContent: React.ReactNode;
|
|
149
|
+
offset?: number; // px; negative = against scroll direction
|
|
150
|
+
}) {
|
|
151
|
+
const sectionRef = useRef<HTMLDivElement>(null);
|
|
152
|
+
|
|
153
|
+
const { scrollYProgress } = useScroll({
|
|
154
|
+
target: sectionRef,
|
|
155
|
+
offset: ['start end', 'end start'],
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
const y = useTransform(scrollYProgress, [0, 1], [0, offset]);
|
|
159
|
+
|
|
160
|
+
return (
|
|
161
|
+
<div ref={sectionRef} style={{ position: 'relative', overflow: 'hidden' }}>
|
|
162
|
+
{/* Background moves at a different rate */}
|
|
163
|
+
<motion.div
|
|
164
|
+
style={{
|
|
165
|
+
position: 'absolute',
|
|
166
|
+
inset: `${Math.abs(offset)}px 0`,
|
|
167
|
+
y,
|
|
168
|
+
}}
|
|
169
|
+
aria-hidden
|
|
170
|
+
>
|
|
171
|
+
{bgContent}
|
|
172
|
+
</motion.div>
|
|
173
|
+
|
|
174
|
+
{/* Foreground at normal scroll speed */}
|
|
175
|
+
<div style={{ position: 'relative' }}>
|
|
176
|
+
{children}
|
|
177
|
+
</div>
|
|
178
|
+
</div>
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
## Reduced-motion variant
|
|
184
|
+
|
|
185
|
+
In reduced-motion context, the parallax background is static — no scroll-linked
|
|
186
|
+
movement. The section renders identically to a non-parallax section with a normal
|
|
187
|
+
background image.
|
|
188
|
+
|
|
189
|
+
```css
|
|
190
|
+
@media (prefers-reduced-motion: reduce) {
|
|
191
|
+
.parallax-bg {
|
|
192
|
+
animation: none;
|
|
193
|
+
transform: none;
|
|
194
|
+
/* Restore to full-bleed static background. */
|
|
195
|
+
inset: 0;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
The JS path must also check:
|
|
201
|
+
|
|
202
|
+
```js
|
|
203
|
+
const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
204
|
+
if (!prefersReduced && !CSS.supports('animation-timeline', 'scroll()')) {
|
|
205
|
+
document.querySelectorAll('.parallax-bg').forEach(attachParallax);
|
|
206
|
+
}
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
This is one of the few recipes where the reduced-motion variant genuinely improves the
|
|
210
|
+
experience for a broader audience — not just the audience that has set the preference.
|
|
211
|
+
As `core/theory/motion.md` notes, many users who experience vestibular discomfort from
|
|
212
|
+
parallax have not discovered or set the `prefers-reduced-motion` preference. The
|
|
213
|
+
restraint clause above (applying this recipe narrowly) is the stronger intervention.
|
|
214
|
+
|
|
215
|
+
## Performance note
|
|
216
|
+
|
|
217
|
+
The CSS scroll-driven path runs entirely off the main thread in Chromium. The element's
|
|
218
|
+
layer is promoted by the animation; the scroll position update drives the transform
|
|
219
|
+
without touching JavaScript. This is measurably lighter than the JS `requestAnimationFrame`
|
|
220
|
+
path, which fires a callback, computes a value, and writes a style on every scroll frame.
|
|
221
|
+
|
|
222
|
+
The JS path uses the `ticking` pattern to batch updates to one `requestAnimationFrame`
|
|
223
|
+
per scroll event burst — standard debounce for scroll handlers. The `{ passive: true }`
|
|
224
|
+
flag is required; without it the browser cannot optimise scroll performance.
|
|
225
|
+
|
|
226
|
+
`will-change: transform` on the parallax element is applied implicitly by the scroll-
|
|
227
|
+
driven animation and the Framer Motion `useTransform` hook. Do not add it manually on
|
|
228
|
+
top; double promotion provides no benefit.
|
|
229
|
+
|
|
230
|
+
Keep `--parallax-offset` small. A 30px offset over a full-viewport-height section
|
|
231
|
+
produces a 3% depth differential — perceptible as depth without producing foreground-
|
|
232
|
+
background collision. At 100px on a 400px-tall section, foreground and background
|
|
233
|
+
visibly separate, causing the page to look broken rather than deep.
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# Scroll reveal
|
|
2
|
+
|
|
3
|
+
A scroll reveal makes content visible as it enters the viewport — opacity rising from
|
|
4
|
+
zero, a small translate completing to zero — so that each section announces itself as
|
|
5
|
+
the user scrolls toward it rather than sitting pre-exposed on a long static page.
|
|
6
|
+
|
|
7
|
+
The implementation hierarchy is: CSS scroll-driven animations first (`animation-timeline:
|
|
8
|
+
view()`), IntersectionObserver as the fallback for broader browser support. The static
|
|
9
|
+
layout — what the page shows when no animation fires — must be complete and legible.
|
|
10
|
+
|
|
11
|
+
## When it earns its place / When it does not
|
|
12
|
+
|
|
13
|
+
Condition: the content is a scroll narrative — sections have a sequential logic, and the
|
|
14
|
+
reveal of each section reinforces the order of reading. A portfolio case study where
|
|
15
|
+
chapters arrive one by one, a product page where features emerge in the sequence the
|
|
16
|
+
concept requires. The animation confirms what the layout already implies: read this, then
|
|
17
|
+
this, then this.
|
|
18
|
+
|
|
19
|
+
Condition against: product UI that the user came to operate. Navigation elements. Any
|
|
20
|
+
content the user is scrolling to find — when the destination is below the fold and the
|
|
21
|
+
user is scanning for it, a reveal that delays visibility is a usability failure dressed
|
|
22
|
+
as an aesthetic choice. `expressive.md`: "A reveal that delays reading is a usability
|
|
23
|
+
failure regardless of how well-timed it is." Also: `core/theory/motion.md` — entrance
|
|
24
|
+
animations that replay on scroll turn scrolling into a performance the user must watch.
|
|
25
|
+
Scroll-triggered reveal belongs on presentation pages, not on tools.
|
|
26
|
+
|
|
27
|
+
## Parameters
|
|
28
|
+
|
|
29
|
+
```css
|
|
30
|
+
:root {
|
|
31
|
+
/* How far the element travels upward on entrance. 20–32px is the legible range;
|
|
32
|
+
larger values read as falling rather than arriving. */
|
|
33
|
+
--reveal-y: 28px;
|
|
34
|
+
|
|
35
|
+
/* Duration of the reveal animation. Slightly longer than a load entrance because
|
|
36
|
+
scroll-triggered context gives the user more time to process. 280–380ms. */
|
|
37
|
+
--duration-reveal: 340ms;
|
|
38
|
+
|
|
39
|
+
/* Easing from core/motion/easing.md. ease-out-quint for graceful arrival;
|
|
40
|
+
ease-out-expo for decisive arrival. */
|
|
41
|
+
--ease-reveal: var(--ease-out-quint);
|
|
42
|
+
|
|
43
|
+
/* For staggered groups (see stagger-orchestrator.md), this is the per-item delay. */
|
|
44
|
+
--stagger-reveal: 60ms;
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Implementation
|
|
49
|
+
|
|
50
|
+
```css
|
|
51
|
+
/* ── CSS scroll-driven (preferred; Chromium 115+, Safari 18+) ─────────────── */
|
|
52
|
+
|
|
53
|
+
.reveal {
|
|
54
|
+
animation: reveal-up var(--duration-reveal) var(--ease-reveal) both;
|
|
55
|
+
animation-timeline: view();
|
|
56
|
+
/* entry 0% = element starts entering viewport; 25% = comfortably inside. */
|
|
57
|
+
animation-range: entry 0% entry 25%;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
@keyframes reveal-up {
|
|
61
|
+
from {
|
|
62
|
+
opacity: 0;
|
|
63
|
+
transform: translateY(var(--reveal-y));
|
|
64
|
+
}
|
|
65
|
+
to {
|
|
66
|
+
opacity: 1;
|
|
67
|
+
transform: translateY(0);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/* ── IntersectionObserver fallback for browsers without scroll-driven support ── */
|
|
72
|
+
/* Start hidden; JS adds .is-visible when the element intersects. */
|
|
73
|
+
@supports not (animation-timeline: view()) {
|
|
74
|
+
.reveal {
|
|
75
|
+
opacity: 0;
|
|
76
|
+
transform: translateY(var(--reveal-y));
|
|
77
|
+
transition: opacity var(--duration-reveal) var(--ease-reveal),
|
|
78
|
+
transform var(--duration-reveal) var(--ease-reveal);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.reveal.is-visible {
|
|
82
|
+
opacity: 1;
|
|
83
|
+
transform: translateY(0);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
```js
|
|
89
|
+
// Only runs in browsers that lack scroll-driven animation support.
|
|
90
|
+
if (!CSS.supports('animation-timeline', 'view()')) {
|
|
91
|
+
const observer = new IntersectionObserver(
|
|
92
|
+
(entries) => {
|
|
93
|
+
entries.forEach((entry) => {
|
|
94
|
+
if (entry.isIntersecting) {
|
|
95
|
+
entry.target.classList.add('is-visible');
|
|
96
|
+
// Reveal fires once. Unobserve after first intersection.
|
|
97
|
+
observer.unobserve(entry.target);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
},
|
|
101
|
+
{ threshold: 0.15 } // 15% of element visible triggers reveal
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
document.querySelectorAll('.reveal').forEach((el) => observer.observe(el));
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## React
|
|
109
|
+
|
|
110
|
+
```tsx
|
|
111
|
+
import { motion, useInView } from 'framer-motion';
|
|
112
|
+
import { useRef } from 'react';
|
|
113
|
+
|
|
114
|
+
const revealVariants = {
|
|
115
|
+
hidden: { opacity: 0, y: 28 },
|
|
116
|
+
visible: {
|
|
117
|
+
opacity: 1,
|
|
118
|
+
y: 0,
|
|
119
|
+
transition: {
|
|
120
|
+
duration: 0.34,
|
|
121
|
+
ease: [0.22, 1, 0.36, 1], // --ease-out-quint
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
function Reveal({ children, className }: { children: React.ReactNode; className?: string }) {
|
|
127
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
128
|
+
// once: true — reveals fire once, not on every scroll pass.
|
|
129
|
+
const isInView = useInView(ref, { once: true, margin: '-10% 0px' });
|
|
130
|
+
|
|
131
|
+
return (
|
|
132
|
+
<motion.div
|
|
133
|
+
ref={ref}
|
|
134
|
+
className={className}
|
|
135
|
+
variants={revealVariants}
|
|
136
|
+
initial="hidden"
|
|
137
|
+
animate={isInView ? 'visible' : 'hidden'}
|
|
138
|
+
>
|
|
139
|
+
{children}
|
|
140
|
+
</motion.div>
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// For staggered groups, wrap in a container with staggerChildren:
|
|
145
|
+
const groupVariants = {
|
|
146
|
+
hidden: {},
|
|
147
|
+
visible: { transition: { staggerChildren: 0.06 } },
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
function RevealGroup({ children }: { children: React.ReactNode }) {
|
|
151
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
152
|
+
const isInView = useInView(ref, { once: true, margin: '-10% 0px' });
|
|
153
|
+
|
|
154
|
+
return (
|
|
155
|
+
<motion.div
|
|
156
|
+
ref={ref}
|
|
157
|
+
variants={groupVariants}
|
|
158
|
+
initial="hidden"
|
|
159
|
+
animate={isInView ? 'visible' : 'hidden'}
|
|
160
|
+
>
|
|
161
|
+
{children}
|
|
162
|
+
</motion.div>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## Reduced-motion variant
|
|
168
|
+
|
|
169
|
+
The reduced-motion state must be a complete, legible layout — not an empty page waiting
|
|
170
|
+
for JavaScript, not hidden content. In reduced-motion context, elements are visible from
|
|
171
|
+
the start. No fade, no translate, no delay.
|
|
172
|
+
|
|
173
|
+
```css
|
|
174
|
+
@media (prefers-reduced-motion: reduce) {
|
|
175
|
+
.reveal {
|
|
176
|
+
/* Override both the scroll-driven animation and the IO fallback. */
|
|
177
|
+
animation: none !important;
|
|
178
|
+
opacity: 1 !important;
|
|
179
|
+
transform: none !important;
|
|
180
|
+
transition: none !important;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
The IntersectionObserver JS should also check before adding opacity:
|
|
186
|
+
|
|
187
|
+
```js
|
|
188
|
+
const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
189
|
+
if (!prefersReduced && !CSS.supports('animation-timeline', 'view()')) {
|
|
190
|
+
// ... observer setup
|
|
191
|
+
}
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
## Performance note
|
|
195
|
+
|
|
196
|
+
`animation-timeline: view()` runs off the main thread in Chromium — the browser's
|
|
197
|
+
compositor advances the animation as scroll position changes, with no JavaScript
|
|
198
|
+
involvement per frame. This is why it is preferred over IntersectionObserver: IO fires
|
|
199
|
+
a JS callback, which schedules a style change, which runs on the main thread. Both are
|
|
200
|
+
acceptable; the scroll-driven path is measurably lighter.
|
|
201
|
+
|
|
202
|
+
Animate only `opacity` and `transform`. The `translateY` in `--reveal-y` is a
|
|
203
|
+
composited property; it does not trigger layout. If you are tempted to reveal by changing
|
|
204
|
+
`height`, `padding`, or `margin`, the answer is no — use `transform: scaleY()` from a
|
|
205
|
+
`transform-origin: top` if a size change is genuinely required.
|
|
206
|
+
|
|
207
|
+
Do not apply `will-change: transform` to every `.reveal` element on the page. On a page
|
|
208
|
+
with twenty reveal elements, this promotes twenty compositor layers simultaneously and
|
|
209
|
+
consumes GPU memory on mobile hardware. The browser promotes elements as needed during
|
|
210
|
+
the animation; pre-promoting them does not help and costs memory.
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
# Section colour inversion
|
|
2
|
+
|
|
3
|
+
A section colour inversion swaps the page's colour scheme — dark background to light,
|
|
4
|
+
or light to dark — at a specific scroll threshold, marking a conceptual pivot in the
|
|
5
|
+
content. The background and text colours switch together so the palette inverts as a
|
|
6
|
+
unit rather than changing element by element.
|
|
7
|
+
|
|
8
|
+
## When it earns its place / When it does not
|
|
9
|
+
|
|
10
|
+
Condition: the scroll narrative has a turn — a moment where the register shifts to mark
|
|
11
|
+
a conceptual pivot. The inversion must correspond to a narrative event: a before/after
|
|
12
|
+
split, a transition from problem to solution, a shift from the user's world to the
|
|
13
|
+
product's. The colour change makes the pivot felt as well as read. `expressive.md`:
|
|
14
|
+
"The inversion makes the change visible and felt, not just read. One inversion per page
|
|
15
|
+
is usually correct; more than one is a rhythm, not a pivot."
|
|
16
|
+
|
|
17
|
+
Condition against: arbitrary decoration. Using two inversions because the page has three
|
|
18
|
+
sections reads as a pattern, not a moment — the technique has been applied to a grid
|
|
19
|
+
rather than a concept. Also: inversion without a corresponding narrative event is visual
|
|
20
|
+
noise. If you cannot complete the sentence "This is where the page pivots from __ to __",
|
|
21
|
+
the inversion does not belong.
|
|
22
|
+
|
|
23
|
+
The cost: users who have accessibility settings that invert colours or use high-contrast
|
|
24
|
+
modes will double-invert. Test in Windows High Contrast and macOS Invert Colours modes.
|
|
25
|
+
|
|
26
|
+
## Parameters
|
|
27
|
+
|
|
28
|
+
```css
|
|
29
|
+
:root {
|
|
30
|
+
/* The two poles of the inversion. Board-sourced tokens — never raw hex here. */
|
|
31
|
+
--color-bg-light: var(--surface-primary); /* light section background */
|
|
32
|
+
--color-text-light: var(--text-primary); /* light section text */
|
|
33
|
+
--color-bg-dark: var(--surface-inverse); /* dark section background */
|
|
34
|
+
--color-text-dark: var(--text-inverse); /* dark section text */
|
|
35
|
+
|
|
36
|
+
/* Duration of the transition when the inversion fires.
|
|
37
|
+
Faster than a typical content transition — the switch should read as a cut,
|
|
38
|
+
not a dissolve. 200–280ms. */
|
|
39
|
+
--duration-invert: 240ms;
|
|
40
|
+
--ease-invert: var(--ease-out-quint);
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Implementation
|
|
45
|
+
|
|
46
|
+
The cleanest approach: a `data-theme` attribute on the section toggles between two
|
|
47
|
+
CSS variable sets. One IntersectionObserver or scroll-driven animation flips the
|
|
48
|
+
attribute. The body or a persistent header that spans sections must respond to the
|
|
49
|
+
active section's theme.
|
|
50
|
+
|
|
51
|
+
```html
|
|
52
|
+
<section class="section" data-theme="light">
|
|
53
|
+
<!-- ... light section content ... -->
|
|
54
|
+
</section>
|
|
55
|
+
|
|
56
|
+
<section class="section" data-theme="dark">
|
|
57
|
+
<!-- ... pivot section — the narrative turn ... -->
|
|
58
|
+
</section>
|
|
59
|
+
|
|
60
|
+
<section class="section" data-theme="light">
|
|
61
|
+
<!-- ... resolution section ... -->
|
|
62
|
+
</section>
|
|
63
|
+
|
|
64
|
+
<!-- A persistent header tracks the active section's theme -->
|
|
65
|
+
<header class="site-header" data-theme="light"></header>
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
```css
|
|
69
|
+
/* Define both poles as CSS variable sets. */
|
|
70
|
+
[data-theme="light"] {
|
|
71
|
+
--bg: var(--color-bg-light);
|
|
72
|
+
--text: var(--color-text-light);
|
|
73
|
+
--border: var(--color-border-light, rgba(0,0,0,0.08));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
[data-theme="dark"] {
|
|
77
|
+
--bg: var(--color-bg-dark);
|
|
78
|
+
--text: var(--color-text-dark);
|
|
79
|
+
--border: var(--color-border-dark, rgba(255,255,255,0.12));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.section {
|
|
83
|
+
background-color: var(--bg);
|
|
84
|
+
color: var(--text);
|
|
85
|
+
/* Instant switch — no transition on section background itself. The transition
|
|
86
|
+
is on the header and any sticky element that spans sections. */
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/* Optional: a very short opacity dip on direct section children softens the
|
|
90
|
+
colour inversion by briefly fading content as the palette switches. This is
|
|
91
|
+
a compositor-only property (opacity) — no layout cost. */
|
|
92
|
+
.section > * {
|
|
93
|
+
transition: opacity var(--duration-invert) var(--ease-invert);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
[data-theme] .section > * {
|
|
97
|
+
opacity: 1;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/* The sticky header transitions between themes smoothly. */
|
|
101
|
+
.site-header {
|
|
102
|
+
position: sticky;
|
|
103
|
+
top: 0;
|
|
104
|
+
background-color: var(--bg);
|
|
105
|
+
color: var(--text);
|
|
106
|
+
transition:
|
|
107
|
+
background-color var(--duration-invert) var(--ease-invert),
|
|
108
|
+
color var(--duration-invert) var(--ease-invert);
|
|
109
|
+
}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
```js
|
|
113
|
+
// Track which section is currently dominant in the viewport and update the header.
|
|
114
|
+
function trackSectionThemes() {
|
|
115
|
+
const header = document.querySelector('.site-header');
|
|
116
|
+
if (!header) return;
|
|
117
|
+
|
|
118
|
+
const sections = document.querySelectorAll('.section[data-theme]');
|
|
119
|
+
|
|
120
|
+
const observer = new IntersectionObserver(
|
|
121
|
+
(entries) => {
|
|
122
|
+
entries.forEach((entry) => {
|
|
123
|
+
if (entry.isIntersecting && entry.intersectionRatio >= 0.5) {
|
|
124
|
+
const theme = entry.target.getAttribute('data-theme');
|
|
125
|
+
if (theme) header.setAttribute('data-theme', theme);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
},
|
|
129
|
+
{ threshold: 0.5 }
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
sections.forEach((s) => observer.observe(s));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
document.addEventListener('DOMContentLoaded', trackSectionThemes);
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## React
|
|
139
|
+
|
|
140
|
+
```tsx
|
|
141
|
+
import { useEffect, useRef, useState } from 'react';
|
|
142
|
+
import { motion, AnimatePresence } from 'framer-motion';
|
|
143
|
+
|
|
144
|
+
type Theme = 'light' | 'dark';
|
|
145
|
+
|
|
146
|
+
function useActiveTheme(sections: React.RefObject<HTMLElement>[]) {
|
|
147
|
+
const [activeTheme, setActiveTheme] = useState<Theme>('light');
|
|
148
|
+
|
|
149
|
+
useEffect(() => {
|
|
150
|
+
const observer = new IntersectionObserver(
|
|
151
|
+
(entries) => {
|
|
152
|
+
entries.forEach((entry) => {
|
|
153
|
+
if (entry.isIntersecting && entry.intersectionRatio >= 0.5) {
|
|
154
|
+
const theme = (entry.target as HTMLElement).dataset.theme as Theme;
|
|
155
|
+
if (theme) setActiveTheme(theme);
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
},
|
|
159
|
+
{ threshold: 0.5 }
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
sections.forEach((ref) => {
|
|
163
|
+
if (ref.current) observer.observe(ref.current);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
return () => observer.disconnect();
|
|
167
|
+
}, [sections]);
|
|
168
|
+
|
|
169
|
+
return activeTheme;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function SiteHeader({ theme }: { theme: Theme }) {
|
|
173
|
+
return (
|
|
174
|
+
<motion.header
|
|
175
|
+
animate={{
|
|
176
|
+
backgroundColor: theme === 'dark' ? 'var(--color-bg-dark)' : 'var(--color-bg-light)',
|
|
177
|
+
color: theme === 'dark' ? 'var(--color-text-dark)' : 'var(--color-text-light)',
|
|
178
|
+
}}
|
|
179
|
+
transition={{ duration: 0.24, ease: [0.22, 1, 0.36, 1] }}
|
|
180
|
+
style={{ position: 'sticky', top: 0 }}
|
|
181
|
+
>
|
|
182
|
+
{/* header content */}
|
|
183
|
+
</motion.header>
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
## Reduced-motion variant
|
|
189
|
+
|
|
190
|
+
The sections retain their different background colours — the inversion is a layout
|
|
191
|
+
choice, not purely a motion effect. The transition on the header is removed so the
|
|
192
|
+
switch is instant rather than animated.
|
|
193
|
+
|
|
194
|
+
```css
|
|
195
|
+
@media (prefers-reduced-motion: reduce) {
|
|
196
|
+
.site-header {
|
|
197
|
+
transition: none;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
The IntersectionObserver continues to run and update the `data-theme` attribute (correct
|
|
203
|
+
behaviour — the sections genuinely have different themes). Only the animated transition
|
|
204
|
+
is removed.
|
|
205
|
+
|
|
206
|
+
## Performance note
|
|
207
|
+
|
|
208
|
+
Background colour transitions on a sticky header trigger the paint step but not the
|
|
209
|
+
layout step — the dimensions of the header do not change. On modern browsers this is
|
|
210
|
+
GPU-composited for elements with `will-change: transform` already applied (a sticky
|
|
211
|
+
element implicitly gets this in most implementations). The transition is not on
|
|
212
|
+
`transform` or `opacity`, so it is not off-thread, but the paint cost for a single
|
|
213
|
+
header background colour change is negligible.
|
|
214
|
+
|
|
215
|
+
Do not animate `color` on large blocks of text — at scale this triggers text-specific
|
|
216
|
+
paint operations on every frame. The inversion pattern here transitions only the header's
|
|
217
|
+
colour; section backgrounds switch instantly (no transition on `.section` itself).
|