@struq/skill 0.3.0 → 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 +22 -36
- package/package.json +3 -2
- package/skills/struq/SKILL.md +139 -307
- 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/{recipes/components/struq → blocks/kit}/Cursor.tsx +1 -1
- package/skills/struq/{recipes/components/struq → blocks/kit}/HeroCanvas.tsx +2 -2
- package/skills/struq/{recipes/components/struq → blocks/kit}/HorizontalGallery.tsx +2 -2
- package/skills/struq/{recipes/components/struq → blocks/kit}/MagneticButton.tsx +1 -1
- package/skills/struq/{recipes/components/struq → blocks/kit}/MediaFrame.tsx +2 -2
- package/skills/struq/{recipes/components/struq → blocks/kit}/PinnedZoom.tsx +14 -4
- package/skills/struq/{recipes/components/struq → blocks/kit}/Reveal.tsx +1 -1
- package/skills/struq/{recipes/components/struq → blocks/kit}/SmoothScroll.tsx +1 -1
- package/skills/struq/{recipes/components/struq → blocks/kit}/SplitHeadline.tsx +1 -1
- package/skills/struq/blocks/kit/blocks.css +2102 -0
- package/skills/struq/blocks/kit/images.ts +17 -0
- package/skills/struq/blocks/kit/photo-read.ts +102 -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/{cinematic/showpieces/toonstuk-fill-rows.tsx → blocks/showpieces/fill-rows.tsx} +1 -1
- package/skills/struq/{cinematic/showpieces/toonstuk-hero-opener.tsx → blocks/showpieces/hero-opener.tsx} +5 -5
- package/skills/struq/{cinematic/showpieces/toonstuk-image-to-fullbleed.tsx → blocks/showpieces/image-to-fullbleed.tsx} +16 -5
- package/skills/struq/blocks/showpieces/manifesto-scrub.tsx +48 -0
- package/skills/struq/{cinematic/showpieces/toonstuk-panel-wipe.tsx → blocks/showpieces/panel-wipe.tsx} +2 -2
- package/skills/struq/{cinematic/showpieces/toonstuk-pixel-resolve.tsx → blocks/showpieces/pixel-resolve.tsx} +1 -1
- package/skills/struq/{cinematic/showpieces/toonstuk-project-stack.tsx → blocks/showpieces/project-stack.tsx} +2 -2
- package/skills/struq/blocks/showpieces/scrub-connector.tsx +307 -0
- package/skills/struq/{cinematic/showpieces/toonstuk-wordmark-finale.tsx → blocks/showpieces/wordmark-finale.tsx} +1 -1
- 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 -22
- package/skills/struq/recipes/app/globals.css +34 -177
- package/skills/struq/recipes/app/layout.tsx +13 -11
- 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 +106 -281
- package/skills/struq/scripts/check.mjs +92 -126
- package/skills/struq/scripts/images.mjs +2 -2
- package/skills/struq/scripts/log.mjs +6 -23
- package/skills/struq/scripts/tokens.mjs +222 -0
- package/skills/struq/cinematic/showpieces/README.md +0 -18
- package/skills/struq/cinematic/showpieces/toonstuk-manifesto-scrub.tsx +0 -81
- package/skills/struq/cinematic/showpieces/toonstuk-scrub-connector.tsx +0 -78
- package/skills/struq/references/anti-patterns.md +0 -330
- package/skills/struq/references/baseline-prompt.md +0 -122
- package/skills/struq/references/brand.md +0 -143
- package/skills/struq/references/brief-router.md +0 -218
- package/skills/struq/references/composition.md +0 -190
- 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/hero-scenes.md +0 -107
- package/skills/struq/references/imagery.md +0 -125
- 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/niche-packets.md +0 -338
- 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 -340
- 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 -279
- package/skills/struq/references/sections.md +0 -141
- 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
- package/skills/struq/scripts/font-names.mjs +0 -40
- package/skills/struq/scripts/recipes.mjs +0 -135
- /package/skills/struq/{recipes/lib → blocks/kit}/gsap.ts +0 -0
- /package/skills/struq/{recipes/components/struq → blocks/kit}/scenes/contours.ts +0 -0
- /package/skills/struq/{recipes/components/struq → blocks/kit}/scenes/dots.ts +0 -0
- /package/skills/struq/{recipes/components/struq → blocks/kit}/scenes/ripple.ts +0 -0
- /package/skills/struq/{recipes/components/struq → blocks/kit}/scenes/types.ts +0 -0
|
@@ -1,340 +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
|
-
<h3 className="font-display text-h2">{p.title}</h3>
|
|
100
|
-
<p className="text-body mt-6 max-w-[36ch] text-ink-muted">{p.body}</p>
|
|
101
|
-
</li>
|
|
102
|
-
))}
|
|
103
|
-
</ol>
|
|
104
|
-
<div data-dial aria-hidden
|
|
105
|
-
className="col-span-4 col-start-8 aspect-square rounded-full border border-line max-md:hidden" />
|
|
106
|
-
</div>
|
|
107
|
-
</section>
|
|
108
|
-
);
|
|
109
|
-
}
|
|
110
|
-
```
|
|
111
|
-
|
|
112
|
-
Craft notes:
|
|
113
|
-
- **`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.
|
|
114
|
-
- **One continuous element** (`data-dial`) spanning the whole timeline is what makes it feel like a sequence rather than a slide deck.
|
|
115
|
-
- **`"<0.15"` overlap** between exit and entrance: gapless storytelling.
|
|
116
|
-
- 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.
|
|
117
|
-
- Snap is for stepped content; omit for continuous narratives. Never snap with distances under ~80% per phase (fights the user's thumb).
|
|
118
|
-
- **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.
|
|
119
|
-
- **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.
|
|
120
|
-
|
|
121
|
-
**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.
|
|
122
|
-
|
|
123
|
-
---
|
|
124
|
-
|
|
125
|
-
## 2. Horizontal scroll section
|
|
126
|
-
|
|
127
|
-
**Term:** Horizontal scroll-jack (the respectable kind).
|
|
128
|
-
**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.
|
|
129
|
-
|
|
130
|
-
**When:** Galleries, project indexes, timeline content that is *genuinely sequential*.
|
|
131
|
-
**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.
|
|
132
|
-
**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.
|
|
133
|
-
|
|
134
|
-
```tsx
|
|
135
|
-
"use client";
|
|
136
|
-
|
|
137
|
-
import { useRef } from "react";
|
|
138
|
-
import { gsap, useGSAP } from "@/lib/gsap";
|
|
139
|
-
|
|
140
|
-
export function WorkGallery({ projects }: { projects: { title: string; meta: string; img: string }[] }) {
|
|
141
|
-
const container = useRef<HTMLElement>(null);
|
|
142
|
-
const track = useRef<HTMLDivElement>(null);
|
|
143
|
-
|
|
144
|
-
useGSAP(
|
|
145
|
-
() => {
|
|
146
|
-
const getDistance = () => track.current!.scrollWidth - window.innerWidth;
|
|
147
|
-
|
|
148
|
-
const scrollTween = gsap.to(track.current, {
|
|
149
|
-
x: () => -getDistance(), // function-based: recalcs on refresh/resize
|
|
150
|
-
ease: "none", // scroll IS the ease; easing here feels broken
|
|
151
|
-
scrollTrigger: {
|
|
152
|
-
trigger: container.current,
|
|
153
|
-
start: "top top",
|
|
154
|
-
end: () => `+=${getDistance()}`, // pin distance = hidden width → 1:1 feel
|
|
155
|
-
pin: true,
|
|
156
|
-
scrub: 1,
|
|
157
|
-
invalidateOnRefresh: true,
|
|
158
|
-
// Do NOT add anticipatePin here when Lenis (or any smooth scroll) is on.
|
|
159
|
-
// See craft note below: it jumps the frame at the pin handoff.
|
|
160
|
-
},
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
// Per-panel parallax inside the horizontal world: containerAnimation
|
|
164
|
-
gsap.utils.toArray<HTMLElement>("[data-panel] img").forEach((img) => {
|
|
165
|
-
gsap.fromTo(img, { xPercent: -8 }, {
|
|
166
|
-
xPercent: 8,
|
|
167
|
-
ease: "none",
|
|
168
|
-
scrollTrigger: {
|
|
169
|
-
trigger: img.parentElement,
|
|
170
|
-
containerAnimation: scrollTween, // triggers measured inside the moving track
|
|
171
|
-
start: "left right",
|
|
172
|
-
end: "right left",
|
|
173
|
-
scrub: true,
|
|
174
|
-
},
|
|
175
|
-
});
|
|
176
|
-
});
|
|
177
|
-
},
|
|
178
|
-
{ scope: container }
|
|
179
|
-
);
|
|
180
|
-
|
|
181
|
-
return (
|
|
182
|
-
<section ref={container} className="overflow-hidden bg-surface-0">
|
|
183
|
-
<div ref={track} className="flex h-[100svh] w-max items-center gap-[6vw] px-[8vw]">
|
|
184
|
-
<div className="w-[28vw] shrink-0">
|
|
185
|
-
<h2 className="font-display text-h2">Selected<br />work</h2>
|
|
186
|
-
</div>
|
|
187
|
-
{projects.map((p, i) => (
|
|
188
|
-
<article key={p.title} data-panel className="w-[42vw] shrink-0">
|
|
189
|
-
<div className="overflow-hidden">
|
|
190
|
-
<img src={p.img} alt="" loading={i > 1 ? "lazy" : undefined}
|
|
191
|
-
className="aspect-[4/3] w-full scale-110 object-cover" />
|
|
192
|
-
</div>
|
|
193
|
-
<p className="text-small mt-4 flex items-baseline justify-between text-ink-muted">
|
|
194
|
-
<span>{p.title}</span><span className="tabular-nums">{p.meta}</span>
|
|
195
|
-
</p>
|
|
196
|
-
</article>
|
|
197
|
-
))}
|
|
198
|
-
</div>
|
|
199
|
-
</section>
|
|
200
|
-
);
|
|
201
|
-
}
|
|
202
|
-
```
|
|
203
|
-
|
|
204
|
-
Craft notes:
|
|
205
|
-
- **Function-based `x` and `end` + `invalidateOnRefresh`**: without these, resize breaks the mapping and the section over/under-scrolls.
|
|
206
|
-
- **`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.
|
|
207
|
-
- Varying panel widths (`28vw` intro, `42vw` projects) beats uniform cards, the same asymmetry rule as vertical layout.
|
|
208
|
-
- **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`.
|
|
209
|
-
- Mobile fallback: **never pin on touch**. The exact matchMedia pattern is §4 below. Pinned horizontal on small phones is a common award-site sin.
|
|
210
|
-
|
|
211
|
-
**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.
|
|
212
|
-
|
|
213
|
-
---
|
|
214
|
-
|
|
215
|
-
## 3. Scrub-tied hero (no pin)
|
|
216
|
-
|
|
217
|
-
**Term:** Departure scrub.
|
|
218
|
-
**Mechanic:** The hero reacts to the first hundreds of scroll pixels: headline drifts up faster than scroll, media scales and dims, the lead line fades. No pinning; the page just *leaves* cinematically instead of sliding away as a rigid block.
|
|
219
|
-
|
|
220
|
-
**When:** Every hero. This is the cheapest scrub win on the page. Do it even when you do nothing else from this file.
|
|
221
|
-
**Cost:** Trivial.
|
|
222
|
-
|
|
223
|
-
```tsx
|
|
224
|
-
useGSAP(
|
|
225
|
-
() => {
|
|
226
|
-
const tl = gsap.timeline({
|
|
227
|
-
scrollTrigger: {
|
|
228
|
-
trigger: container.current,
|
|
229
|
-
start: "top top",
|
|
230
|
-
end: "bottom top", // while the hero leaves the viewport
|
|
231
|
-
scrub: true, // direct tie; departure should feel attached to the hand
|
|
232
|
-
},
|
|
233
|
-
});
|
|
234
|
-
|
|
235
|
-
tl.to("[data-hero-title]", { yPercent: -40, ease: "none" }, 0) // rises faster than page
|
|
236
|
-
.to("[data-hero-media]", { scale: 1.12, ease: "none" }, 0) // grows as it leaves
|
|
237
|
-
.to("[data-hero-veil]", { autoAlpha: 0.55, ease: "none" }, 0) // dims under a veil div
|
|
238
|
-
.to("[data-hero-meta]", { autoAlpha: 0, yPercent: -100, ease: "none" }, 0);
|
|
239
|
-
},
|
|
240
|
-
{ scope: container }
|
|
241
|
-
);
|
|
242
|
-
```
|
|
243
|
-
|
|
244
|
-
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.
|
|
245
|
-
|
|
246
|
-
**Cheap tell:** hero scrolls away as one static raster. **Instead:** Title, media, veil, and meta leave at four different speeds, tied to the hand.
|
|
247
|
-
|
|
248
|
-
---
|
|
249
|
-
|
|
250
|
-
## 4. The touch fallback: pin on desktop, swipe on touch (the exact pattern)
|
|
251
|
-
|
|
252
|
-
Pinning fights native touch scroll; every pinned/horizontal section MUST branch.
|
|
253
|
-
The contract: **one markup tree**, authored so the touch experience is pure
|
|
254
|
-
CSS (scroll-snap), with the pin added *only* inside a desktop
|
|
255
|
-
`gsap.matchMedia()` branch. matchMedia reverts the ScrollTriggers automatically
|
|
256
|
-
when the viewport crosses the breakpoint: no stale pins, nothing to clean up
|
|
257
|
-
by hand.
|
|
258
|
-
|
|
259
|
-
```tsx
|
|
260
|
-
"use client";
|
|
261
|
-
|
|
262
|
-
import { useRef } from "react";
|
|
263
|
-
import { gsap, useGSAP } from "@/lib/gsap";
|
|
264
|
-
|
|
265
|
-
export function Showcase({ items }: { items: { id: string; title: string }[] }) {
|
|
266
|
-
const container = useRef<HTMLElement>(null);
|
|
267
|
-
const track = useRef<HTMLDivElement>(null);
|
|
268
|
-
|
|
269
|
-
useGSAP(
|
|
270
|
-
() => {
|
|
271
|
-
const mm = gsap.matchMedia();
|
|
272
|
-
|
|
273
|
-
// Desktop + motion-OK ONLY: the pinned 1:1 horizontal scrub (§2).
|
|
274
|
-
mm.add("(min-width: 768px) and (prefers-reduced-motion: no-preference)", () => {
|
|
275
|
-
const getDistance = () => track.current!.scrollWidth - window.innerWidth;
|
|
276
|
-
gsap.to(track.current, {
|
|
277
|
-
x: () => -getDistance(),
|
|
278
|
-
ease: "none",
|
|
279
|
-
scrollTrigger: {
|
|
280
|
-
trigger: container.current,
|
|
281
|
-
start: "top top",
|
|
282
|
-
end: () => `+=${getDistance()}`,
|
|
283
|
-
pin: true,
|
|
284
|
-
scrub: 1,
|
|
285
|
-
invalidateOnRefresh: true,
|
|
286
|
-
},
|
|
287
|
-
});
|
|
288
|
-
});
|
|
289
|
-
|
|
290
|
-
// Touch/small/reduced: NO branch needed. The markup below is already a
|
|
291
|
-
// native scroll-snap carousel via the max-md: utilities. No JS, nothing
|
|
292
|
-
// fighting the user's thumb.
|
|
293
|
-
},
|
|
294
|
-
{ scope: container }
|
|
295
|
-
);
|
|
296
|
-
|
|
297
|
-
return (
|
|
298
|
-
<section ref={container} className="overflow-hidden">
|
|
299
|
-
{/* One tree serves both modes: the max-md: snap utilities are inert on
|
|
300
|
-
desktop (where the pin translates the track), and the md: width is
|
|
301
|
-
inert on mobile (where the track scrolls natively). */}
|
|
302
|
-
<div
|
|
303
|
-
ref={track}
|
|
304
|
-
className="flex items-center gap-[5vw] px-[8vw]
|
|
305
|
-
max-md:snap-x max-md:snap-mandatory max-md:overflow-x-auto
|
|
306
|
-
md:h-[100svh] md:w-max"
|
|
307
|
-
>
|
|
308
|
-
{items.map((item) => (
|
|
309
|
-
<article key={item.id} className="w-[82vw] shrink-0 snap-center md:w-[42vw]">
|
|
310
|
-
{/* … */}
|
|
311
|
-
</article>
|
|
312
|
-
))}
|
|
313
|
-
</div>
|
|
314
|
-
</section>
|
|
315
|
-
);
|
|
316
|
-
}
|
|
317
|
-
```
|
|
318
|
-
|
|
319
|
-
What makes this the *correct* shape (each is a common failure when improvised):
|
|
320
|
-
- **The touch path is CSS-only.** If the fallback also needs JS, it can race the
|
|
321
|
-
matchMedia revert. Scroll-snap markup that's simply *inert* under the desktop
|
|
322
|
-
pin can't break.
|
|
323
|
-
- **Both conditions in one query** (`min-width` AND `no-preference`), so
|
|
324
|
-
reduced-motion desktop users get the native carousel too, not the pin.
|
|
325
|
-
- **Function-based `x`/`end` + `invalidateOnRefresh`** so crossing the
|
|
326
|
-
breakpoint (rotate, window resize) re-measures instead of over/under-scrolling.
|
|
327
|
-
- For *vertically stacked* pinned sequences (§1-style phase swaps), the touch
|
|
328
|
-
fallback is a plain stacked list: keep the phases `relative` in normal flow on
|
|
329
|
-
mobile and only position them `absolute` inside the desktop branch. Same
|
|
330
|
-
principle: the mobile layout must work with zero JS.
|
|
331
|
-
|
|
332
|
-
---
|
|
333
|
-
|
|
334
|
-
## When *not* to pin: the self-check
|
|
335
|
-
|
|
336
|
-
1. Would the content lose meaning as a normal section? If no → don't pin.
|
|
337
|
-
2. Is the pin distance ≤ 2.5 viewports? If no → cut phases.
|
|
338
|
-
3. Is there already a pinned section on this page? If yes → strong justification or cut.
|
|
339
|
-
4. Mobile + reduced-motion fallbacks written (§4)? Not optional.
|
|
340
|
-
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.
|
|
@@ -1,290 +0,0 @@
|
|
|
1
|
-
# Pitfalls: the bugs that recur on every GSAP build
|
|
2
|
-
|
|
3
|
-
Symptom first. Find what you see in the index, read the cause, apply the fix, run the check. Every entry here has shipped in a real build at least once, usually because the recipe looked right in a static screenshot. Most of them are invisible to a typecheck, a green build and a reduced-motion screenshot; they only show with motion on, at a real scroll speed, at both 390 and 1440.
|
|
4
|
-
|
|
5
|
-
Read this file before the first tween (workflow step 6) and again at verify (step 8). The rules behind it live in [setup.md](setup.md) §5; this file is how they fail.
|
|
6
|
-
|
|
7
|
-
| What you see | Go to |
|
|
8
|
-
|---|---|
|
|
9
|
-
| The tail of g, y, p, j, q or a comma is cut off; an italic last letter loses its right edge | §1 |
|
|
10
|
-
| A pinned section jerks, jumps, snaps or lands in the wrong place | §2 |
|
|
11
|
-
| A section is an empty band with motion on, but fine under reduced motion | §3.1 |
|
|
12
|
-
| A Tailwind offset (`translate-y-16`, `scale-95`) is gone on an animated element | §3.2 |
|
|
13
|
-
| A timed entrance (match cut, hold, intro) fires over an empty frame or behind the loader | §3.3 |
|
|
14
|
-
| The hero flashes unsplit, then jumps into lines | §3.4 |
|
|
15
|
-
| Two animations fight over one element (a menu that half-closes, a jittering object) | §3.5 |
|
|
16
|
-
| A footer, CTA, frame or row is blank in a reduced-motion, no-JS or settled capture | §3.6 |
|
|
17
|
-
| Bars, lines or display type run past their frame at 390 or 1920 | §3.7 |
|
|
18
|
-
|
|
19
|
-
---
|
|
20
|
-
|
|
21
|
-
## 1. Descender clipping
|
|
22
|
-
|
|
23
|
-
**The 10-second check.** Temporarily set the text of every masked component (split headlines, the hero, the menu links, a clip-line hero, any reveal wrapper) to **`Gyp, jq.`** at its real display size, plus one line that ends in the pairing's italic accent. Screenshot at 390 and 1440, mid-animation and at rest. Every tail and the comma must be whole, and the italic's last stroke must not be sliced vertically. Then put the copy back.
|
|
24
|
-
|
|
25
|
-
### 1.1 SplitText line masks at leading under 1
|
|
26
|
-
|
|
27
|
-
- **Symptom:** descenders of a split headline are cut flat during and after the rise; at rest the line looks underlined by nothing.
|
|
28
|
-
- **Cause:** `mask: "lines"` wraps each line in an `overflow: clip` box exactly one line-height tall. Display leading is 0.92 to 1.0, so the glyphs are taller than the box.
|
|
29
|
-
- **Fix:** pad the mask and pull the margin back, so the clip box grows and the layout does not move:
|
|
30
|
-
|
|
31
|
-
```css
|
|
32
|
-
/* globals.css: class = linesClass + "-mask" */
|
|
33
|
-
.split-line-mask { padding-block: 0.1em; margin-block: -0.1em; }
|
|
34
|
-
```
|
|
35
|
-
|
|
36
|
-
- **Verify:** the 10-second check on every `SplitText` with `mask: "lines"` ([scroll-reveals.md](scroll-reveals.md) §3, [typography.md](typography.md) "Fonts and SplitText").
|
|
37
|
-
|
|
38
|
-
### 1.2 `overflow: hidden` or `clip` on a parent, or a reveal wrapper sized to the line
|
|
39
|
-
|
|
40
|
-
- **Symptom:** same clipping, but on text that is not split: a menu link, a card title inside an image frame, a text-swap button label.
|
|
41
|
-
- **Cause:** a hand-built mask (`overflow-hidden` on an `li`, a `h-[1em]` wrapper, a fixed-height button) clips at the line box, or a section-level `overflow-hidden` sits flush with the last line.
|
|
42
|
-
- **Fix:** every hand-built mask carries the same padding pulled back with a negative margin: `className="-my-[0.1em] overflow-hidden py-[0.1em]"` ([nav-header.md](nav-header.md) §3). Never size a mask with a fixed height in `em` or `px`; let padding and leading size it. A section that needs `overflow-hidden` for a bleed keeps at least `0.2em` between its edge and the last line of display type.
|
|
43
|
-
- **Verify:** the 10-second check on each hand-built mask, including the menu open state.
|
|
44
|
-
|
|
45
|
-
### 1.3 Clip-path insets
|
|
46
|
-
|
|
47
|
-
- **Symptom:** a `clip-path` wipe (`clip-line-hero`, a curtain over a headline) slices descenders while open, or leaves them cut when finished.
|
|
48
|
-
- **Cause:** `inset()` clips to the element's border box, and a heading's box at leading 0.92 is shorter than its ink.
|
|
49
|
-
- **Fix:** padding pulled back on the clipped element (`-my-[0.1em] py-[0.1em]`) so its box contains the ink, and `clearProps: "clipPath"` when the wipe ends so nothing clips the glyphs at rest ([page-entrances.md](page-entrances.md), `clip-line-hero`).
|
|
50
|
-
- **Verify:** the 10-second check, mid-wipe and after.
|
|
51
|
-
|
|
52
|
-
### 1.4 Italic overhang on the right
|
|
53
|
-
|
|
54
|
-
- **Symptom:** the last letter of an italic accent word (Fraunces italic, Cormorant italic, Newsreader italic) loses a sliver of its right side, most visible on f, y and d.
|
|
55
|
-
- **Cause:** italic glyphs lean past their advance width. Any horizontal clip (a line mask, `overflow-clip` on the heading, a clip-path inset at 0) cuts the overhang.
|
|
56
|
-
- **Fix:** inline padding pulled back, on the mask and on any clipped heading that can end in italic:
|
|
57
|
-
|
|
58
|
-
```css
|
|
59
|
-
.split-line-mask { padding-inline: 0.08em; margin-inline: -0.08em; } /* 0.12em for Cormorant */
|
|
60
|
-
```
|
|
61
|
-
|
|
62
|
-
- **Verify:** the italic line of the 10-second check, ending the line on the italic word.
|
|
63
|
-
|
|
64
|
-
### 1.5 Tight display leading inside overflow containers
|
|
65
|
-
|
|
66
|
-
- **Symptom:** an oversized line (`scrub-headline`, a cropped display, ghost type) loses its descenders at the section's bottom edge or its accents at the top.
|
|
67
|
-
- **Cause:** the section is `overflow-clip` for the run-off, and the line's ink extends past its leading box into the clip.
|
|
68
|
-
- **Fix:** vertical padding on the section at least `0.25em` of the display size above and below the line (`py-[14rem]` in [typography.md](typography.md) `scrub-headline`), and the crop only on the axis the design crops (`overflow-x-clip`, not `overflow-clip`, when only the horizontal run-off is wanted).
|
|
69
|
-
- **Verify:** screenshot the line at the scrub's start, middle and end.
|
|
70
|
-
|
|
71
|
-
### 1.6 The wordmark viewBox
|
|
72
|
-
|
|
73
|
-
- **Symptom:** the logo's descender (a g, a y, a p) is cut, or the logo sits too high next to the nav links.
|
|
74
|
-
- **Cause:** a hand-written or exported `viewBox` from the baseline to the cap height instead of the real ink bounds.
|
|
75
|
-
- **Fix:** generate the outline with the script in [brand.md](brand.md) §3, whose `viewBox` hugs the ink including descenders; never edit the viewBox by hand to "fix" alignment. Align the logo by its box in CSS.
|
|
76
|
-
- **Verify:** the logo at nav size and at its largest use, on both surfaces.
|
|
77
|
-
|
|
78
|
-
### 1.7 Text inside pinned or transformed containers
|
|
79
|
-
|
|
80
|
-
- **Symptom:** text that was whole before the pin is clipped while pinned, or a scaled card clips its own title.
|
|
81
|
-
- **Cause:** the pin-spacer and a scaled or transformed parent with `overflow-hidden` both create a new clip box; a `scale: 0.92` recede ([depth-parallax.md](depth-parallax.md) §3) shrinks the box while the text keeps its leading.
|
|
82
|
-
- **Fix:** keep the clip on the media frame, not on the text's container; put text outside the scaled element or give the container the padding from 1.2.
|
|
83
|
-
- **Verify:** the 10-second check with the section pinned and mid-recede.
|
|
84
|
-
|
|
85
|
-
---
|
|
86
|
-
|
|
87
|
-
## 2. Jarring pins
|
|
88
|
-
|
|
89
|
-
**The check for every pinned section:** with motion on, scroll into it slowly, then again with one fast flick, then scroll back up through it. The pin must engage without a jump, hold without drifting, release without a jump, and the content after it must start where it should. At 390 and on touch there must be no pin at all, and the content must still read. Then reload mid-page and repeat.
|
|
90
|
-
|
|
91
|
-
### 2.1 A jerk at the moment the pin engages
|
|
92
|
-
|
|
93
|
-
- **Cause:** `anticipatePin` with Lenis. It predicts from a smoothed velocity, overshoots, and pins a frame early, then corrects.
|
|
94
|
-
- **Fix:** delete `anticipatePin` on every smooth-scrolled page. It exists to hide native-scroll latency that Lenis already removes ([pinning-scrub.md](pinning-scrub.md) §2).
|
|
95
|
-
- **Verify:** the fast flick into the pin.
|
|
96
|
-
|
|
97
|
-
### 2.2 Scrub feels laggy, mushy or jittery
|
|
98
|
-
|
|
99
|
-
- **Cause:** scrub smoothing stacked on Lenis smoothing (`scrub: 2` or more on lerp 0.1 reads as lag), or `scrub: true` on a jumpy native scroll.
|
|
100
|
-
- **Fix:** pair them: Lenis lerp 0.09 to 0.11 with `scrub: 1` for showpieces, `scrub: true` for departure scrubs that must feel attached to the hand ([feel-layer.md](feel-layer.md) §2). Never raise `scrub` to hide a jump; find the jump's cause below.
|
|
101
|
-
- **Verify:** stop scrolling mid-pin: the timeline should settle within about a second and then stand still.
|
|
102
|
-
|
|
103
|
-
### 2.3 The pin starts or ends in the wrong place, or jumps after something loads
|
|
104
|
-
|
|
105
|
-
- **Cause:** stale measurements. Triggers were measured before webfonts swapped, images got their height, an entrance finished, a route changed or a split re-ran. Horizontal tracks measured once overscroll after any resize.
|
|
106
|
-
- **Fix:** refresh after the layout settles, and make every measured distance a function:
|
|
107
|
-
|
|
108
|
-
```ts
|
|
109
|
-
// after fonts, and once more after the first-load entrance or route reveal
|
|
110
|
-
document.fonts.ready.then(() => ScrollTrigger.refresh());
|
|
111
|
-
|
|
112
|
-
gsap.to(track, {
|
|
113
|
-
x: () => -(track.scrollWidth - window.innerWidth), // function: recomputed on refresh
|
|
114
|
-
ease: "none",
|
|
115
|
-
scrollTrigger: {
|
|
116
|
-
trigger: section, start: "top top", pin: true, scrub: 1,
|
|
117
|
-
end: () => `+=${track.scrollWidth - window.innerWidth}`,
|
|
118
|
-
invalidateOnRefresh: true, // re-run the functions on refresh
|
|
119
|
-
},
|
|
120
|
-
});
|
|
121
|
-
```
|
|
122
|
-
|
|
123
|
-
Give every image an aspect ratio (width and height, or `fill` in a sized frame) so late loads cannot move a trigger. The providers already refresh once after mount ([setup.md](setup.md) §2) and the zone template refreshes after its entrance ([page-entrances.md](page-entrances.md)); a component that changes layout after that (an accordion, a late split) calls `ScrollTrigger.refresh()` itself.
|
|
124
|
-
- **Verify:** hard reload, scroll straight to the pin; then navigate to the page from another route and do it again. Both must engage at the same spot.
|
|
125
|
-
|
|
126
|
-
### 2.4 The pin jumps on mobile when the URL bar shows or hides
|
|
127
|
-
|
|
128
|
-
- **Cause:** `100vh` changes with the browser chrome, and ScrollTrigger refreshes on that resize mid-scroll.
|
|
129
|
-
- **Fix:** size one-screen sections with `svh` (`h-[100svh]`, `min-h-[100svh]`), never `h-screen` or `100vh`, and set `ScrollTrigger.config({ ignoreMobileResize: true })` once in the scroll provider ([setup.md](setup.md) §2). Pins are off on touch anyway (2.12); this protects the sections that remain one screen tall.
|
|
130
|
-
- **Verify:** at 390 in a device emulator, scroll down and back up so the URL bar collapses and returns.
|
|
131
|
-
|
|
132
|
-
### 2.5 The pinned element shifts sideways, scrolls away, or will not stick
|
|
133
|
-
|
|
134
|
-
- **Cause:** an ancestor with a `transform`, `filter`, `perspective`, `contain: paint` or `will-change: transform` becomes the containing block for `position: fixed`, so the pinned element is fixed to that ancestor instead of the viewport. The usual culprit is a page wrapper left with an inline transform by an entrance, or a smooth-scroll wrapper.
|
|
135
|
-
- **Fix:** clear the ancestor's transform after its entrance (`clearProps: "all"`, [page-entrances.md](page-entrances.md)) and never leave `will-change` on a wrapper. If the ancestor must keep its transform, restructure so the pinned section is not inside it, or set `pinReparent: true` on the trigger (it moves the element to `<body>` while pinned; styles scoped by the old parent then stop applying, so check it).
|
|
136
|
-
- **Verify:** in devtools, walk up from the pinned element: no ancestor may have a computed `transform` other than `none` while pinned.
|
|
137
|
-
|
|
138
|
-
### 2.6 A pin inside a flex or grid parent collapses or misaligns
|
|
139
|
-
|
|
140
|
-
- **Cause:** the pin-spacer replaces a flex or grid item; while pinned the element is `position: fixed` and loses the width the track gave it, or the spacer takes a different track size.
|
|
141
|
-
- **Fix:** pin a block-level element that is a direct child of the page flow (the section, or a full-width wrapper inside it), and put the flex or grid layout *inside* the pinned element. In [webgl-scenes.md](webgl-scenes.md) §0 the `outer` section holds the spacer and the inner `wrap` is pinned for the same reason.
|
|
142
|
-
- **Verify:** compare a screenshot just before the pin engages with one while pinned: nothing may change width.
|
|
143
|
-
|
|
144
|
-
### 2.7 The next section slides under or over the pin unexpectedly
|
|
145
|
-
|
|
146
|
-
- **Cause:** `pinSpacing: false`, which removes the spacer so the following content scrolls up under the pinned element.
|
|
147
|
-
- **Fix:** leave `pinSpacing` at its default. Use `false` only when the overlap is the design (a sticky stack, a section that should slide over the pinned one), and then give the following section a surface and a z-index on purpose.
|
|
148
|
-
- **Verify:** the release: the next section must start below the pin's end, or overlap it exactly as designed.
|
|
149
|
-
|
|
150
|
-
### 2.8 Nested pins
|
|
151
|
-
|
|
152
|
-
- **Cause:** a pinned section inside another pinned section, or a pin inside a horizontal track. Each pin re-parents and measures against a layout the other is changing.
|
|
153
|
-
- **Fix:** one pin per section tree. Inside a pinned horizontal track, use `containerAnimation` for per-panel triggers ([pinning-scrub.md](pinning-scrub.md) §2), never another pin.
|
|
154
|
-
- **Verify:** search the code for `pin:` and confirm no two pinned elements are ancestors of each other.
|
|
155
|
-
|
|
156
|
-
### 2.9 A pin directly under the hero
|
|
157
|
-
|
|
158
|
-
- **Cause:** the pin starts while the hero is still leaving, so the departure scrub and the pin compete for the same scroll distance, and the visitor meets a locked scroll before anything has earned it.
|
|
159
|
-
- **Fix:** a breather between the hero and any showpiece ([page-architecture.md](page-architecture.md) §3, [showcase.md](showcase.md) Never).
|
|
160
|
-
- **Verify:** the arc in STRUQ.md: no showpiece in the row after the hero, unless it is the hero.
|
|
161
|
-
|
|
162
|
-
### 2.10 A snap on first engage after a fast scroll
|
|
163
|
-
|
|
164
|
-
- **Cause:** `snap` fires as soon as scrolling stops; after a fast flick into a stepped pin, the stop lands near a phase boundary and the snap yanks the timeline to the next phase. Stale pin positions (2.3) make it worse.
|
|
165
|
-
- **Fix:** snap only stepped content whose phases are at least about 80% of a viewport apart, and give it a beat and no momentum:
|
|
166
|
-
|
|
167
|
-
```ts
|
|
168
|
-
snap: { snapTo: 1 / (phases - 1), duration: { min: 0.2, max: 0.5 }, delay: 0.15, ease: "power2.inOut", inertia: false },
|
|
169
|
-
```
|
|
170
|
-
|
|
171
|
-
If it still yanks, remove `snap`: a continuous scrub never needs it.
|
|
172
|
-
- **Verify:** the fast flick into the pin, then stop: the phase that settles is the one in view, with no second move.
|
|
173
|
-
|
|
174
|
-
### 2.11 The 3D scene freezes halfway through a pinned shot
|
|
175
|
-
|
|
176
|
-
- **Cause:** the offscreen-pause trigger sits on the pinned element, measured against its unpinned box, so it toggles inactive mid-pin.
|
|
177
|
-
- **Fix:** put the pause trigger on the never-pinned outer section with `refreshPriority: -1` ([webgl-scenes.md](webgl-scenes.md) §0).
|
|
178
|
-
- **Verify:** scroll through the whole pinned shot: the scene keeps rendering to the end.
|
|
179
|
-
|
|
180
|
-
### 2.12 Reduced motion, touch and no-JS: the content must still read
|
|
181
|
-
|
|
182
|
-
- **Cause:** pinned layouts authored as the default markup (phases `absolute` and hidden with inline `opacity: 0`), with motion added on top.
|
|
183
|
-
- **Fix:** the server markup is the readable fallback (a stacked list, a native scroll-snap row). The pin, the stage layout and the hidden phases exist only inside one `gsap.matchMedia()` gate, set with `gsap.set` and a data attribute that matchMedia reverts ([pinning-scrub.md](pinning-scrub.md) §1 and §4). One `GATE` string decides the pin, the layout and any canvas.
|
|
184
|
-
- **Verify:** emulate `prefers-reduced-motion: reduce`, then 390 with touch, then JavaScript disabled: every phase's text is visible in all three.
|
|
185
|
-
|
|
186
|
-
---
|
|
187
|
-
|
|
188
|
-
## 3. Animation owners collide
|
|
189
|
-
|
|
190
|
-
### 3.1 Content stuck at opacity 0: a CSS transition and GSAP on the same property
|
|
191
|
-
|
|
192
|
-
- **Symptom:** a revealed list or grid is an empty band with motion on. Devtools shows one tween at progress 1, `y` back at 0, but inline `opacity: 0; visibility: hidden` forever. Reduced-motion screenshots look perfect, because no tween runs.
|
|
193
|
-
- **Cause:** the element GSAP animates also carries a CSS `transition-*` on a property GSAP writes. The usual source is a sibling-dim hover (`transition-opacity duration-500`) on the same `li` that `[data-reveal]` targets. The browser transitions every inline value GSAP writes, so the `from` tween reads a half-transitioned style and resolves its end value as 0.
|
|
194
|
-
- **Fix:** one owner per property per element. Reveal the wrapper, transition the child:
|
|
195
|
-
|
|
196
|
-
```tsx
|
|
197
|
-
// The li is the GSAP target: no transition, no opacity class.
|
|
198
|
-
<li data-reveal className="group">
|
|
199
|
-
{/* The hover dim lives one level in. */}
|
|
200
|
-
<a href={href} className="block transition-opacity duration-500
|
|
201
|
-
group-hover/list:opacity-40 group-hover/list:hover:opacity-100">
|
|
202
|
-
{children}
|
|
203
|
-
</a>
|
|
204
|
-
</li>
|
|
205
|
-
```
|
|
206
|
-
|
|
207
|
-
The same applies to `transition-transform` and `transition-all` on anything GSAP moves ([micro-interactions.md](micro-interactions.md) §3b, [scroll-reveals.md](scroll-reveals.md) §1).
|
|
208
|
-
- **Verify:** search the code: no element with `data-reveal` (or any GSAP target class) has a `transition-` class. Then scroll the page with motion on and check every revealed group ends visible.
|
|
209
|
-
|
|
210
|
-
### 3.2 A Tailwind offset vanished: `translate: none`
|
|
211
|
-
|
|
212
|
-
- **Symptom:** a staggered column offset (`lg:translate-y-16`), a `scale-95` rest state or a `rotate-2` tilt is gone on an element that animates, while the code still claims it.
|
|
213
|
-
- **Cause:** Tailwind (v4 or later) writes `translate-*`, `scale-*` and `rotate-*` to the individual CSS `translate`, `scale` and `rotate` properties. GSAP sets those properties to `none` inline on every element it transforms and puts its own values in `transform`, so the utility is erased.
|
|
214
|
-
- **Fix:** offset with layout, or on a wrapper, or inside the tween:
|
|
215
|
-
|
|
216
|
-
```tsx
|
|
217
|
-
// padding or margin, never translate, on a GSAP target
|
|
218
|
-
<li data-reveal className="lg:even:mt-16">...</li>
|
|
219
|
-
|
|
220
|
-
// or the offset on a static wrapper, the reveal on the child
|
|
221
|
-
<div className="lg:translate-y-16"><article data-reveal>...</article></div>
|
|
222
|
-
```
|
|
223
|
-
|
|
224
|
-
For an element that must rest transformed, express the rest state in GSAP (`gsap.set(el, { yPercent: -50 })`) rather than in a utility.
|
|
225
|
-
- **Verify:** compare the motion-on screenshot with the reduced-motion one: the offsets must match.
|
|
226
|
-
|
|
227
|
-
### 3.3 A timed entrance fires before the image paints, or behind the loader
|
|
228
|
-
|
|
229
|
-
- **Symptom:** a match-cut hero shows an empty dark frame, then the type; the cut never reads. Or an intro plays behind the loader curtain and is over when it lifts. Static screenshots never show it.
|
|
230
|
-
- **Cause:** the hold starts on mount (`gsap.timeline({ delay: 1 })`) while the photo is still downloading, or the entrance starts before the thing it waits for. A delay set later with `tl.delay(1)` is ignored by `tl.play()`.
|
|
231
|
-
- **Fix:** start the hold from the event it depends on, with a `delayedCall`, and a fallback:
|
|
232
|
-
|
|
233
|
-
```ts
|
|
234
|
-
const tl = gsap.timeline({ paused: true }); // the cut itself
|
|
235
|
-
let started = false;
|
|
236
|
-
const start = (hold: number) => {
|
|
237
|
-
if (started) return;
|
|
238
|
-
started = true;
|
|
239
|
-
gsap.delayedCall(hold, () => { tl.play(); });
|
|
240
|
-
};
|
|
241
|
-
img.addEventListener("load", () => start(1), { once: true });
|
|
242
|
-
if (img.complete && img.naturalWidth > 0) start(1); // cached
|
|
243
|
-
gsap.delayedCall(2.5, () => start(0)); // never arrived: go straight to the type
|
|
244
|
-
```
|
|
245
|
-
|
|
246
|
-
With a loader, start it from the loader's exit ([loaders.md](loaders.md)). The full recipe, including the LCP cost of holding: [page-entrances.md](page-entrances.md) `match-cut-hero`.
|
|
247
|
-
- **Verify:** the frame trace in [performance.md](performance.md) §8: the hold frame must be the photo, at 1440 and at 390, on a throttled network.
|
|
248
|
-
|
|
249
|
-
### 3.4 The hero flashes unsplit, then jumps
|
|
250
|
-
|
|
251
|
-
- **Cause:** the split runs after first paint and nothing hides the h1 in between.
|
|
252
|
-
- **Fix:** the pre-split guard in [typography.md](typography.md) `line-rise-hero` (a CSS rule that hides the hero only while motion is allowed, lifted by a `data-ready` attribute in the same tick as the split), plus the `<noscript>` override.
|
|
253
|
-
- **Verify:** the first frames of the trace: the hero is either hidden or split, never plain then split.
|
|
254
|
-
|
|
255
|
-
### 3.5 Two animations write the same property
|
|
256
|
-
|
|
257
|
-
- **Symptom:** a menu that half-closes when Escape is pressed during its entrance; a 3D object that jitters between two rotations; a header that stutters between hidden and shown.
|
|
258
|
-
- **Cause:** two timelines, or GSAP and Motion, or scroll and pointer, writing one property on one element.
|
|
259
|
-
- **Fix:** kill the running timeline before starting its opposite (keep it in a ref, [nav-header.md](nav-header.md) §3b); nest groups so scroll and pointer each own one ([webgl-scenes.md](webgl-scenes.md) §4, product-rig); use `quickTo` for anything that reverses mid-gesture; never GSAP and Motion on the same property ([../SKILL.md](../SKILL.md), GSAP vs Motion).
|
|
260
|
-
- **Verify:** trigger the reverse mid-animation (Escape during the menu entrance, a direction flip mid-scroll): the motion reverses cleanly.
|
|
261
|
-
|
|
262
|
-
### 3.6 A section renders empty: a blank footer, an empty frame, a CTA with nothing in it
|
|
263
|
-
|
|
264
|
-
This is the most common way an ambitious build loses craft points to a plain one: the plain build has no reveals, so it never shows an empty state.
|
|
265
|
-
|
|
266
|
-
- **Symptom:** in a full-page capture, a reduced-motion capture or mid-scroll, a whole region is blank: the footer, a CTA panel, a media frame, a list row. The reveal never ran, or ran and was reverted.
|
|
267
|
-
- **Cause:** the element starts hidden in CSS or from a `gsap.set` outside `matchMedia`, so the reduced-motion and no-JS branches never show it; a ScrollTrigger whose start is never reached (a footer-reveal or the last section of the page, where `top 80%` cannot happen); a reveal keyed to an element that a pin or a footer-reveal moved; a drawn SVG or canvas that waits for an event that never fires.
|
|
268
|
-
- **Fix:** final state is the default and hidden state is set only inside the motion branch (`gsap.from` inside `mm.add("(prefers-reduced-motion: no-preference)")`, [setup.md](setup.md) §4); triggers near the end of the page use `start: "top bottom"` or fire on load when the section is already in view (`ScrollTrigger.isInViewport`); anything drawn or loaded gets a visible fallback until it is ready.
|
|
269
|
-
- **Verify:** three captures, each read region by region for anything blank that should hold content: a full page with `reducedMotion: 'reduce'`, a full page with JavaScript disabled, and the motion-on scroll sequence after each step settles (wait for the longest reveal). An empty region in any of them fails verify.
|
|
270
|
-
|
|
271
|
-
### 3.7 Something overruns its frame
|
|
272
|
-
|
|
273
|
-
- **Symptom:** data bars run past their axis, a drawn line leaves its container, a wordmark or display line pushes past the viewport at 1920.
|
|
274
|
-
- **Cause:** values scaled to a fixed width while the frame is fluid; an axis that does not cover the data's range; display clamps without a max.
|
|
275
|
-
- **Fix:** scale from the measured container (or `%` of it), make the axis cover the data's max, and keep display type inside its clamp ceiling ([typography.md](typography.md)).
|
|
276
|
-
- **Verify:** look at the edges of every frame at 390 and 1920.
|
|
277
|
-
|
|
278
|
-
---
|
|
279
|
-
|
|
280
|
-
## 4. The pitfall pass at verify
|
|
281
|
-
|
|
282
|
-
Run it on the production server ([performance.md](performance.md) §8), motion on, at 390 and 1440:
|
|
283
|
-
|
|
284
|
-
1. The descender test from §1 on every masked component, including the menu and the italic accent.
|
|
285
|
-
2. Every pinned section through the check at the top of §2: slow, fast flick, back up, reload mid-page, then reduced motion, touch and no-JS.
|
|
286
|
-
3. A search for `transition-` and `translate-`, `scale-`, `rotate-` utilities on GSAP targets (§3.1, §3.2).
|
|
287
|
-
4. The frame trace of the first 3 seconds, read frame by frame (§3.3, §3.4).
|
|
288
|
-
5. The empty-region check: reduced-motion full page, no-JS full page, and the settled scroll sequence, each with no blank region that should hold content (§3.6); then the frame edges at 390 and 1920 (§3.7).
|
|
289
|
-
|
|
290
|
-
Write the result on the `Motion:` line under VERIFY in STRUQ.md.
|