@struq/skill 0.2.2 → 0.4.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 +23 -35
- package/package.json +3 -2
- package/skills/struq/SKILL.md +148 -212
- package/skills/struq/blocks/CATALOG.md +426 -0
- package/skills/struq/blocks/backgrounds/background-aurora.tsx +818 -0
- package/skills/struq/blocks/backgrounds/background-facets.tsx +1283 -0
- package/skills/struq/blocks/backgrounds/background-silk.tsx +906 -0
- package/skills/struq/blocks/catalog.json +1302 -0
- package/skills/struq/blocks/footers/footer-contact.tsx +789 -0
- package/skills/struq/blocks/footers/footer-reveal.tsx +313 -0
- package/skills/struq/blocks/headers/header-bar.tsx +652 -0
- package/skills/struq/blocks/headers/menu-curtain.tsx +790 -0
- package/skills/struq/blocks/heroes/hero-framed.tsx +627 -0
- package/skills/struq/blocks/heroes/hero-index.tsx +855 -0
- package/skills/struq/blocks/heroes/hero-poster.tsx +1086 -0
- package/skills/struq/blocks/heroes/hero-split.tsx +685 -0
- package/skills/struq/blocks/heroes/hero-still.tsx +862 -0
- package/skills/struq/blocks/kit/Cursor.tsx +117 -0
- package/skills/struq/blocks/kit/HeroCanvas.tsx +278 -0
- package/skills/struq/blocks/kit/HorizontalGallery.tsx +106 -0
- package/skills/struq/blocks/kit/MagneticButton.tsx +92 -0
- package/skills/struq/blocks/kit/MediaFrame.tsx +96 -0
- package/skills/struq/blocks/kit/PinnedZoom.tsx +89 -0
- package/skills/struq/blocks/kit/Reveal.tsx +64 -0
- package/skills/struq/blocks/kit/SmoothScroll.tsx +57 -0
- package/skills/struq/blocks/kit/SplitHeadline.tsx +68 -0
- package/skills/struq/blocks/kit/blocks.css +2102 -0
- package/skills/struq/blocks/kit/gsap.ts +21 -0
- package/skills/struq/blocks/kit/images.ts +17 -0
- package/skills/struq/blocks/kit/photo-read.ts +102 -0
- package/skills/struq/blocks/kit/scenes/contours.ts +161 -0
- package/skills/struq/blocks/kit/scenes/dots.ts +110 -0
- package/skills/struq/blocks/kit/scenes/ripple.ts +99 -0
- package/skills/struq/blocks/kit/scenes/types.ts +114 -0
- package/skills/struq/blocks/page-transitions/page-transition-gate.tsx +706 -0
- package/skills/struq/blocks/page-transitions/page-transition-slices.tsx +469 -0
- package/skills/struq/blocks/page-transitions/page-transition-stack.tsx +583 -0
- package/skills/struq/blocks/preloaders/preloader-gate.tsx +358 -0
- package/skills/struq/blocks/preloaders/preloader-slices-uncovered.ts +50 -0
- package/skills/struq/blocks/preloaders/preloader-slices.tsx +289 -0
- package/skills/struq/blocks/preloaders/preloader-stack.tsx +367 -0
- package/skills/struq/blocks/preloaders/preloader-wordmark.tsx +568 -0
- package/skills/struq/blocks/sections/about-studio.tsx +436 -0
- package/skills/struq/blocks/sections/approach.tsx +661 -0
- package/skills/struq/blocks/sections/contact-form.tsx +664 -0
- package/skills/struq/blocks/sections/cta-closing.tsx +763 -0
- package/skills/struq/blocks/sections/fanned-cards.tsx +547 -0
- package/skills/struq/blocks/sections/film-strip.tsx +385 -0
- package/skills/struq/blocks/sections/grid-assembly.tsx +616 -0
- package/skills/struq/blocks/sections/hover-list.tsx +467 -0
- package/skills/struq/blocks/sections/image-trail.tsx +432 -0
- package/skills/struq/blocks/sections/manifesto.tsx +691 -0
- package/skills/struq/blocks/sections/panel-accordion.tsx +380 -0
- package/skills/struq/blocks/sections/reveals.tsx +416 -0
- package/skills/struq/blocks/sections/split-pair.tsx +670 -0
- package/skills/struq/blocks/sections/sticky-chapters.tsx +256 -0
- package/skills/struq/blocks/sections/velocity-type.tsx +269 -0
- package/skills/struq/blocks/showpieces/fill-rows.tsx +74 -0
- package/skills/struq/blocks/showpieces/hero-opener.tsx +128 -0
- package/skills/struq/blocks/showpieces/image-to-fullbleed.tsx +89 -0
- package/skills/struq/blocks/showpieces/manifesto-scrub.tsx +48 -0
- package/skills/struq/blocks/showpieces/panel-wipe.tsx +89 -0
- package/skills/struq/blocks/showpieces/pixel-resolve.tsx +107 -0
- package/skills/struq/blocks/showpieces/project-stack.tsx +86 -0
- package/skills/struq/blocks/showpieces/scrub-connector.tsx +307 -0
- package/skills/struq/blocks/showpieces/wordmark-finale.tsx +105 -0
- package/skills/struq/blocks/transitions/zoom-through.tsx +613 -0
- package/skills/struq/canon/canon.json +627 -0
- package/skills/struq/cinematic/README.md +7 -18
- package/skills/struq/cinematic/examples/code/nextjs/app/components/LenisRoot.tsx +1 -1
- package/skills/struq/cinematic/examples/code/shared/styles.css +1 -1
- package/skills/struq/cinematic/examples/code/vite/src/LenisRoot.tsx +1 -1
- package/skills/struq/cinematic/references/01-core-mental-model.md +1 -1
- package/skills/struq/cinematic/references/02-vocabulary.md +1 -1
- package/skills/struq/cinematic/references/07-stack-integration.md +2 -2
- package/skills/struq/recipes/app/fonts.ts +12 -0
- package/skills/struq/recipes/app/globals.css +42 -0
- package/skills/struq/recipes/app/layout.tsx +30 -0
- package/skills/struq/references/craft.md +77 -0
- package/skills/struq/references/iconography.md +28 -230
- package/skills/struq/references/stack-targets.md +23 -74
- package/skills/struq/references/tells.md +52 -0
- package/skills/struq/references/triage.md +6 -7
- package/skills/struq/scripts/blocks.mjs +156 -0
- package/skills/struq/scripts/canon.mjs +134 -0
- package/skills/struq/scripts/check.mjs +513 -0
- package/skills/struq/scripts/images.mjs +360 -0
- package/skills/struq/scripts/log.mjs +75 -0
- package/skills/struq/scripts/packs.mjs +156 -0
- package/skills/struq/scripts/tokens.mjs +222 -0
- package/skills/struq/references/anti-patterns.md +0 -265
- package/skills/struq/references/baseline-prompt.md +0 -118
- package/skills/struq/references/brand.md +0 -143
- package/skills/struq/references/brief-router.md +0 -219
- package/skills/struq/references/composition.md +0 -191
- package/skills/struq/references/depth-parallax.md +0 -235
- package/skills/struq/references/direction.md +0 -188
- package/skills/struq/references/feel-layer.md +0 -107
- package/skills/struq/references/gsap-api.md +0 -293
- package/skills/struq/references/imagery.md +0 -144
- package/skills/struq/references/loaders.md +0 -287
- package/skills/struq/references/micro-interactions.md +0 -270
- package/skills/struq/references/mobile-composition.md +0 -70
- package/skills/struq/references/nav-header.md +0 -296
- package/skills/struq/references/page-architecture.md +0 -134
- package/skills/struq/references/page-entrances.md +0 -198
- package/skills/struq/references/performance.md +0 -235
- package/skills/struq/references/pinning-scrub.md +0 -341
- package/skills/struq/references/pitfalls.md +0 -290
- package/skills/struq/references/route-transitions.md +0 -281
- package/skills/struq/references/scaling.md +0 -151
- package/skills/struq/references/scroll-reveals.md +0 -280
- package/skills/struq/references/seo.md +0 -219
- package/skills/struq/references/setup.md +0 -397
- package/skills/struq/references/showcase.md +0 -87
- package/skills/struq/references/struq-imagery.md +0 -107
- package/skills/struq/references/svg-motion.md +0 -390
- package/skills/struq/references/transitions.md +0 -160
- package/skills/struq/references/typography.md +0 -720
- package/skills/struq/references/vibe-presets.md +0 -287
- package/skills/struq/references/webgl-r3f.md +0 -274
- package/skills/struq/references/webgl-scenes.md +0 -593
|
@@ -1,235 +0,0 @@
|
|
|
1
|
-
# Performance: cinematic and fast
|
|
2
|
-
|
|
3
|
-
An award-tier site that stutters or takes four seconds to show its hero is not award-tier. The motion in this skill is cheap when it runs on one clock and animates transforms; what actually makes these sites slow is **assets**: a 40MB hero video, PNG photography, an unoptimized GLB, six font files. Fix assets first, then code.
|
|
4
|
-
|
|
5
|
-
Run this in two places: on every build before verify, and first thing on a redesign of an existing project (existing projects are almost always carrying badly optimized media).
|
|
6
|
-
|
|
7
|
-
## 1. Budgets
|
|
8
|
-
|
|
9
|
-
| Asset | Budget | Notes |
|
|
10
|
-
|---|---|---|
|
|
11
|
-
| Hero image | 250KB at 1920w (AVIF or WebP) | the LCP element on most pages; preload it |
|
|
12
|
-
| Content image | 150KB at 1440w | lazy below the first viewport |
|
|
13
|
-
| Hero or ambient video loop | 2 to 4MB, 6 to 12s, no audio | always a poster; WebM first, MP4 fallback |
|
|
14
|
-
| Any single video | 10MB max | longer content belongs on a video host |
|
|
15
|
-
| GLB model | 1 to 3MB after compression | Draco or meshopt geometry, KTX2 or WebP textures |
|
|
16
|
-
| Fonts | 2 families, 4 files max, about 150KB total | WOFF2, subset, variable where possible |
|
|
17
|
-
| JavaScript on first load | about 250KB gzipped for the route, measured (§8) | three.js and R3F never in it; lazy island only |
|
|
18
|
-
|
|
19
|
-
**Where the 250KB goes** (measured on a dry run with a recent Next.js and React, three.js correctly excluded: 244KB gzipped for `/`): the framework runtime (React, React DOM, the Next.js client and router) is roughly 150 to 190KB of it and is not yours to cut; GSAP core, ScrollTrigger, SplitText and DrawSVG are about 49KB; Lenis about 6KB; the rest is your components. Over budget, cut in this order: GSAP plugins registered but unused (MorphSVG and Flip are the usual passengers, [setup.md](setup.md) §1), Motion on a page where nothing uses it, Radix primitives imported into the root layout instead of the component that opens them, then heavy client components that could stay server components. The framework share moves with each major, so measure on the installed versions and write the number under VERIFY; never quote this table as the measurement.
|
|
20
|
-
|
|
21
|
-
Targets on a mid-range phone profile: **LCP under 2.5s, CLS under 0.1, INP under 200ms**. Record them under VERIFY in STRUQ.md ([direction.md](direction.md)).
|
|
22
|
-
|
|
23
|
-
## 2. Audit an existing project (redesign mode, step one)
|
|
24
|
-
|
|
25
|
-
```bash
|
|
26
|
-
# the 25 heaviest files under public/ and any asset folders
|
|
27
|
-
find public assets -type f \( -iname "*.mp4" -o -iname "*.mov" -o -iname "*.webm" -o -iname "*.png" -o -iname "*.jpg" -o -iname "*.jpeg" -o -iname "*.webp" -o -iname "*.avif" -o -iname "*.gif" -o -iname "*.svg" -o -iname "*.glb" -o -iname "*.gltf" -o -iname "*.ttf" -o -iname "*.otf" -o -iname "*.woff" -o -iname "*.woff2" \) -printf "%s\t%p\n" 2>/dev/null | sort -rn | head -25 | awk -F'\t' '{printf "%8.1f KB %s\n", $1/1024, $2}'
|
|
28
|
-
|
|
29
|
-
# what a video actually is: codec, size, fps, duration, bitrate, audio
|
|
30
|
-
ffprobe -v error -show_entries stream=codec_type,codec_name,width,height,r_frame_rate:format=duration,bit_rate,size -of compact input.mp4
|
|
31
|
-
```
|
|
32
|
-
|
|
33
|
-
(On macOS, `find -printf` is unavailable: use `du -k` over the same globs.)
|
|
34
|
-
|
|
35
|
-
List every file over budget with its role (hero, ambient, content, poster), then fix them in this order: videos, images, models, fonts. Replace references in code to point at the optimized files; never optimize in place over the source.
|
|
36
|
-
|
|
37
|
-
## 3. Video with ffmpeg
|
|
38
|
-
|
|
39
|
-
Decorative and hero loops: strip audio, cap at 30fps, never upscale, trim to the loop.
|
|
40
|
-
|
|
41
|
-
```bash
|
|
42
|
-
# WebM (VP9), primary source. CRF 32 to 36 for ambient, 28 to 32 for a hero that people look at.
|
|
43
|
-
ffmpeg -i input.mov -t 10 -an -vf "scale='min(1920,iw)':-2,fps=30" \
|
|
44
|
-
-c:v libvpx-vp9 -crf 32 -b:v 0 -pix_fmt yuv420p -row-mt 1 -deadline good -cpu-used 2 \
|
|
45
|
-
public/media/hero.webm
|
|
46
|
-
|
|
47
|
-
# MP4 (H.264), fallback. faststart so playback starts before the download ends.
|
|
48
|
-
ffmpeg -i input.mov -t 10 -an -vf "scale='min(1920,iw)':-2,fps=30" \
|
|
49
|
-
-c:v libx264 -preset slow -crf 24 -profile:v high -pix_fmt yuv420p -movflags +faststart \
|
|
50
|
-
public/media/hero.mp4
|
|
51
|
-
|
|
52
|
-
# Mobile variant: same commands with min(1080,iw) and one CRF step higher.
|
|
53
|
-
|
|
54
|
-
# Poster from a representative frame (pick the second, never a black first frame)
|
|
55
|
-
ffmpeg -ss 2.5 -i input.mov -frames:v 1 -vf "scale='min(1920,iw)':-2" -c:v libwebp -quality 80 public/media/hero-poster.webp
|
|
56
|
-
```
|
|
57
|
-
|
|
58
|
-
```tsx
|
|
59
|
-
<video autoPlay muted loop playsInline preload="metadata" poster="/media/hero-poster.webp">
|
|
60
|
-
<source src="/media/hero.webm" type="video/webm" />
|
|
61
|
-
<source src="/media/hero.mp4" type="video/mp4" />
|
|
62
|
-
</video>
|
|
63
|
-
```
|
|
64
|
-
|
|
65
|
-
- Under `prefers-reduced-motion` or Save-Data, show the poster and do not autoplay.
|
|
66
|
-
- Pause offscreen loops (IntersectionObserver); keep one decoding video per viewport.
|
|
67
|
-
- A scroll-scrubbed video sequence is better as a WebP frame sequence on a canvas than as `currentTime` scrubbing, which stutters on most browsers: `ffmpeg -i input.mov -vf "fps=24,scale='min(1440,iw)':-2" -c:v libwebp -quality 75 frames/%04d.webp`, then cap the count (120 to 180 frames).
|
|
68
|
-
|
|
69
|
-
## 4. Images
|
|
70
|
-
|
|
71
|
-
Use `next/image` for everything it can serve: it negotiates AVIF and WebP and resizes per `sizes`. Set `images.formats = ["image/avif", "image/webp"]` in `next.config`. What it cannot fix is a 12MB source in the repo, so normalize sources first:
|
|
72
|
-
|
|
73
|
-
```bash
|
|
74
|
-
# one-off with sharp-cli: cap sources at 2400w and convert to high-quality WebP
|
|
75
|
-
npx sharp-cli -i "assets/raw/*.{jpg,png}" -o public/images/ resize 2400 --withoutEnlargement -f webp -q 82
|
|
76
|
-
```
|
|
77
|
-
|
|
78
|
-
- The LCP hint on exactly one image, the LCP image of that page: `preload` or `priority`, whichever the installed `next/image` declares ([setup.md](setup.md) §0, Known API shifts). Never `fetchPriority="high"` without `loading="eager"`; on a lazy image the hint does nothing.
|
|
79
|
-
- `sizes` on every large image, matching the layout (`(min-width: 1024px) 50vw, 100vw`).
|
|
80
|
-
- Width and height, or `fill` inside a sized frame, on every image: that is CLS.
|
|
81
|
-
- PNG only for real transparency that WebP cannot carry; GIF never (convert to a muted video loop).
|
|
82
|
-
- The grade from [imagery.md](imagery.md) is applied before export, not with CSS filters at runtime.
|
|
83
|
-
|
|
84
|
-
## 5. 3D models and textures
|
|
85
|
-
|
|
86
|
-
```bash
|
|
87
|
-
# one command: dedupe, weld, meshopt geometry, WebP textures capped at 2048,
|
|
88
|
-
# with the scene graph kept intact
|
|
89
|
-
npx @gltf-transform/cli optimize input.glb public/models/product.glb \
|
|
90
|
-
--compress meshopt --texture-compress webp --texture-size 2048 \
|
|
91
|
-
--flatten false --join false
|
|
92
|
-
```
|
|
93
|
-
|
|
94
|
-
- `--flatten false --join false` keep the node hierarchy and names. The defaults flatten the scene and merge meshes, which drops the node names that rigs, `useGLTF` lookups and per-part animation target. Drop the two flags only for a static, single-object model that nothing addresses by name.
|
|
95
|
-
- drei's `useGLTF` takes meshopt as a boolean and ships its own decoder: `useGLTF(url, true, true)` (Draco on, meshopt on). The Draco decoder is fetched only for a Draco-compressed file; self-host it once with `useGLTF.setDecoderPath("/draco/")` at module level in the island. See [webgl-scenes.md](webgl-scenes.md) §1.
|
|
96
|
-
- Textures are usually the weight, not geometry: 2048 max on desktop, 1024 for mobile variants.
|
|
97
|
-
- Every model loads when its island mounts. `useGLTF.preload` buys nothing inside the lazy island (it runs only when the island chunk loads), and calling it from the section pulls three into the page bundle; the poster covers the wait.
|
|
98
|
-
- Always ship the poster image that stands in before and instead of the canvas ([webgl-r3f.md](webgl-r3f.md)).
|
|
99
|
-
|
|
100
|
-
## 6. Fonts
|
|
101
|
-
|
|
102
|
-
- Two families, WOFF2 only, variable files where they exist. `next/font` self-hosts and subsets Google fonts automatically; for local files subset to the languages used.
|
|
103
|
-
- Preload the display and the body face, nothing else.
|
|
104
|
-
- `adjustFontFallback` (default in `next/font`) to keep CLS near zero when the face swaps.
|
|
105
|
-
- The TTF kept for OG images lives outside `public/` so it is never shipped to browsers ([seo.md](seo.md)).
|
|
106
|
-
|
|
107
|
-
## 7. Code
|
|
108
|
-
|
|
109
|
-
- **Lazy WebGL.** three.js, R3F, Drei and postprocessing load only inside the island, via `next/dynamic` with `ssr: false`. They must never appear in the first-load bundle.
|
|
110
|
-
- **One clock.** Lenis, ScrollTrigger and R3F all on `gsap.ticker` ([setup.md](setup.md)); a second requestAnimationFrame loop is a performance bug, not a style choice.
|
|
111
|
-
- **Animate transforms and opacity only.** Never `top`, `left`, `width`, `height` or `filter: blur()` on scroll. `clip-path` and masks are fine for reveals.
|
|
112
|
-
- **No React state per frame.** Scroll and pointer values go to refs, CSS variables or uniforms ([webgl-r3f.md](webgl-r3f.md) §3).
|
|
113
|
-
- **`will-change` only while animating**, set and cleared by the timeline, never globally.
|
|
114
|
-
- **Clean up.** Every `useGSAP` scope reverts on unmount; route changes must not leave ScrollTriggers, tickers or canvases alive ([route-transitions.md](route-transitions.md)).
|
|
115
|
-
- **Register only the GSAP plugins in use.**
|
|
116
|
-
|
|
117
|
-
## 8. Verify
|
|
118
|
-
|
|
119
|
-
1. **Build for production and serve it** (`next build && next start -p <port>`, or `vite build && vite preview --port <port>`). Never measure or shoot the dev server. **Confirm the port belongs to the server you just started:** before starting, `lsof -i :<port>` (Windows: `netstat -ano | findstr :<port>`) shows nothing; after, it shows the PID of that production process. A dev server left running on the same port answers every request silently, and `next start` then fails with `EADDRINUSE` in a log nobody reads.
|
|
120
|
-
2. **First-load JS.** Turbopack builds print no per-route "First Load JS" table, so measure it from the served HTML. The script also proves three.js is absent on touch and arrives lazily on desktop:
|
|
121
|
-
|
|
122
|
-
```js
|
|
123
|
-
// scripts/firstload.mjs: node scripts/firstload.mjs http://localhost:<port>
|
|
124
|
-
import { gzipSync } from "node:zlib";
|
|
125
|
-
import { chromium } from "playwright";
|
|
126
|
-
|
|
127
|
-
const base = process.argv[2];
|
|
128
|
-
const html = await (await fetch(base + "/")).text();
|
|
129
|
-
const srcs = [...new Set([...html.matchAll(/<script[^>]+src="([^"]+)"/g)].map((m) => m[1]))];
|
|
130
|
-
const THREE = /WebGLRenderer|WebGL2RenderingContext|react-three-fiber/;
|
|
131
|
-
let raw = 0, gz = 0;
|
|
132
|
-
const hits = [];
|
|
133
|
-
for (const s of srcs) {
|
|
134
|
-
const buf = Buffer.from(await (await fetch(new URL(s, base))).arrayBuffer());
|
|
135
|
-
raw += buf.length; gz += gzipSync(buf).length;
|
|
136
|
-
if (THREE.test(buf.toString("utf8"))) hits.push(s);
|
|
137
|
-
}
|
|
138
|
-
console.log(`first-load: ${srcs.length} scripts, ${(raw / 1024).toFixed(1)} kB raw, ${(gz / 1024).toFixed(1)} kB gzip`);
|
|
139
|
-
console.log(`three.js in first load: ${hits.length ? hits.join(", ") : "none"}`);
|
|
140
|
-
|
|
141
|
-
const browser = await chromium.launch({ args: ["--use-angle=swiftshader", "--enable-unsafe-swiftshader"] });
|
|
142
|
-
for (const [name, opts] of [
|
|
143
|
-
["desktop", { viewport: { width: 1440, height: 900 } }],
|
|
144
|
-
["phone", { viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true }],
|
|
145
|
-
]) {
|
|
146
|
-
const page = await browser.newPage(opts);
|
|
147
|
-
const js = [];
|
|
148
|
-
page.on("response", async (r) => {
|
|
149
|
-
if (!r.url().endsWith(".js")) return;
|
|
150
|
-
try { const b = await r.body(); js.push({ kb: b.length / 1024, three: THREE.test(b.toString("utf8")) }); } catch {}
|
|
151
|
-
});
|
|
152
|
-
await page.goto(base, { waitUntil: "networkidle" });
|
|
153
|
-
await page.waitForTimeout(1500);
|
|
154
|
-
const lazy = js.filter((j) => j.three);
|
|
155
|
-
console.log(`${name}: ${js.length} JS files; three.js chunks ${lazy.length} (${lazy.reduce((a, b) => a + b.kb, 0).toFixed(1)} kB raw)`);
|
|
156
|
-
await page.close();
|
|
157
|
-
}
|
|
158
|
-
await browser.close();
|
|
159
|
-
```
|
|
160
|
-
|
|
161
|
-
3. **LCP and CLS: an in-page probe** under 4x CPU and a throttled network (about 1.6 Mbps down, 150ms latency), mobile 390 and desktop 1440. It names the LCP element, which is the number that matters: with a hidden split hero or a match cut, the LCP element is whatever appears last, and on an unthrottled local server the figure is meaningless (a text hero measures 150ms locally).
|
|
162
|
-
|
|
163
|
-
```js
|
|
164
|
-
// scripts/vitals.mjs: node scripts/vitals.mjs http://localhost:<port>
|
|
165
|
-
import { chromium } from "playwright";
|
|
166
|
-
|
|
167
|
-
const base = process.argv[2];
|
|
168
|
-
const browser = await chromium.launch();
|
|
169
|
-
for (const [label, opts] of [
|
|
170
|
-
["mobile 390", { viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true }],
|
|
171
|
-
["desktop 1440", { viewport: { width: 1440, height: 900 } }],
|
|
172
|
-
]) {
|
|
173
|
-
const ctx = await browser.newContext(opts);
|
|
174
|
-
const page = await ctx.newPage();
|
|
175
|
-
const cdp = await ctx.newCDPSession(page);
|
|
176
|
-
await cdp.send("Emulation.setCPUThrottlingRate", { rate: 4 });
|
|
177
|
-
await cdp.send("Network.enable");
|
|
178
|
-
await cdp.send("Network.emulateNetworkConditions", {
|
|
179
|
-
offline: false, latency: 150, downloadThroughput: (1.6 * 1024 * 1024) / 8, uploadThroughput: (750 * 1024) / 8,
|
|
180
|
-
});
|
|
181
|
-
await page.addInitScript(() => {
|
|
182
|
-
const v = { lcp: 0, cls: 0, el: "" };
|
|
183
|
-
Object.assign(window, { __vitals: v });
|
|
184
|
-
new PerformanceObserver((list) => {
|
|
185
|
-
for (const e of list.getEntries()) {
|
|
186
|
-
v.lcp = e.startTime;
|
|
187
|
-
const el = e.element;
|
|
188
|
-
v.el = el ? `${el.tagName} ${(el.getAttribute("src") ?? el.textContent ?? "").slice(0, 60)}` : "";
|
|
189
|
-
}
|
|
190
|
-
}).observe({ type: "largest-contentful-paint", buffered: true });
|
|
191
|
-
new PerformanceObserver((list) => {
|
|
192
|
-
for (const e of list.getEntries()) if (!e.hadRecentInput) v.cls += e.value;
|
|
193
|
-
}).observe({ type: "layout-shift", buffered: true });
|
|
194
|
-
});
|
|
195
|
-
await page.goto(base, { waitUntil: "load" });
|
|
196
|
-
await page.waitForTimeout(5000); // let timed entrances finish: their content is often the LCP
|
|
197
|
-
await page.mouse.wheel(0, 600); // LCP stops recording at the first input
|
|
198
|
-
await page.waitForTimeout(500);
|
|
199
|
-
const v = await page.evaluate(() => window.__vitals);
|
|
200
|
-
console.log(`${label}: LCP ${(v.lcp / 1000).toFixed(2)}s (${v.el.trim()}), CLS ${v.cls.toFixed(3)}`);
|
|
201
|
-
await ctx.close();
|
|
202
|
-
}
|
|
203
|
-
await browser.close();
|
|
204
|
-
```
|
|
205
|
-
|
|
206
|
-
Lighthouse (or PageSpeed Insights on the deploy) on the home page and one inner page is the second opinion. Lab tools report TBT, not INP: INP needs field data (PageSpeed's CrUX panel once the site has traffic) or a manual interaction trace in the performance panel (click the menu, a hover scene and a form control; read the longest interaction). Until then write "INP: field data pending" with the TBT number.
|
|
207
|
-
4. **Frame trace for timed entrances.** A PNG at rest cannot show a hold that fires over an empty frame, a split hero that flashes unsplit, or a reveal stuck at opacity 0. Capture the first 3 seconds and look at the frames in order:
|
|
208
|
-
|
|
209
|
-
```js
|
|
210
|
-
// scripts/trace.mjs: node scripts/trace.mjs http://localhost:<port> (writes shots/trace-<w>-NN.png)
|
|
211
|
-
import { mkdirSync } from "node:fs";
|
|
212
|
-
import { chromium } from "playwright";
|
|
213
|
-
|
|
214
|
-
const base = process.argv[2];
|
|
215
|
-
mkdirSync("shots", { recursive: true });
|
|
216
|
-
const browser = await chromium.launch();
|
|
217
|
-
for (const [w, h, mobile] of [[1440, 900, false], [390, 844, true]]) {
|
|
218
|
-
const page = await browser.newPage({ viewport: { width: w, height: h }, isMobile: mobile, hasTouch: mobile });
|
|
219
|
-
const t0 = Date.now();
|
|
220
|
-
await page.goto(base, { waitUntil: "commit" });
|
|
221
|
-
for (let i = 0; i < 15; i++) {
|
|
222
|
-
await page.screenshot({ path: `shots/trace-${w}-${String(i).padStart(2, "0")}.png` }).catch(() => {});
|
|
223
|
-
console.log(`${w} frame ${i}: ${Date.now() - t0}ms`);
|
|
224
|
-
await page.waitForTimeout(200);
|
|
225
|
-
}
|
|
226
|
-
await page.close();
|
|
227
|
-
}
|
|
228
|
-
await browser.close();
|
|
229
|
-
```
|
|
230
|
-
|
|
231
|
-
Each entrance must do what its recipe says: the hold frame of a match cut is the photo ([page-entrances.md](page-entrances.md)), the split hero never shows unsplit, nothing stays invisible ([pitfalls.md](pitfalls.md) §3).
|
|
232
|
-
5. Scroll the full page with the browser's performance panel recording: no long tasks during scroll, frames steady through the pinned showpiece.
|
|
233
|
-
6. Re-run the size listing from §2 on the output: nothing over budget without a written reason in STRUQ.md.
|
|
234
|
-
|
|
235
|
-
The scripts use the `playwright` package (`npm i -D playwright`, then `npx playwright install chromium`); any harness browser tool that can throttle, observe and screenshot does the same job.
|
|
@@ -1,341 +0,0 @@
|
|
|
1
|
-
# Pinning & Scrub-Tied Sequences
|
|
2
|
-
|
|
3
|
-
The showpiece category. A pinned, scrubbed section is where a site stops being a page and becomes a sequence, but it's also the easiest place to wreck UX. Budget: **1 to 2 pinned sequences per site.** Three or more and scrolling feels hijacked. For named, ready-made showpiece recipes with ids, see [showcase.md](showcase.md).
|
|
4
|
-
|
|
5
|
-
Core concept first, because everything here is one idea: **`scrub` ties timeline progress to scroll position** (scroll = the playhead), and **`pin` freezes an element while the scroll distance plays out**. Triggered tweens fire once and play on their own clock; scrubbed timelines move when the user moves and stop when they stop. That hand-connection is the feel difference between an award site and a slideshow.
|
|
6
|
-
|
|
7
|
-
All snippets assume [setup.md](setup.md). `scrub: true` vs `scrub: 1` is a feel decision; see [feel-layer.md](feel-layer.md).
|
|
8
|
-
|
|
9
|
-
**A pin that jerks, jumps, snaps or lands in the wrong place** is one of a short list of causes (`anticipatePin` with Lenis, stale measurements, a transformed ancestor, `vh` on mobile, a pin in a flex or grid parent, nested pins). Work through [pitfalls.md](pitfalls.md) §2 before tuning `scrub`.
|
|
10
|
-
|
|
11
|
-
---
|
|
12
|
-
|
|
13
|
-
## 1. Pinned scrub timeline (the workhorse)
|
|
14
|
-
|
|
15
|
-
**Term:** Pinned sequence / scroll-told story.
|
|
16
|
-
**Mechanic:** A one-screen (`100svh`) section pins; the user scrolls "through" an invisible distance (`end: "+=250%"` → 2.5 viewport-heights of scroll) while a timeline scrubs across phases: text swaps, media transforms, layers move. Scroll distance, not time, is the duration unit.
|
|
17
|
-
|
|
18
|
-
**When:** Product walkthroughs, process steps, one cinematic brand moment.
|
|
19
|
-
**Overkill when:** the content is just three paragraphs. Pinning 3 viewports of scroll to read 40 words is hostage-taking.
|
|
20
|
-
**Cost:** Pinning re-parents into a pin-spacer and recalculates on resize; transforms inside are cheap. The real cost is UX: always check the section is skippable in <3 swipes on mobile, and skip pinning entirely under `prefers-reduced-motion`.
|
|
21
|
-
|
|
22
|
-
```tsx
|
|
23
|
-
"use client";
|
|
24
|
-
|
|
25
|
-
import { useRef } from "react";
|
|
26
|
-
import { gsap, useGSAP } from "@/lib/gsap";
|
|
27
|
-
|
|
28
|
-
const PHASES = [
|
|
29
|
-
{ kicker: "01", title: "Research", body: "We start in the field, not in Figma." },
|
|
30
|
-
{ kicker: "02", title: "Systems", body: "Design tokens before screens." },
|
|
31
|
-
{ kicker: "03", title: "Craft", body: "Motion and microcopy get design reviews too." },
|
|
32
|
-
];
|
|
33
|
-
|
|
34
|
-
// ONE gate for the layout switch and the pin. Touch, small screens, reduced motion and
|
|
35
|
-
// no-JS all get the stacked list the server rendered.
|
|
36
|
-
const GATE = "(min-width: 768px) and (prefers-reduced-motion: no-preference)";
|
|
37
|
-
|
|
38
|
-
export function ProcessSequence() {
|
|
39
|
-
const container = useRef<HTMLElement>(null);
|
|
40
|
-
|
|
41
|
-
useGSAP(
|
|
42
|
-
() => {
|
|
43
|
-
const mm = gsap.matchMedia();
|
|
44
|
-
|
|
45
|
-
mm.add(GATE, () => {
|
|
46
|
-
const section = container.current!;
|
|
47
|
-
const phases = gsap.utils.toArray<HTMLElement>("[data-phase]");
|
|
48
|
-
|
|
49
|
-
// Switch the stacked list into a stage, then hide the later phases with GSAP.
|
|
50
|
-
// matchMedia reverts the gsap.set and runs the returned cleanup when the gate
|
|
51
|
-
// stops matching, so the list comes back by itself.
|
|
52
|
-
section.dataset.mode = "rig";
|
|
53
|
-
gsap.set(phases.slice(1), { yPercent: 30, autoAlpha: 0 });
|
|
54
|
-
|
|
55
|
-
const tl = gsap.timeline({
|
|
56
|
-
scrollTrigger: {
|
|
57
|
-
trigger: section,
|
|
58
|
-
start: "top top",
|
|
59
|
-
end: "+=250%", // 2.5 viewports of scroll-room: ~0.8 per phase
|
|
60
|
-
pin: true,
|
|
61
|
-
scrub: 1, // 1s catch-up smoothing, see feel-layer.md
|
|
62
|
-
// snap: phases feel like steps? snap to them. Continuous story? omit.
|
|
63
|
-
snap: { snapTo: 1 / (phases.length - 1), duration: 0.4, ease: "power2.inOut" },
|
|
64
|
-
},
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
phases.forEach((phase, i) => {
|
|
68
|
-
if (i === 0) return; // phase 0 is the resting state
|
|
69
|
-
tl.to(phases[i - 1], { yPercent: -30, autoAlpha: 0, ease: "power2.in", duration: 0.4 })
|
|
70
|
-
.to(phase,
|
|
71
|
-
{ yPercent: 0, autoAlpha: 1, ease: "power2.out", duration: 0.4 },
|
|
72
|
-
"<0.15" // overlap: new phase starts before old one is fully gone
|
|
73
|
-
);
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
// A continuous element that evolves across ALL phases sells the sequence
|
|
77
|
-
tl.fromTo("[data-dial]", { rotate: 0 }, { rotate: 270, ease: "none", duration: tl.duration() }, 0);
|
|
78
|
-
|
|
79
|
-
return () => { delete section.dataset.mode; };
|
|
80
|
-
});
|
|
81
|
-
},
|
|
82
|
-
{ scope: container }
|
|
83
|
-
);
|
|
84
|
-
|
|
85
|
-
// Server markup = a readable stacked list. data-mode="rig" (motion branch only) turns it
|
|
86
|
-
// into the pinned stage: the phases stack absolutely and the section takes one screen.
|
|
87
|
-
return (
|
|
88
|
-
<section
|
|
89
|
-
ref={container}
|
|
90
|
-
className="group/seq relative bg-surface-1 py-[8rem]
|
|
91
|
-
data-[mode=rig]:h-[100svh] data-[mode=rig]:overflow-hidden data-[mode=rig]:py-0"
|
|
92
|
-
>
|
|
93
|
-
<div className="grid grid-cols-12 items-center gap-y-16 px-6 group-data-[mode=rig]/seq:h-full">
|
|
94
|
-
<ol className="relative col-span-12 space-y-16 md:col-span-5 md:col-start-2
|
|
95
|
-
group-data-[mode=rig]/seq:h-full group-data-[mode=rig]/seq:space-y-0">
|
|
96
|
-
{PHASES.map((p) => (
|
|
97
|
-
<li key={p.kicker} data-phase
|
|
98
|
-
className="flex flex-col justify-center group-data-[mode=rig]/seq:absolute group-data-[mode=rig]/seq:inset-0">
|
|
99
|
-
<span className="eyebrow text-eyebrow text-ink-muted">{p.kicker}</span>
|
|
100
|
-
<h3 className="font-display text-h2 mt-4">{p.title}</h3>
|
|
101
|
-
<p className="text-body mt-6 max-w-[36ch] text-ink-muted">{p.body}</p>
|
|
102
|
-
</li>
|
|
103
|
-
))}
|
|
104
|
-
</ol>
|
|
105
|
-
<div data-dial aria-hidden
|
|
106
|
-
className="col-span-4 col-start-8 aspect-square rounded-full border border-line max-md:hidden" />
|
|
107
|
-
</div>
|
|
108
|
-
</section>
|
|
109
|
-
);
|
|
110
|
-
}
|
|
111
|
-
```
|
|
112
|
-
|
|
113
|
-
Craft notes:
|
|
114
|
-
- **`ease: "none"` for anything that should track scroll linearly** (the dial); eases *inside* a scrubbed timeline still apply within their segment, so entrance/exit phases keep `power2` and each swap has shape.
|
|
115
|
-
- **One continuous element** (`data-dial`) spanning the whole timeline is what makes it feel like a sequence rather than a slide deck.
|
|
116
|
-
- **`"<0.15"` overlap** between exit and entrance: gapless storytelling.
|
|
117
|
-
- The `01/02/03` kickers here are fine. Ordinal numbering *inside one stepped component* is real content, distinct from the page-wide numbered-everything tell ([anti-patterns.md](anti-patterns.md) §16). Don't let this pattern leak numerals onto every other section.
|
|
118
|
-
- Snap is for stepped content; omit for continuous narratives. Never snap with distances under ~80% per phase (fights the user's thumb).
|
|
119
|
-
- **Server markup is the final, readable state.** Nothing is hidden with an inline `style={{ opacity: 0 }}` or a CSS class: the later phases are hidden by `gsap.set` inside the motion branch, so reduced motion, touch, a failed bundle and no-JS all read the three phases as a list. The layout switch is a data attribute set in the same branch, so the list and the pin can never disagree.
|
|
120
|
-
- **One `GATE` string** for every decision about this section (the layout switch, the pin, and a canvas if one joins it, [webgl-r3f.md](webgl-r3f.md) §1). Two queries drift: a fine-pointer tablet ends up with a pin and no stage, or a stage and no pin.
|
|
121
|
-
|
|
122
|
-
**Cheap tell:** content swaps via `opacity` crossfade fired at thresholds (`toggleActions`), no pin: a slideshow that happens near scroll. Or: pinned for 6 viewports with one sentence. **Instead:** Pinned, scrubbed, phases overlapping, one element evolving continuously, ≤2.5 viewports.
|
|
123
|
-
|
|
124
|
-
---
|
|
125
|
-
|
|
126
|
-
## 2. Horizontal scroll section
|
|
127
|
-
|
|
128
|
-
**Term:** Horizontal scroll-jack (the respectable kind).
|
|
129
|
-
**Mechanic:** A pinned viewport-height section translates a wide flex row on `x` as the user scrolls vertically. Vertical input, horizontal travel. The pin distance equals the hidden width, so scroll speed maps 1:1 to lateral movement. This 1:1 mapping is why it feels natural where `scroll-snap` carousels feel like a different widget.
|
|
130
|
-
|
|
131
|
-
**When:** Galleries, project indexes, timeline content that is *genuinely sequential*.
|
|
132
|
-
**Overkill when:** the content would work as a grid. Horizontal scroll must be earned by content with an axis. Never put critical info (pricing, CTA) inside one.
|
|
133
|
-
**Cost:** Transforms only, so cheap. But images off-screen still load; lazy-load panels 3+. On touch, 1:1 vertical→horizontal is fine; *nested* horizontal sections are not: one per page.
|
|
134
|
-
|
|
135
|
-
```tsx
|
|
136
|
-
"use client";
|
|
137
|
-
|
|
138
|
-
import { useRef } from "react";
|
|
139
|
-
import { gsap, useGSAP } from "@/lib/gsap";
|
|
140
|
-
|
|
141
|
-
export function WorkGallery({ projects }: { projects: { title: string; meta: string; img: string }[] }) {
|
|
142
|
-
const container = useRef<HTMLElement>(null);
|
|
143
|
-
const track = useRef<HTMLDivElement>(null);
|
|
144
|
-
|
|
145
|
-
useGSAP(
|
|
146
|
-
() => {
|
|
147
|
-
const getDistance = () => track.current!.scrollWidth - window.innerWidth;
|
|
148
|
-
|
|
149
|
-
const scrollTween = gsap.to(track.current, {
|
|
150
|
-
x: () => -getDistance(), // function-based: recalcs on refresh/resize
|
|
151
|
-
ease: "none", // scroll IS the ease; easing here feels broken
|
|
152
|
-
scrollTrigger: {
|
|
153
|
-
trigger: container.current,
|
|
154
|
-
start: "top top",
|
|
155
|
-
end: () => `+=${getDistance()}`, // pin distance = hidden width → 1:1 feel
|
|
156
|
-
pin: true,
|
|
157
|
-
scrub: 1,
|
|
158
|
-
invalidateOnRefresh: true,
|
|
159
|
-
// Do NOT add anticipatePin here when Lenis (or any smooth scroll) is on.
|
|
160
|
-
// See craft note below: it jumps the frame at the pin handoff.
|
|
161
|
-
},
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
// Per-panel parallax inside the horizontal world: containerAnimation
|
|
165
|
-
gsap.utils.toArray<HTMLElement>("[data-panel] img").forEach((img) => {
|
|
166
|
-
gsap.fromTo(img, { xPercent: -8 }, {
|
|
167
|
-
xPercent: 8,
|
|
168
|
-
ease: "none",
|
|
169
|
-
scrollTrigger: {
|
|
170
|
-
trigger: img.parentElement,
|
|
171
|
-
containerAnimation: scrollTween, // triggers measured inside the moving track
|
|
172
|
-
start: "left right",
|
|
173
|
-
end: "right left",
|
|
174
|
-
scrub: true,
|
|
175
|
-
},
|
|
176
|
-
});
|
|
177
|
-
});
|
|
178
|
-
},
|
|
179
|
-
{ scope: container }
|
|
180
|
-
);
|
|
181
|
-
|
|
182
|
-
return (
|
|
183
|
-
<section ref={container} className="overflow-hidden bg-surface-0">
|
|
184
|
-
<div ref={track} className="flex h-[100svh] w-max items-center gap-[6vw] px-[8vw]">
|
|
185
|
-
<div className="w-[28vw] shrink-0">
|
|
186
|
-
<h2 className="font-display text-h2">Selected<br />work</h2>
|
|
187
|
-
</div>
|
|
188
|
-
{projects.map((p, i) => (
|
|
189
|
-
<article key={p.title} data-panel className="w-[42vw] shrink-0">
|
|
190
|
-
<div className="overflow-hidden">
|
|
191
|
-
<img src={p.img} alt="" loading={i > 1 ? "lazy" : undefined}
|
|
192
|
-
className="aspect-[4/3] w-full scale-110 object-cover" />
|
|
193
|
-
</div>
|
|
194
|
-
<p className="text-small mt-4 flex items-baseline justify-between text-ink-muted">
|
|
195
|
-
<span>{p.title}</span><span className="eyebrow text-eyebrow">{p.meta}</span>
|
|
196
|
-
</p>
|
|
197
|
-
</article>
|
|
198
|
-
))}
|
|
199
|
-
</div>
|
|
200
|
-
</section>
|
|
201
|
-
);
|
|
202
|
-
}
|
|
203
|
-
```
|
|
204
|
-
|
|
205
|
-
Craft notes:
|
|
206
|
-
- **Function-based `x` and `end` + `invalidateOnRefresh`**: without these, resize breaks the mapping and the section over/under-scrolls.
|
|
207
|
-
- **`containerAnimation`** is the GSAP-only feature that makes triggers work *inside* the moving track (per-panel parallax, reveals as panels enter). This is why Motion isn't the tool here.
|
|
208
|
-
- Varying panel widths (`28vw` intro, `42vw` projects) beats uniform cards, the same asymmetry rule as vertical layout.
|
|
209
|
-
- **Never `anticipatePin` with smooth scroll.** `anticipatePin` pins *early* by predicting scroll velocity, but Lenis feeds it a **smoothed** velocity, so the prediction overshoots and the pin engages a frame too early, then corrects. The result is a visible jump at the exact vertical→horizontal handoff that reads as "jagged, not eased in," worst on a fast scroll-in. It exists to hide native-scroll pin latency; smooth scroll already removes that latency, so it only adds jitter. The fix for a jagged handoff is almost always **remove `anticipatePin`** (not tuning `scrub`). If the *first* scroll-in specifically snaps, the cause is stale pin positions: add the delayed initial `lenis.resize()` + `ScrollTrigger.refresh()` from [setup.md](setup.md) §2, not `anticipatePin`.
|
|
210
|
-
- Mobile fallback: **never pin on touch**. The exact matchMedia pattern is §4 below. Pinned horizontal on small phones is a common award-site sin.
|
|
211
|
-
|
|
212
|
-
**Cheap tell:** `overflow-x-auto` with `scroll-snap` and visible scrollbar presented as the showpiece; or a horizontal section where panels are identical-width cards 1 to 12. **Instead:** Pinned 1:1 mapping, mixed panel widths, parallax inside panels, native fallback on mobile.
|
|
213
|
-
|
|
214
|
-
---
|
|
215
|
-
|
|
216
|
-
## 3. Scrub-tied hero (no pin)
|
|
217
|
-
|
|
218
|
-
**Term:** Departure scrub.
|
|
219
|
-
**Mechanic:** The hero reacts to the first hundreds of scroll pixels: headline drifts up faster than scroll, media scales and dims, eyebrow fades. No pinning; the page just *leaves* cinematically instead of sliding away as a rigid block.
|
|
220
|
-
|
|
221
|
-
**When:** Every hero. This is the cheapest scrub win on the page. Do it even when you do nothing else from this file.
|
|
222
|
-
**Cost:** Trivial.
|
|
223
|
-
|
|
224
|
-
```tsx
|
|
225
|
-
useGSAP(
|
|
226
|
-
() => {
|
|
227
|
-
const tl = gsap.timeline({
|
|
228
|
-
scrollTrigger: {
|
|
229
|
-
trigger: container.current,
|
|
230
|
-
start: "top top",
|
|
231
|
-
end: "bottom top", // while the hero leaves the viewport
|
|
232
|
-
scrub: true, // direct tie; departure should feel attached to the hand
|
|
233
|
-
},
|
|
234
|
-
});
|
|
235
|
-
|
|
236
|
-
tl.to("[data-hero-title]", { yPercent: -40, ease: "none" }, 0) // rises faster than page
|
|
237
|
-
.to("[data-hero-media]", { scale: 1.12, ease: "none" }, 0) // grows as it leaves
|
|
238
|
-
.to("[data-hero-veil]", { autoAlpha: 0.55, ease: "none" }, 0) // dims under a veil div
|
|
239
|
-
.to("[data-hero-meta]", { autoAlpha: 0, yPercent: -100, ease: "none" }, 0);
|
|
240
|
-
},
|
|
241
|
-
{ scope: container }
|
|
242
|
-
);
|
|
243
|
-
```
|
|
244
|
-
|
|
245
|
-
Different elements at different rates = instant depth (full multi-layer treatment in [depth-parallax.md](depth-parallax.md)). Dim with a `data-hero-veil` overlay div, not `filter: brightness()`: opacity composites cheaply, scrubbed filters don't.
|
|
246
|
-
|
|
247
|
-
**Cheap tell:** hero scrolls away as one static raster. **Instead:** Title, media, veil, and meta leave at four different speeds, tied to the hand.
|
|
248
|
-
|
|
249
|
-
---
|
|
250
|
-
|
|
251
|
-
## 4. The touch fallback: pin on desktop, swipe on touch (the exact pattern)
|
|
252
|
-
|
|
253
|
-
Pinning fights native touch scroll; every pinned/horizontal section MUST branch.
|
|
254
|
-
The contract: **one markup tree**, authored so the touch experience is pure
|
|
255
|
-
CSS (scroll-snap), with the pin added *only* inside a desktop
|
|
256
|
-
`gsap.matchMedia()` branch. matchMedia reverts the ScrollTriggers automatically
|
|
257
|
-
when the viewport crosses the breakpoint: no stale pins, nothing to clean up
|
|
258
|
-
by hand.
|
|
259
|
-
|
|
260
|
-
```tsx
|
|
261
|
-
"use client";
|
|
262
|
-
|
|
263
|
-
import { useRef } from "react";
|
|
264
|
-
import { gsap, useGSAP } from "@/lib/gsap";
|
|
265
|
-
|
|
266
|
-
export function Showcase({ items }: { items: { id: string; title: string }[] }) {
|
|
267
|
-
const container = useRef<HTMLElement>(null);
|
|
268
|
-
const track = useRef<HTMLDivElement>(null);
|
|
269
|
-
|
|
270
|
-
useGSAP(
|
|
271
|
-
() => {
|
|
272
|
-
const mm = gsap.matchMedia();
|
|
273
|
-
|
|
274
|
-
// Desktop + motion-OK ONLY: the pinned 1:1 horizontal scrub (§2).
|
|
275
|
-
mm.add("(min-width: 768px) and (prefers-reduced-motion: no-preference)", () => {
|
|
276
|
-
const getDistance = () => track.current!.scrollWidth - window.innerWidth;
|
|
277
|
-
gsap.to(track.current, {
|
|
278
|
-
x: () => -getDistance(),
|
|
279
|
-
ease: "none",
|
|
280
|
-
scrollTrigger: {
|
|
281
|
-
trigger: container.current,
|
|
282
|
-
start: "top top",
|
|
283
|
-
end: () => `+=${getDistance()}`,
|
|
284
|
-
pin: true,
|
|
285
|
-
scrub: 1,
|
|
286
|
-
invalidateOnRefresh: true,
|
|
287
|
-
},
|
|
288
|
-
});
|
|
289
|
-
});
|
|
290
|
-
|
|
291
|
-
// Touch/small/reduced: NO branch needed. The markup below is already a
|
|
292
|
-
// native scroll-snap carousel via the max-md: utilities. No JS, nothing
|
|
293
|
-
// fighting the user's thumb.
|
|
294
|
-
},
|
|
295
|
-
{ scope: container }
|
|
296
|
-
);
|
|
297
|
-
|
|
298
|
-
return (
|
|
299
|
-
<section ref={container} className="overflow-hidden">
|
|
300
|
-
{/* One tree serves both modes: the max-md: snap utilities are inert on
|
|
301
|
-
desktop (where the pin translates the track), and the md: width is
|
|
302
|
-
inert on mobile (where the track scrolls natively). */}
|
|
303
|
-
<div
|
|
304
|
-
ref={track}
|
|
305
|
-
className="flex items-center gap-[5vw] px-[8vw]
|
|
306
|
-
max-md:snap-x max-md:snap-mandatory max-md:overflow-x-auto
|
|
307
|
-
md:h-[100svh] md:w-max"
|
|
308
|
-
>
|
|
309
|
-
{items.map((item) => (
|
|
310
|
-
<article key={item.id} className="w-[82vw] shrink-0 snap-center md:w-[42vw]">
|
|
311
|
-
{/* … */}
|
|
312
|
-
</article>
|
|
313
|
-
))}
|
|
314
|
-
</div>
|
|
315
|
-
</section>
|
|
316
|
-
);
|
|
317
|
-
}
|
|
318
|
-
```
|
|
319
|
-
|
|
320
|
-
What makes this the *correct* shape (each is a common failure when improvised):
|
|
321
|
-
- **The touch path is CSS-only.** If the fallback also needs JS, it can race the
|
|
322
|
-
matchMedia revert. Scroll-snap markup that's simply *inert* under the desktop
|
|
323
|
-
pin can't break.
|
|
324
|
-
- **Both conditions in one query** (`min-width` AND `no-preference`), so
|
|
325
|
-
reduced-motion desktop users get the native carousel too, not the pin.
|
|
326
|
-
- **Function-based `x`/`end` + `invalidateOnRefresh`** so crossing the
|
|
327
|
-
breakpoint (rotate, window resize) re-measures instead of over/under-scrolling.
|
|
328
|
-
- For *vertically stacked* pinned sequences (§1-style phase swaps), the touch
|
|
329
|
-
fallback is a plain stacked list: keep the phases `relative` in normal flow on
|
|
330
|
-
mobile and only position them `absolute` inside the desktop branch. Same
|
|
331
|
-
principle: the mobile layout must work with zero JS.
|
|
332
|
-
|
|
333
|
-
---
|
|
334
|
-
|
|
335
|
-
## When *not* to pin: the self-check
|
|
336
|
-
|
|
337
|
-
1. Would the content lose meaning as a normal section? If no → don't pin.
|
|
338
|
-
2. Is the pin distance ≤ 2.5 viewports? If no → cut phases.
|
|
339
|
-
3. Is there already a pinned section on this page? If yes → strong justification or cut.
|
|
340
|
-
4. Mobile + reduced-motion fallbacks written (§4)? Not optional.
|
|
341
|
-
5. Does anything *continuous* evolve across the pin? If it's only discrete swaps → maybe it's just a stepped section with reveals, not a pin.
|