@polymorphism-tech/morph-spec 8.28.0 → 8.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/agents.json +14 -3
- package/framework/commands/create-umbraco-lp.md +1 -1
- package/framework/mcp/registry.json +59 -8
- package/framework/skills/core/create-umbraco-lp/SKILL.md +13 -2
- package/framework/skills/core/create-umbraco-lp/references/umbraco-lp-patterns.md +598 -0
- package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +92 -7
- package/framework/skills/core/morph-apply/SKILL.md +1 -1
- package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
- package/framework/skills/core/morph-eval/SKILL.md +1 -1
- package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
- package/framework/skills/core/morph-implement/SKILL.md +1 -1
- package/framework/skills/core/morph-plan/SKILL.md +1 -1
- package/framework/skills/core/morph-proposal/SKILL.md +1 -1
- package/framework/skills/core/morph-review/SKILL.md +1 -1
- package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
- package/framework/skills/core/morph-standards/SKILL.md +1 -1
- package/framework/skills/core/morph-uiux/SKILL.md +1 -1
- package/framework/standards/PROVENANCE.json +41 -0
- package/framework/standards/STANDARDS.json +218 -7
- package/framework/standards/frontend/design-system/ai-image-generation.md +43 -0
- package/framework/standards/frontend/design-system/ai-video-generation.md +113 -0
- package/framework/standards/frontend/design-system/premium-finish.md +160 -0
- package/framework/standards/frontend/gsap/gsap-core.md +1 -1
- package/framework/standards/frontend/gsap/gsap-plugins.md +1 -1
- package/framework/standards/frontend/gsap/gsap-scrolltrigger.md +3 -1
- package/framework/standards/frontend/nextjs/motion-patterns.md +3 -1
- package/framework/standards/frontend/scroll-driven/frame-scrub.md +287 -0
- package/framework/standards/frontend/scroll-driven/scroll-components.md +256 -0
- package/framework/standards/frontend/scroll-driven/smooth-scroll.md +148 -0
- package/framework/standards/frontend/seo/meta-schema-audit.md +134 -0
- package/package.json +1 -1
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
# Canvas Frame Scrub — Scroll-Driven Video
|
|
2
|
+
|
|
3
|
+
> **Scope:** frontend-scroll-driven
|
|
4
|
+
> **Layer:** 3
|
|
5
|
+
> **Keywords:** frame-scrub, canvas, scroll-driven video, scrubbing, ffmpeg, apple-style, poster fallback
|
|
6
|
+
> **Load When:** ui-designer active, or the brief says scroll controls a video
|
|
7
|
+
|
|
8
|
+
**Verified against:** GSAP 3.13 ScrollTrigger + Lenis 1.x + ffmpeg 6.x, shipped to production. Last-verified: 2026-08-21.
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
Reference for building a hero (or any section) where scrolling advances a video frame by frame, drawn into a `<canvas>`. This is the Apple product-page mechanic. It covers why canvas beats `<video>`, frame extraction with ffmpeg, the focal-point cover draw, the resize-on-visible bug, cache busting, the mobile poster fallback, and how to verify the result with motion instead of a still.
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## Section 1: Canvas, Not `<video currentTime>`
|
|
17
|
+
|
|
18
|
+
Two failure modes rule out driving a `<video>` element from scroll position:
|
|
19
|
+
|
|
20
|
+
1. **`<video currentTime>` is unreliable on iOS Safari.** Decode throttling and autoplay restrictions make seeking stutter, and swapping the source can flash a black frame.
|
|
21
|
+
2. **An autoplaying `<video>` is not a scroll animation at all.** It reads as a demo running by itself rather than something the user drives. If the requirement is "scroll controls the video", a looping autoplay video does not satisfy it.
|
|
22
|
+
|
|
23
|
+
A canvas fed by a preloaded image sequence works cross-browser and never flashes.
|
|
24
|
+
|
|
25
|
+
A self-playing ambient loop is the exception, not the default: use it only for a secondary background plane, never for the primary hero.
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## Section 2: Frame Extraction
|
|
30
|
+
|
|
31
|
+
Extract the approved `.mp4` into a JPG sequence:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
ffmpeg -i source.mp4 -vf "fps=30,scale=1280:-1" -q:v 5 frames/frame_%03d.jpg
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
| Knob | Guidance |
|
|
38
|
+
|---|---|
|
|
39
|
+
| Duration and fps | 4 to 10s at 30fps yields roughly 120 to 300 frames. Around 145 is a good balance of smooth and light. |
|
|
40
|
+
| `-q:v` | 5 balances quality against weight. |
|
|
41
|
+
| Numbering | `%03d` (frame_001) matches the 1-based `framePath` contract in Section 4. |
|
|
42
|
+
| Format | JPG is the safe default (fast decode, universal). If the sequence gets heavy (tens of MB), also try WebP via `-c:v libwebp -q:v 70`, which usually cuts total weight at similar quality. |
|
|
43
|
+
|
|
44
|
+
**Treat total sequence weight as a performance budget.** It shows up directly in Lighthouse. If it is too heavy, cut frame count (120 instead of 145) or resolution before cutting quality.
|
|
45
|
+
|
|
46
|
+
**Mirror the footage when the subject collides with the copy.** If the subject sits on the side where text goes, flip it during extraction:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
ffmpeg -i source.mp4 -vf "hflip,fps=30,scale=1664:-1" -q:v 5 frames/frame_%03d.jpg
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
**Always extract a poster.** Pick a frame with the subject well composed and save it as `poster.jpg`. It backs the mobile and reduced-motion paths.
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## Section 3: Cache Busting Is Mandatory
|
|
57
|
+
|
|
58
|
+
Re-extracting frames (for example going from 480p to 1080p) while keeping the same filenames means the browser serves the stale ones and nothing appears to change.
|
|
59
|
+
|
|
60
|
+
Always version the frame URL and bump `N` on every re-extraction:
|
|
61
|
+
|
|
62
|
+
```js
|
|
63
|
+
const framePath = (i) => `/hero-frames/frame_${String(i).padStart(3, '0')}.jpg?v=2`
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
## Section 4: The Component
|
|
69
|
+
|
|
70
|
+
```jsx
|
|
71
|
+
import { useEffect, useRef } from 'react'
|
|
72
|
+
import { gsap, prefersReducedMotion } from '../lib/motion'
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* props:
|
|
76
|
+
* frameCount number of extracted frames
|
|
77
|
+
* framePath (i) => url for frame i (1-based). ALWAYS cache-busted with ?v=N
|
|
78
|
+
* focusX/Y 0..1 focal point preserved when cover-cropping. default 0.5
|
|
79
|
+
* poster static poster url (mobile + reduced-motion + fallback)
|
|
80
|
+
*/
|
|
81
|
+
export default function ScrollScrub({
|
|
82
|
+
frameCount, framePath, focusX = 0.5, focusY = 0.5,
|
|
83
|
+
poster, posterAlt = '', className = 'scrub', children,
|
|
84
|
+
}) {
|
|
85
|
+
const sectionRef = useRef(null)
|
|
86
|
+
const canvasRef = useRef(null)
|
|
87
|
+
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
// Mobile and reduced-motion never scrub. See Section 6.
|
|
90
|
+
const smallScreen = typeof window !== 'undefined'
|
|
91
|
+
&& window.matchMedia('(max-width: 860px)').matches
|
|
92
|
+
if (prefersReducedMotion || smallScreen) return
|
|
93
|
+
|
|
94
|
+
const canvas = canvasRef.current
|
|
95
|
+
const ctx = canvas.getContext('2d')
|
|
96
|
+
const images = []
|
|
97
|
+
let loaded = 0, failed = false, current = -1
|
|
98
|
+
const state = { frame: 0 }
|
|
99
|
+
const dpr = Math.min(window.devicePixelRatio || 1, 2)
|
|
100
|
+
|
|
101
|
+
// Cover with a focal point: keeps the subject visible when the cover crop
|
|
102
|
+
// eats the edges (16:9 footage in a wide, short viewport).
|
|
103
|
+
const drawCover = (img, cw, ch) => {
|
|
104
|
+
const ir = img.width / img.height
|
|
105
|
+
const cr = cw / ch
|
|
106
|
+
let dw, dh, dx, dy
|
|
107
|
+
if (ir > cr) { dh = ch; dw = ch * ir; dy = 0; dx = (cw - dw) * focusX }
|
|
108
|
+
else { dw = cw; dh = cw / ir; dx = 0; dy = (ch - dh) * focusY }
|
|
109
|
+
ctx.drawImage(img, dx, dy, dw, dh)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const draw = (idx, force) => {
|
|
113
|
+
const i = Math.max(0, Math.min(frameCount - 1, Math.round(idx)))
|
|
114
|
+
if (i === current && !force) return
|
|
115
|
+
current = i
|
|
116
|
+
const img = images[i]
|
|
117
|
+
const r = canvas.getBoundingClientRect()
|
|
118
|
+
if (img && img.complete && img.naturalWidth) drawCover(img, r.width, r.height)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const resize = () => {
|
|
122
|
+
const r = canvas.getBoundingClientRect()
|
|
123
|
+
canvas.width = Math.max(1, Math.floor(r.width * dpr))
|
|
124
|
+
canvas.height = Math.max(1, Math.floor(r.height * dpr))
|
|
125
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
|
126
|
+
draw(current < 0 ? 0 : current, true)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Preload every frame before scrubbing.
|
|
130
|
+
for (let i = 0; i < frameCount; i++) {
|
|
131
|
+
const img = new Image()
|
|
132
|
+
img.onload = () => {
|
|
133
|
+
loaded++
|
|
134
|
+
// See Section 5: resize once the canvas is actually visible.
|
|
135
|
+
if (loaded === 1) { canvas.style.display = 'block'; resize() }
|
|
136
|
+
}
|
|
137
|
+
img.onerror = () => { failed = true }
|
|
138
|
+
img.src = framePath(i + 1)
|
|
139
|
+
images[i] = img
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const ctxGsap = gsap.context(() => {
|
|
143
|
+
gsap.to(state, {
|
|
144
|
+
frame: frameCount - 1,
|
|
145
|
+
ease: 'none',
|
|
146
|
+
snap: 'frame',
|
|
147
|
+
scrollTrigger: {
|
|
148
|
+
trigger: sectionRef.current,
|
|
149
|
+
start: 'top top', end: 'bottom bottom', scrub: 0.4,
|
|
150
|
+
},
|
|
151
|
+
onUpdate: () => { if (!failed) draw(state.frame) },
|
|
152
|
+
})
|
|
153
|
+
}, sectionRef)
|
|
154
|
+
|
|
155
|
+
window.addEventListener('resize', resize)
|
|
156
|
+
const t = setTimeout(resize, 60) // first measurement after layout settles
|
|
157
|
+
return () => {
|
|
158
|
+
clearTimeout(t)
|
|
159
|
+
window.removeEventListener('resize', resize)
|
|
160
|
+
ctxGsap.revert()
|
|
161
|
+
}
|
|
162
|
+
}, [])
|
|
163
|
+
|
|
164
|
+
return (
|
|
165
|
+
<section className={className} ref={sectionRef}>
|
|
166
|
+
<div className={`${className}__sticky`}>
|
|
167
|
+
<div className={`${className}__fallback`}><img src={poster} alt={posterAlt} /></div>
|
|
168
|
+
{/* starts hidden so resize-on-visible fires correctly */}
|
|
169
|
+
<canvas className={`${className}__canvas`} ref={canvasRef} style={{ display: 'none' }} />
|
|
170
|
+
<div className={`${className}__scrim`} />
|
|
171
|
+
<div className={`${className}__copy`}>{children}</div>
|
|
172
|
+
</div>
|
|
173
|
+
</section>
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Use one instance per scrub section, varying `framePath`, `focusX/focusY` and the overlaid copy.
|
|
179
|
+
|
|
180
|
+
---
|
|
181
|
+
|
|
182
|
+
## Section 5: The 1x1 Canvas Bug
|
|
183
|
+
|
|
184
|
+
**Symptom:** the whole section renders as one stretched color or blur instead of the footage.
|
|
185
|
+
|
|
186
|
+
**Cause:** if the section starts outside the viewport with `display:none`, the initial resize runs while `getBoundingClientRect()` returns 0. The canvas becomes 1x1 and then draws a single pixel stretched to full size.
|
|
187
|
+
|
|
188
|
+
**Fix (already in the component):** keep the canvas hidden, and on the first loaded frame set `display:block` and re-run `resize()` with real measurements.
|
|
189
|
+
|
|
190
|
+
If it reappears, the section is almost certainly being measured while hidden.
|
|
191
|
+
|
|
192
|
+
---
|
|
193
|
+
|
|
194
|
+
## Section 6: Responsive and Reduced Motion
|
|
195
|
+
|
|
196
|
+
**Mobile does not scrub.** Landscape footage crops badly in portrait, and downloading dozens of 1080p frames costs 10 to 25 MB. The section collapses to a static 100vh poster.
|
|
197
|
+
|
|
198
|
+
```css
|
|
199
|
+
.scrub { position: relative; height: 320vh; } /* height is the scrub duration */
|
|
200
|
+
.scrub__sticky { position: sticky; top: 0; height: 100vh; overflow: hidden; }
|
|
201
|
+
.scrub__fallback { position: absolute; inset: 0; }
|
|
202
|
+
.scrub__fallback img { width: 100%; height: 100%; object-fit: cover; }
|
|
203
|
+
.scrub__canvas { position: absolute; inset: 0; width: 100%; height: 100%; }
|
|
204
|
+
.scrub__copy { position: relative; z-index: 3; }
|
|
205
|
+
|
|
206
|
+
@media (max-width: 860px) {
|
|
207
|
+
.scrub { height: 100vh; }
|
|
208
|
+
.scrub__sticky { position: relative; }
|
|
209
|
+
.scrub__canvas { display: none !important; }
|
|
210
|
+
}
|
|
211
|
+
@media (prefers-reduced-motion: reduce) {
|
|
212
|
+
.scrub { height: 100vh; }
|
|
213
|
+
.scrub__sticky { position: relative; }
|
|
214
|
+
.scrub__canvas { display: none !important; }
|
|
215
|
+
}
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
The JS guard and the CSS guard must agree. The component returns early on the same conditions the media queries cover.
|
|
219
|
+
|
|
220
|
+
---
|
|
221
|
+
|
|
222
|
+
## Section 7: Text Beats Over the Scrub
|
|
223
|
+
|
|
224
|
+
Copy that enters and exits at scroll milestones uses a separate GSAP timeline on the same trigger. Positions are fractions of the timeline (0 to 1):
|
|
225
|
+
|
|
226
|
+
```js
|
|
227
|
+
const tl = gsap.timeline({
|
|
228
|
+
scrollTrigger: { trigger: sectionRef.current, start: 'top top', end: 'bottom bottom', scrub: 0.4 },
|
|
229
|
+
})
|
|
230
|
+
// pointerEvents tracks each beat so a beat is never clickable while invisible
|
|
231
|
+
tl.to(beat1Ref.current, { opacity: 0, yPercent: -18, duration: 0.16 }, 0.24)
|
|
232
|
+
tl.set(beat1Ref.current, { pointerEvents: 'none' }, 0.24)
|
|
233
|
+
tl.set(beat2Ref.current, { pointerEvents: 'auto' }, 0.40)
|
|
234
|
+
tl.fromTo(beat2Ref.current, { opacity: 0, yPercent: 18 }, { opacity: 1, yPercent: 0, duration: 0.12 }, 0.40)
|
|
235
|
+
tl.to(beat2Ref.current, { opacity: 0, yPercent: -18, duration: 0.12 }, 0.62)
|
|
236
|
+
tl.set(beat2Ref.current, { pointerEvents: 'none' }, 0.62)
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
**An invisible beat still swallows clicks.** The beats stack in the same area with `position: absolute` and differ only by opacity, so without `pointer-events` the beat that sits last in the DOM wins the hit test even at `opacity: 0`. The visible beat renders fine and its CTA is simply dead. Set the resting state in CSS as well, so the first paint is correct before the timeline runs:
|
|
240
|
+
|
|
241
|
+
```css
|
|
242
|
+
.hero-beat--2, .hero-beat--3 { opacity: 0; pointer-events: none; }
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
**Check what else is `position: fixed` over the hero.** A cookie banner or a floating bar at a high z-index intercepts the CTA until it is dismissed, and on mobile it can take 250 to 270px of height. Push the beats up while the bar is on screen instead of lowering its z-index, and write the selector so it survives a library that hides the node with an inline style rather than removing it from the DOM:
|
|
246
|
+
|
|
247
|
+
```css
|
|
248
|
+
body:has(.cookie-bar:not([style*="display: none"])) .hero-beat { bottom: clamp(190px, 24vh, 260px); }
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
Text over footage needs a directional scrim plus text-shadow to stay readable. See `frontend/design-system/premium-finish.md`.
|
|
252
|
+
|
|
253
|
+
---
|
|
254
|
+
|
|
255
|
+
## Section 8: Verify With Motion, Never With a Still
|
|
256
|
+
|
|
257
|
+
**This is the highest-leverage check in the whole build.** A static screenshot does not reveal a broken scrub: the canvas can be 1x1 drawing garbage, or the frames can be ignoring scroll entirely, and the screenshot still looks plausible.
|
|
258
|
+
|
|
259
|
+
Drive the scroll and capture the sequence, using the exposed Lenis instance (see `frontend/scroll-driven/smooth-scroll.md`):
|
|
260
|
+
|
|
261
|
+
```js
|
|
262
|
+
// browser console, or via Playwright / Chrome DevTools MCP
|
|
263
|
+
const lenis = window.__lenis
|
|
264
|
+
const H = document.body.scrollHeight - innerHeight
|
|
265
|
+
for (const p of [0, .15, .3, .45, .6, .8, 1]) {
|
|
266
|
+
lenis.scrollTo(H * p, { immediate: true })
|
|
267
|
+
await new Promise(r => setTimeout(r, 350)) // let scrub:0.4 settle
|
|
268
|
+
// screenshot here
|
|
269
|
+
}
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
The `setTimeout` matters: `scrub: 0.4` does not jump instantly, so without the wait you photograph the previous frame.
|
|
273
|
+
|
|
274
|
+
Confirm three things: different frames appear at different scroll positions, the subject stays framed, and the copy stays legible over every captured frame.
|
|
275
|
+
|
|
276
|
+
---
|
|
277
|
+
|
|
278
|
+
## Checklist
|
|
279
|
+
|
|
280
|
+
- [ ] Frames extracted with explicit fps, scale and `-q:v`; total weight measured
|
|
281
|
+
- [ ] Poster frame extracted and wired to the fallback
|
|
282
|
+
- [ ] `framePath` carries a `?v=N` cache-bust, bumped on every re-extraction
|
|
283
|
+
- [ ] Canvas starts hidden and resizes on the first loaded frame
|
|
284
|
+
- [ ] `focusX/focusY` set so the cover crop keeps the subject
|
|
285
|
+
- [ ] Mobile and reduced-motion collapse to the poster in both JS and CSS
|
|
286
|
+
- [ ] Inactive text beats carry `pointer-events: none` in the CSS and in the timeline
|
|
287
|
+
- [ ] Verified by driving the scroll and comparing frames, not with a single screenshot
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
# Scroll Components — Interaction Toolbox
|
|
2
|
+
|
|
3
|
+
> **Scope:** frontend-scroll-driven
|
|
4
|
+
> **Layer:** 3
|
|
5
|
+
> **Keywords:** pinned gallery, horizontal scroll, modal, lightbox, parallax, count-up, reveal, scroll progress, accordion
|
|
6
|
+
> **Load When:** ui-designer active, or building a scroll-driven marketing page
|
|
7
|
+
|
|
8
|
+
**Verified against:** GSAP 3.13 (ScrollTrigger, matchMedia, context) + React 18 + Lenis 1.x, shipped to production. Last-verified: 2026-08-21.
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
A toolbox of scroll-driven interaction patterns, not a mandatory list. Pick what the page needs. Every recipe assumes the wiring in `frontend/scroll-driven/smooth-scroll.md` (shared motion registry, Lenis on the GSAP ticker, `window.__lenis` exposed) and cleans itself up on unmount.
|
|
13
|
+
|
|
14
|
+
The frame-scrub hero is documented separately in `frontend/scroll-driven/frame-scrub.md`.
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## Section 1: useReveal (The Base Brick)
|
|
19
|
+
|
|
20
|
+
Most content (headings, paragraphs, cards) needs nothing more than this. Use `.d1 .d2 .d3` to stagger siblings.
|
|
21
|
+
|
|
22
|
+
```jsx
|
|
23
|
+
export function useReveal() {
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
if (prefersReducedMotion) return
|
|
26
|
+
const els = gsap.utils.toArray('.reveal')
|
|
27
|
+
const ctx = gsap.context(() => {
|
|
28
|
+
els.forEach((el) => gsap.fromTo(el,
|
|
29
|
+
{ opacity: 0, y: 28 },
|
|
30
|
+
{ opacity: 1, y: 0, duration: 0.9, ease: 'power3.out',
|
|
31
|
+
scrollTrigger: { trigger: el, start: 'top 88%', once: true } },
|
|
32
|
+
))
|
|
33
|
+
})
|
|
34
|
+
return () => ctx.revert()
|
|
35
|
+
}, [])
|
|
36
|
+
}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
```css
|
|
40
|
+
.reveal { will-change: opacity, transform; }
|
|
41
|
+
.reveal.d1 { transition-delay: .08s } .reveal.d2 { transition-delay: .16s } .reveal.d3 { transition-delay: .24s }
|
|
42
|
+
@media (prefers-reduced-motion: reduce) {
|
|
43
|
+
.reveal { opacity: 1 !important; transform: none !important; }
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`once: true` matters: re-revealing on every pass reads as nervous, not premium.
|
|
48
|
+
|
|
49
|
+
**If you implement this with `IntersectionObserver` instead of ScrollTrigger, keep `threshold: 0`.** A threshold is a fraction of the observed element's own area, so a block taller than the viewport (a long photo grid, a tall editorial section) never reaches 15 percent visible at once. The callback never fires, the element keeps `opacity: 0` forever, and it still occupies its full height in the layout: the page shows a large blank gap and nothing in the console. Use `threshold: 0` with a `rootMargin`, which fires as soon as the first pixel enters, whatever the element's height. The ScrollTrigger version above is immune because `start: 'top 88%'` measures the element's top against the viewport, not its area.
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## Section 2: Pinned Horizontal Gallery
|
|
54
|
+
|
|
55
|
+
A section that pins to the viewport and slides cards horizontally as the user scrolls vertically. Desktop only; on mobile it falls back to normal vertical scrolling via CSS.
|
|
56
|
+
|
|
57
|
+
```jsx
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
const mm = gsap.matchMedia()
|
|
60
|
+
mm.add('(min-width: 861px) and (prefers-reduced-motion: no-preference)', () => {
|
|
61
|
+
const section = sectionRef.current, track = trackRef.current
|
|
62
|
+
section.classList.add('lines--horizontal')
|
|
63
|
+
const distance = () => Math.max(0, track.scrollWidth - window.innerWidth)
|
|
64
|
+
const tween = gsap.to(track, {
|
|
65
|
+
x: () => -distance(),
|
|
66
|
+
ease: 'none',
|
|
67
|
+
scrollTrigger: {
|
|
68
|
+
trigger: section, start: 'top top', end: () => `+=${distance()}`,
|
|
69
|
+
scrub: 0.5, pin: true, anticipatePin: 1, invalidateOnRefresh: true,
|
|
70
|
+
},
|
|
71
|
+
})
|
|
72
|
+
return () => {
|
|
73
|
+
section.classList.remove('lines--horizontal')
|
|
74
|
+
gsap.set(track, { x: 0 })
|
|
75
|
+
tween.scrollTrigger?.kill(); tween.kill()
|
|
76
|
+
}
|
|
77
|
+
})
|
|
78
|
+
return () => mm.revert()
|
|
79
|
+
}, [])
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
`invalidateOnRefresh: true` is required here: without it the travel distance stays frozen at the first measurement and the pin ends in the wrong place after a resize.
|
|
83
|
+
|
|
84
|
+
To lengthen the journey and give cards more breathing room, increase the track gap or padding. `scrollWidth` grows and the pin lasts longer.
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
## Section 3: Expanding Modal
|
|
89
|
+
|
|
90
|
+
A card that opens into a detail panel. The entry pattern is: mount the node, then add the `.in` class on the next frame so the CSS transition fires.
|
|
91
|
+
|
|
92
|
+
```jsx
|
|
93
|
+
const [active, setActive] = useState(null)
|
|
94
|
+
const [shown, setShown] = useState(false)
|
|
95
|
+
|
|
96
|
+
const open = (i) => {
|
|
97
|
+
setActive(i)
|
|
98
|
+
requestAnimationFrame(() => setShown(true))
|
|
99
|
+
window.__lenis?.stop()
|
|
100
|
+
}
|
|
101
|
+
const close = () => {
|
|
102
|
+
setShown(false)
|
|
103
|
+
window.__lenis?.start()
|
|
104
|
+
setTimeout(() => setActive(null), 380) // matches the CSS transition
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
if (active === null) return
|
|
109
|
+
const onKey = (e) => { if (e.key === 'Escape') close() }
|
|
110
|
+
window.addEventListener('keydown', onKey)
|
|
111
|
+
return () => window.removeEventListener('keydown', onKey)
|
|
112
|
+
}, [active])
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
**Render overlays outside any pinned section.** A pin applies a `transform` to its container, which becomes the containing block for `position: fixed` and breaks the overlay. Put the modal in a fragment as a sibling of the section.
|
|
116
|
+
|
|
117
|
+
**Mark the scrollable panel `data-lenis-prevent`.** Stopping the smooth scroller is not enough: while stopped it calls `preventDefault()` on any `touchmove` that reaches it, so a panel with `overflow-y: auto` scrolls with the wheel and is frozen under a finger. Whatever sits at the bottom of the panel, usually the CTA, becomes unreachable on a phone. See `frontend/scroll-driven/smooth-scroll.md`, Section 3.
|
|
118
|
+
|
|
119
|
+
```jsx
|
|
120
|
+
<div className="lmodal__panel" data-lenis-prevent>
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
```css
|
|
124
|
+
.lmodal { position: fixed; inset: 0; z-index: 300; display: grid; place-items: center;
|
|
125
|
+
background: rgba(0,0,0,0); backdrop-filter: blur(0px);
|
|
126
|
+
transition: background .38s, backdrop-filter .38s; }
|
|
127
|
+
.lmodal.in { background: rgba(0,0,0,.6); backdrop-filter: blur(6px); }
|
|
128
|
+
.lmodal__panel { opacity: 0; transform: translateY(24px) scale(.98);
|
|
129
|
+
transition: opacity .38s, transform .38s cubic-bezier(.2,.7,.2,1); }
|
|
130
|
+
.lmodal.in .lmodal__panel { opacity: 1; transform: none; }
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
---
|
|
134
|
+
|
|
135
|
+
## Section 4: Fullscreen Lightbox
|
|
136
|
+
|
|
137
|
+
Same `.in` pattern, plus arrow-key navigation and a paused scroller.
|
|
138
|
+
|
|
139
|
+
```jsx
|
|
140
|
+
const go = (dir) => setLb((i) => (i + dir + PHOTOS.length) % PHOTOS.length)
|
|
141
|
+
|
|
142
|
+
useEffect(() => {
|
|
143
|
+
if (lb === null) return
|
|
144
|
+
const onKey = (e) => {
|
|
145
|
+
if (e.key === 'Escape') close()
|
|
146
|
+
else if (e.key === 'ArrowRight') go(1)
|
|
147
|
+
else if (e.key === 'ArrowLeft') go(-1)
|
|
148
|
+
}
|
|
149
|
+
window.addEventListener('keydown', onKey)
|
|
150
|
+
return () => window.removeEventListener('keydown', onKey)
|
|
151
|
+
}, [lb])
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Grid items that open the lightbox need `role="button"`, `tabIndex={0}` and an `onKeyDown` handling Enter and Space, plus a zoom affordance on hover.
|
|
155
|
+
|
|
156
|
+
---
|
|
157
|
+
|
|
158
|
+
## Section 5: Parallax Drift
|
|
159
|
+
|
|
160
|
+
Images move at slightly different speeds to suggest depth. Subtle wins: 5 to 8 percent of drift is enough.
|
|
161
|
+
|
|
162
|
+
```jsx
|
|
163
|
+
mm.add('(prefers-reduced-motion: no-preference)', () => {
|
|
164
|
+
const imgs = gsap.utils.toArray(gridRef.current.querySelectorAll('img'))
|
|
165
|
+
const tweens = imgs.map((img, i) => {
|
|
166
|
+
const from = i % 2 === 0 ? -5 : 5
|
|
167
|
+
return gsap.fromTo(img, { yPercent: from }, {
|
|
168
|
+
yPercent: -from, ease: 'none',
|
|
169
|
+
scrollTrigger: { trigger: img.closest('figure'), start: 'top bottom', end: 'bottom top', scrub: true },
|
|
170
|
+
})
|
|
171
|
+
})
|
|
172
|
+
return () => {
|
|
173
|
+
tweens.forEach((t) => { t.scrollTrigger?.kill(); t.kill() })
|
|
174
|
+
ScrollTrigger.refresh()
|
|
175
|
+
}
|
|
176
|
+
})
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
Give the `<figure>` `overflow: hidden` and scale the image slightly above 100 percent so the drift never exposes an edge.
|
|
180
|
+
|
|
181
|
+
---
|
|
182
|
+
|
|
183
|
+
## Section 6: Count-Up
|
|
184
|
+
|
|
185
|
+
For a rating or statistic. Animate a plain object and write the text on each update.
|
|
186
|
+
|
|
187
|
+
```jsx
|
|
188
|
+
const obj = { v: 0 }
|
|
189
|
+
gsap.to(obj, {
|
|
190
|
+
v: 4.9, duration: 1.6, ease: 'power2.out',
|
|
191
|
+
scrollTrigger: { trigger: el, start: 'top 85%', once: true },
|
|
192
|
+
onUpdate: () => { el.textContent = obj.v.toFixed(1).replace('.', ',') },
|
|
193
|
+
})
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
Format for the locale at write time (the decimal comma above is pt-BR). Never animate a number the business cannot back up.
|
|
197
|
+
|
|
198
|
+
---
|
|
199
|
+
|
|
200
|
+
## Section 7: Persistent Elements
|
|
201
|
+
|
|
202
|
+
**Slim translucent nav** that gains a background once scrolled:
|
|
203
|
+
|
|
204
|
+
```jsx
|
|
205
|
+
const [solid, setSolid] = useState(false)
|
|
206
|
+
useEffect(() => {
|
|
207
|
+
const onScroll = () => setSolid(window.scrollY > 40)
|
|
208
|
+
window.addEventListener('scroll', onScroll, { passive: true })
|
|
209
|
+
return () => window.removeEventListener('scroll', onScroll)
|
|
210
|
+
}, [])
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
**Scroll progress bar** that prefers the smooth scroller when present:
|
|
214
|
+
|
|
215
|
+
```jsx
|
|
216
|
+
useEffect(() => {
|
|
217
|
+
const onScroll = () => {
|
|
218
|
+
const h = document.documentElement.scrollHeight - innerHeight
|
|
219
|
+
if (ref.current) ref.current.style.transform = `scaleX(${h > 0 ? scrollY / h : 0})`
|
|
220
|
+
}
|
|
221
|
+
const l = window.__lenis
|
|
222
|
+
l ? l.on('scroll', onScroll) : window.addEventListener('scroll', onScroll, { passive: true })
|
|
223
|
+
onScroll()
|
|
224
|
+
return () => { l ? l.off('scroll', onScroll) : window.removeEventListener('scroll', onScroll) }
|
|
225
|
+
}, [])
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
Transform-based progress (`scaleX` with `transform-origin: 0 50%`) stays on the compositor. Animating `width` does not.
|
|
229
|
+
|
|
230
|
+
---
|
|
231
|
+
|
|
232
|
+
## Section 8: Accordion
|
|
233
|
+
|
|
234
|
+
Use native `<details>`. It provides toggle, keyboard support and accessibility for free.
|
|
235
|
+
|
|
236
|
+
```css
|
|
237
|
+
.faq__item summary { cursor: pointer; list-style: none; display: flex;
|
|
238
|
+
justify-content: space-between; align-items: center; }
|
|
239
|
+
.faq__item summary::-webkit-details-marker { display: none; }
|
|
240
|
+
.faq__item .faq__icon { transition: transform .3s; }
|
|
241
|
+
.faq__item[open] .faq__icon { transform: rotate(45deg); }
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
Native `<details>` cannot animate its own height. If height animation is required, drive the inner content with GSAP rather than replacing the element with a custom widget.
|
|
245
|
+
|
|
246
|
+
---
|
|
247
|
+
|
|
248
|
+
## Section 9: Cross-Cutting Rules
|
|
249
|
+
|
|
250
|
+
These apply to every recipe above:
|
|
251
|
+
|
|
252
|
+
- **Pause the scroller on overlays.** `window.__lenis?.stop()` on open, `.start()` on close, or the background scrolls behind the overlay. Add `data-lenis-prevent` to the panel that scrolls inside it, or touch scrolling stays dead while the scroller is stopped.
|
|
253
|
+
- **Overlays live outside pinned sections.** A pin's `transform` breaks `position: fixed` inside it.
|
|
254
|
+
- **Anything clickable that is not a `<button>` or `<a>`** gets `role="button"`, `tabIndex={0}` and Enter/Space handling. Escape closes overlays. `:focus-visible` stays visible.
|
|
255
|
+
- **Every effect has a static fallback under reduced motion.** Scrub and parallax simply do not mount.
|
|
256
|
+
- **Every effect reverts on cleanup** via `gsap.context()` or `matchMedia()`, or hot reloads duplicate triggers.
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# Smooth Scroll — Lenis + GSAP ScrollTrigger
|
|
2
|
+
|
|
3
|
+
> **Scope:** frontend-scroll-driven
|
|
4
|
+
> **Layer:** 3
|
|
5
|
+
> **Keywords:** lenis, smooth-scroll, scrolltrigger, raf, gsap ticker, window.__lenis, HMR, scroll wiring
|
|
6
|
+
> **Load When:** ui-designer active, or the project uses smooth scrolling with scroll-driven animation
|
|
7
|
+
|
|
8
|
+
**Verified against:** Lenis 1.x + GSAP 3.13 ScrollTrigger + Vite 5, shipped to production. Last-verified: 2026-08-21.
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
Wiring guide for pairing a smooth-scroll library with scroll-driven animation. Getting this wrong produces animations that lag behind the scroll, triggers that measure the wrong positions, and sections that silently die after a few hot reloads. Covers the RAF handoff, why the instance must be exposed globally, refresh timing, and the HMR staleness trap.
|
|
13
|
+
|
|
14
|
+
**Which motion stack this is.** Reach for GSAP plus ScrollTrigger plus Lenis when the scroll position drives the scene: frame scrubs, pinned sequences, parallax, progress. For component-level motion in a regular app or product UI (enter and exit, gestures, mount and unmount transitions), use Motion for React and see `frontend/nextjs/motion-patterns.md`. The two can coexist on a page but never on the same element, because both write inline styles and will fight for control. On a page running Lenis, scroll reveals belong to `useReveal` in `frontend/scroll-driven/scroll-components.md`, not to `whileInView`: two libraries observing the same scroll produce two sources of truth for when an element is visible.
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## Section 1: Central Motion Registry
|
|
19
|
+
|
|
20
|
+
Register plugins once, in a single module every component imports from. Never register inside `useEffect` or a loop.
|
|
21
|
+
|
|
22
|
+
```js
|
|
23
|
+
// lib/motion.js
|
|
24
|
+
import { gsap } from 'gsap'
|
|
25
|
+
import { ScrollTrigger } from 'gsap/ScrollTrigger'
|
|
26
|
+
|
|
27
|
+
gsap.registerPlugin(ScrollTrigger)
|
|
28
|
+
|
|
29
|
+
export const prefersReducedMotion =
|
|
30
|
+
typeof window !== 'undefined' && window.matchMedia &&
|
|
31
|
+
window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
|
32
|
+
|
|
33
|
+
export { gsap, ScrollTrigger }
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Exporting `prefersReducedMotion` from the same module keeps every effect checking the same value instead of re-querying `matchMedia` with slightly different strings.
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## Section 2: The RAF Handoff
|
|
41
|
+
|
|
42
|
+
Lenis and GSAP each want to own the animation frame. Running both tickers independently makes scrub visibly lag the scroll. Drive Lenis from the GSAP ticker instead:
|
|
43
|
+
|
|
44
|
+
```js
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (prefersReducedMotion) return
|
|
47
|
+
|
|
48
|
+
const lenis = new Lenis({
|
|
49
|
+
duration: 1.1,
|
|
50
|
+
easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)),
|
|
51
|
+
smoothWheel: true,
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
lenis.on('scroll', ScrollTrigger.update)
|
|
55
|
+
|
|
56
|
+
// See Section 3.
|
|
57
|
+
if (typeof window !== 'undefined') window.__lenis = lenis
|
|
58
|
+
|
|
59
|
+
const tick = (time) => lenis.raf(time * 1000)
|
|
60
|
+
gsap.ticker.add(tick)
|
|
61
|
+
gsap.ticker.lagSmoothing(0)
|
|
62
|
+
|
|
63
|
+
// Recalculate once fonts and images have settled.
|
|
64
|
+
const onLoad = () => ScrollTrigger.refresh()
|
|
65
|
+
window.addEventListener('load', onLoad)
|
|
66
|
+
|
|
67
|
+
return () => {
|
|
68
|
+
window.removeEventListener('load', onLoad)
|
|
69
|
+
gsap.ticker.remove(tick)
|
|
70
|
+
lenis.destroy()
|
|
71
|
+
}
|
|
72
|
+
}, [])
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Three details carry weight:
|
|
76
|
+
|
|
77
|
+
- **`lenis.on('scroll', ScrollTrigger.update)`** keeps triggers in sync with smoothed position rather than raw native scroll.
|
|
78
|
+
- **`gsap.ticker.lagSmoothing(0)`** disables GSAP's frame-drop compensation, which otherwise fights the smooth scroller after a stall.
|
|
79
|
+
- **`ScrollTrigger.refresh()` on `load`** fixes trigger positions measured before webfonts and images changed the layout height.
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
## Section 3: Expose the Instance on `window`
|
|
84
|
+
|
|
85
|
+
Set `window.__lenis` deliberately. It is not a debugging leftover, it serves two production needs:
|
|
86
|
+
|
|
87
|
+
1. **Overlays must stop the scroll.** Modals and lightboxes call `window.__lenis?.stop()` on open and `.start()` on close, otherwise the page scrolls behind the overlay.
|
|
88
|
+
2. **Verification needs to drive the scroll.** Automated checks (Playwright, Chrome DevTools MCP) use `lenis.scrollTo(y, { immediate: true })` to step through scroll positions and capture what actually renders. See `frontend/scroll-driven/frame-scrub.md`, Section 8.
|
|
89
|
+
|
|
90
|
+
Consumers must use optional chaining (`window.__lenis?.stop()`), because under reduced motion the instance is never created.
|
|
91
|
+
|
|
92
|
+
**`stop()` alone breaks touch scrolling inside the overlay.** The smooth scroller listens to `touchmove` globally, and while stopped it calls `preventDefault()` on any touch that bubbles up to it. An overlay panel with `overflow-y: auto` scrolls fine with a mouse wheel and is completely frozen on a phone, which usually hides whatever sits at the bottom of the panel, such as the CTA. Mark the scrollable element with the opt-out attribute Lenis provides for exactly this case:
|
|
93
|
+
|
|
94
|
+
```jsx
|
|
95
|
+
<div className="modal__panel" data-lenis-prevent>
|
|
96
|
+
{/* long content, native overflow-y: auto */}
|
|
97
|
+
</div>
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Wheel and touch are separate code paths here, so a desktop check does not cover this. Verify the overlay on a touch viewport.
|
|
101
|
+
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
## Section 4: The HMR Staleness Trap
|
|
105
|
+
|
|
106
|
+
**Symptom:** after many hot reloads in dev, pins stop holding, scrubs freeze, a pinned horizontal gallery stops locking.
|
|
107
|
+
|
|
108
|
+
**Cause:** ScrollTrigger accumulates stale instances across reloads. In most cases the code is fine.
|
|
109
|
+
|
|
110
|
+
**First response:** do a full page reload (F5) before debugging. It clears the accumulated state. Concluding that a section is broken based on a hot-reloaded page wastes real time.
|
|
111
|
+
|
|
112
|
+
**Defenses in code:**
|
|
113
|
+
|
|
114
|
+
- `invalidateOnRefresh: true` on any trigger whose measurements depend on viewport size (pinned horizontal distance, computed end values).
|
|
115
|
+
- Wrap every effect in `gsap.context()` or `gsap.matchMedia()` and revert it on cleanup, so hot reloads cannot duplicate triggers.
|
|
116
|
+
- Call `ScrollTrigger.refresh()` on `load` and after killing responsive tweens.
|
|
117
|
+
|
|
118
|
+
```js
|
|
119
|
+
useEffect(() => {
|
|
120
|
+
const ctx = gsap.context(() => {
|
|
121
|
+
// tweens and triggers here
|
|
122
|
+
}, scopeRef)
|
|
123
|
+
return () => ctx.revert() // non-negotiable
|
|
124
|
+
}, [])
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
---
|
|
128
|
+
|
|
129
|
+
## Section 5: Reduced Motion
|
|
130
|
+
|
|
131
|
+
Under `prefers-reduced-motion: reduce`, do not instantiate the smooth scroller at all. Native scrolling is the accessible baseline, and every scroll-driven effect must have a static fallback rather than a degraded animation.
|
|
132
|
+
|
|
133
|
+
This means `window.__lenis` is `undefined` in that path, which is exactly why Section 3 requires optional chaining.
|
|
134
|
+
|
|
135
|
+
---
|
|
136
|
+
|
|
137
|
+
## Checklist
|
|
138
|
+
|
|
139
|
+
- [ ] Plugins registered once in a shared `lib/motion` module
|
|
140
|
+
- [ ] Lenis driven from `gsap.ticker`, not its own RAF loop
|
|
141
|
+
- [ ] `lagSmoothing(0)` set
|
|
142
|
+
- [ ] `ScrollTrigger.update` bound to the Lenis scroll event
|
|
143
|
+
- [ ] `ScrollTrigger.refresh()` called on window `load`
|
|
144
|
+
- [ ] `window.__lenis` exposed, and every consumer uses optional chaining
|
|
145
|
+
- [ ] Every effect wrapped in `gsap.context()` / `matchMedia()` with revert on cleanup
|
|
146
|
+
- [ ] `invalidateOnRefresh: true` on viewport-dependent triggers
|
|
147
|
+
- [ ] Scrollable overlay panels marked `data-lenis-prevent` and verified on a touch viewport
|
|
148
|
+
- [ ] Smooth scroller not instantiated under reduced motion
|