@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.
Files changed (122) hide show
  1. package/README.md +23 -35
  2. package/package.json +3 -2
  3. package/skills/struq/SKILL.md +148 -212
  4. package/skills/struq/blocks/CATALOG.md +426 -0
  5. package/skills/struq/blocks/backgrounds/background-aurora.tsx +818 -0
  6. package/skills/struq/blocks/backgrounds/background-facets.tsx +1283 -0
  7. package/skills/struq/blocks/backgrounds/background-silk.tsx +906 -0
  8. package/skills/struq/blocks/catalog.json +1302 -0
  9. package/skills/struq/blocks/footers/footer-contact.tsx +789 -0
  10. package/skills/struq/blocks/footers/footer-reveal.tsx +313 -0
  11. package/skills/struq/blocks/headers/header-bar.tsx +652 -0
  12. package/skills/struq/blocks/headers/menu-curtain.tsx +790 -0
  13. package/skills/struq/blocks/heroes/hero-framed.tsx +627 -0
  14. package/skills/struq/blocks/heroes/hero-index.tsx +855 -0
  15. package/skills/struq/blocks/heroes/hero-poster.tsx +1086 -0
  16. package/skills/struq/blocks/heroes/hero-split.tsx +685 -0
  17. package/skills/struq/blocks/heroes/hero-still.tsx +862 -0
  18. package/skills/struq/blocks/kit/Cursor.tsx +117 -0
  19. package/skills/struq/blocks/kit/HeroCanvas.tsx +278 -0
  20. package/skills/struq/blocks/kit/HorizontalGallery.tsx +106 -0
  21. package/skills/struq/blocks/kit/MagneticButton.tsx +92 -0
  22. package/skills/struq/blocks/kit/MediaFrame.tsx +96 -0
  23. package/skills/struq/blocks/kit/PinnedZoom.tsx +89 -0
  24. package/skills/struq/blocks/kit/Reveal.tsx +64 -0
  25. package/skills/struq/blocks/kit/SmoothScroll.tsx +57 -0
  26. package/skills/struq/blocks/kit/SplitHeadline.tsx +68 -0
  27. package/skills/struq/blocks/kit/blocks.css +2102 -0
  28. package/skills/struq/blocks/kit/gsap.ts +21 -0
  29. package/skills/struq/blocks/kit/images.ts +17 -0
  30. package/skills/struq/blocks/kit/photo-read.ts +102 -0
  31. package/skills/struq/blocks/kit/scenes/contours.ts +161 -0
  32. package/skills/struq/blocks/kit/scenes/dots.ts +110 -0
  33. package/skills/struq/blocks/kit/scenes/ripple.ts +99 -0
  34. package/skills/struq/blocks/kit/scenes/types.ts +114 -0
  35. package/skills/struq/blocks/page-transitions/page-transition-gate.tsx +706 -0
  36. package/skills/struq/blocks/page-transitions/page-transition-slices.tsx +469 -0
  37. package/skills/struq/blocks/page-transitions/page-transition-stack.tsx +583 -0
  38. package/skills/struq/blocks/preloaders/preloader-gate.tsx +358 -0
  39. package/skills/struq/blocks/preloaders/preloader-slices-uncovered.ts +50 -0
  40. package/skills/struq/blocks/preloaders/preloader-slices.tsx +289 -0
  41. package/skills/struq/blocks/preloaders/preloader-stack.tsx +367 -0
  42. package/skills/struq/blocks/preloaders/preloader-wordmark.tsx +568 -0
  43. package/skills/struq/blocks/sections/about-studio.tsx +436 -0
  44. package/skills/struq/blocks/sections/approach.tsx +661 -0
  45. package/skills/struq/blocks/sections/contact-form.tsx +664 -0
  46. package/skills/struq/blocks/sections/cta-closing.tsx +763 -0
  47. package/skills/struq/blocks/sections/fanned-cards.tsx +547 -0
  48. package/skills/struq/blocks/sections/film-strip.tsx +385 -0
  49. package/skills/struq/blocks/sections/grid-assembly.tsx +616 -0
  50. package/skills/struq/blocks/sections/hover-list.tsx +467 -0
  51. package/skills/struq/blocks/sections/image-trail.tsx +432 -0
  52. package/skills/struq/blocks/sections/manifesto.tsx +691 -0
  53. package/skills/struq/blocks/sections/panel-accordion.tsx +380 -0
  54. package/skills/struq/blocks/sections/reveals.tsx +416 -0
  55. package/skills/struq/blocks/sections/split-pair.tsx +670 -0
  56. package/skills/struq/blocks/sections/sticky-chapters.tsx +256 -0
  57. package/skills/struq/blocks/sections/velocity-type.tsx +269 -0
  58. package/skills/struq/blocks/showpieces/fill-rows.tsx +74 -0
  59. package/skills/struq/blocks/showpieces/hero-opener.tsx +128 -0
  60. package/skills/struq/blocks/showpieces/image-to-fullbleed.tsx +89 -0
  61. package/skills/struq/blocks/showpieces/manifesto-scrub.tsx +48 -0
  62. package/skills/struq/blocks/showpieces/panel-wipe.tsx +89 -0
  63. package/skills/struq/blocks/showpieces/pixel-resolve.tsx +107 -0
  64. package/skills/struq/blocks/showpieces/project-stack.tsx +86 -0
  65. package/skills/struq/blocks/showpieces/scrub-connector.tsx +307 -0
  66. package/skills/struq/blocks/showpieces/wordmark-finale.tsx +105 -0
  67. package/skills/struq/blocks/transitions/zoom-through.tsx +613 -0
  68. package/skills/struq/canon/canon.json +627 -0
  69. package/skills/struq/cinematic/README.md +7 -18
  70. package/skills/struq/cinematic/examples/code/nextjs/app/components/LenisRoot.tsx +1 -1
  71. package/skills/struq/cinematic/examples/code/shared/styles.css +1 -1
  72. package/skills/struq/cinematic/examples/code/vite/src/LenisRoot.tsx +1 -1
  73. package/skills/struq/cinematic/references/01-core-mental-model.md +1 -1
  74. package/skills/struq/cinematic/references/02-vocabulary.md +1 -1
  75. package/skills/struq/cinematic/references/07-stack-integration.md +2 -2
  76. package/skills/struq/recipes/app/fonts.ts +12 -0
  77. package/skills/struq/recipes/app/globals.css +42 -0
  78. package/skills/struq/recipes/app/layout.tsx +30 -0
  79. package/skills/struq/references/craft.md +77 -0
  80. package/skills/struq/references/iconography.md +28 -230
  81. package/skills/struq/references/stack-targets.md +23 -74
  82. package/skills/struq/references/tells.md +52 -0
  83. package/skills/struq/references/triage.md +6 -7
  84. package/skills/struq/scripts/blocks.mjs +156 -0
  85. package/skills/struq/scripts/canon.mjs +134 -0
  86. package/skills/struq/scripts/check.mjs +513 -0
  87. package/skills/struq/scripts/images.mjs +360 -0
  88. package/skills/struq/scripts/log.mjs +75 -0
  89. package/skills/struq/scripts/packs.mjs +156 -0
  90. package/skills/struq/scripts/tokens.mjs +222 -0
  91. package/skills/struq/references/anti-patterns.md +0 -265
  92. package/skills/struq/references/baseline-prompt.md +0 -118
  93. package/skills/struq/references/brand.md +0 -143
  94. package/skills/struq/references/brief-router.md +0 -219
  95. package/skills/struq/references/composition.md +0 -191
  96. package/skills/struq/references/depth-parallax.md +0 -235
  97. package/skills/struq/references/direction.md +0 -188
  98. package/skills/struq/references/feel-layer.md +0 -107
  99. package/skills/struq/references/gsap-api.md +0 -293
  100. package/skills/struq/references/imagery.md +0 -144
  101. package/skills/struq/references/loaders.md +0 -287
  102. package/skills/struq/references/micro-interactions.md +0 -270
  103. package/skills/struq/references/mobile-composition.md +0 -70
  104. package/skills/struq/references/nav-header.md +0 -296
  105. package/skills/struq/references/page-architecture.md +0 -134
  106. package/skills/struq/references/page-entrances.md +0 -198
  107. package/skills/struq/references/performance.md +0 -235
  108. package/skills/struq/references/pinning-scrub.md +0 -341
  109. package/skills/struq/references/pitfalls.md +0 -290
  110. package/skills/struq/references/route-transitions.md +0 -281
  111. package/skills/struq/references/scaling.md +0 -151
  112. package/skills/struq/references/scroll-reveals.md +0 -280
  113. package/skills/struq/references/seo.md +0 -219
  114. package/skills/struq/references/setup.md +0 -397
  115. package/skills/struq/references/showcase.md +0 -87
  116. package/skills/struq/references/struq-imagery.md +0 -107
  117. package/skills/struq/references/svg-motion.md +0 -390
  118. package/skills/struq/references/transitions.md +0 -160
  119. package/skills/struq/references/typography.md +0 -720
  120. package/skills/struq/references/vibe-presets.md +0 -287
  121. package/skills/struq/references/webgl-r3f.md +0 -274
  122. package/skills/struq/references/webgl-scenes.md +0 -593
@@ -0,0 +1,256 @@
1
+ "use client";
2
+ // sticky-chapters (Struq section recipe)
3
+ // A process or a story told in chapters, with the camera holding still. One large photograph stands on
4
+ // the left while the chapters scroll past on the right; it stays put with plain CSS (position sticky),
5
+ // so nothing pins and nothing jumps. As a chapter climbs toward the middle of the screen its photograph
6
+ // rises over the last from the bottom edge of the frame, out of a slight push-in, and the chapter comes
7
+ // forward while the others step back. A hairline beside the frame fills as you read on, the only measure
8
+ // of how far you are.
9
+ //
10
+ // The swaps are tied to the scroll, not fired by it: each chapter after the first owns one short stretch
11
+ // of scroll (its top travelling from 70% to 45% of the screen) and its photograph's mask follows that
12
+ // stretch, lightly smoothed. So the frame always matches the chapter you are reading, a fast flick scrubs
13
+ // straight through, scrolling back simply lowers the photograph again, and the stretches never overlap,
14
+ // so the frame never holds more than two photographs. The emphasis follows the same positions, so text
15
+ // and photograph change together. Every photograph loads at the frame's size and is decoded before it
16
+ // shows; every mask is written from plain numbers.
17
+ //
18
+ // <StickyChapters title="Van plek tot huis." chapters={[
19
+ // { title: "Eerst kijken.", text: "...", image: images.column[1] },
20
+ // ...
21
+ // ]} />
22
+ //
23
+ // Without scripts and on phones: each chapter carries its photograph inline, above its text on phones
24
+ // and beside it on desktop. Reduced motion: the sticky frame with hard cuts and no push-in.
25
+ import Image from "next/image";
26
+ import { useRef } from "react";
27
+ import { gsap, useGSAP } from "../kit/gsap";
28
+ import type { StruqImage } from "../kit/images";
29
+
30
+ type Chapter = {
31
+ title: string;
32
+ /** Two or three sentences. */
33
+ text: string;
34
+ image: StruqImage;
35
+ };
36
+
37
+ type Props = {
38
+ /** An optional heading above the chapters. */
39
+ title?: string;
40
+ chapters: Chapter[];
41
+ className?: string;
42
+ };
43
+
44
+ /** Each chapter's stretch of scroll: from its top at this share of the screen to its top at that one. */
45
+ const FROM = "top 70%";
46
+ const TO = "top 45%";
47
+ /** The smoothing on the scrub, in seconds: enough to feel like a camera, never enough to lag the text. */
48
+ const SMOOTH = 0.4;
49
+ /** A rising photograph settles out of this push-in. */
50
+ const PUSH = 1.12;
51
+
52
+ export function StickyChapters({ title, chapters, className = "" }: Props) {
53
+ const stage = useRef<HTMLElement>(null);
54
+
55
+ useGSAP(
56
+ () => {
57
+ const el = stage.current;
58
+ if (!el) return;
59
+ const frame = el.querySelector<HTMLElement>("[data-frame]");
60
+ const fill = el.querySelector<HTMLElement>("[data-fill]");
61
+ const list = el.querySelector<HTMLElement>("[data-chapters]");
62
+ const items = Array.from(el.querySelectorAll<HTMLElement>("[data-chapter]"));
63
+ const layers = Array.from(el.querySelectorAll<HTMLElement>("[data-layer]"));
64
+ const pics = layers.map((l) => l.querySelector<HTMLElement>("[data-pic]"));
65
+ const imgs = layers.map((l) => l.querySelector<HTMLImageElement>("img"));
66
+ if (!frame || !list || items.length === 0 || layers.length === 0) return;
67
+
68
+ const mm = gsap.matchMedia();
69
+ mm.add({ wide: "(min-width: 1024px)", reduce: "(prefers-reduced-motion: reduce)" }, (ctx) => {
70
+ const { wide, reduce } = ctx.conditions as { wide: boolean; reduce: boolean };
71
+ if (!wide) return;
72
+
73
+ // Every photograph loads now, at the frame's size. A layer stays hidden until its photograph is
74
+ // decoded, so an undecoded one can only ever show the photograph beneath it, never an empty frame.
75
+ imgs.forEach((img, i) => {
76
+ if (!img || i === 0) return;
77
+ layers[i].style.visibility = "hidden";
78
+ img.loading = "eager";
79
+ const loaded = img.complete
80
+ ? Promise.resolve()
81
+ : new Promise<void>((res) => {
82
+ img.addEventListener("load", () => res(), { once: true });
83
+ img.addEventListener("error", () => res(), { once: true });
84
+ });
85
+ loaded
86
+ .then(() => img.decode())
87
+ .catch(() => undefined)
88
+ .then(() => {
89
+ layers[i].style.visibility = "";
90
+ });
91
+ });
92
+ if (imgs[0]) imgs[0].loading = "eager";
93
+
94
+ // Each layer's progress (0 hidden below the frame's bottom edge, 1 whole), and the mask and push-in
95
+ // written from it. Layers stack in chapter order, so the newest is always on top.
96
+ const prog = layers.map((_, i) => ({ p: i === 0 ? 1 : 0 }));
97
+ const rise = gsap.parseEase("power2.inOut");
98
+ const settle = gsap.parseEase("power3.out");
99
+ const paint = (i: number) => {
100
+ const p = prog[i].p;
101
+ const shown = reduce ? (p >= 0.5 ? 1 : 0) : rise(p);
102
+ layers[i].style.clipPath = `inset(${((1 - shown) * 100).toFixed(2)}% 0% 0% 0%)`;
103
+ const pic = pics[i];
104
+ if (pic && !reduce && i > 0) {
105
+ const k = 1 + (PUSH - 1) * (1 - settle(p));
106
+ pic.style.transform = `translate3d(0, ${((1 - settle(p)) * 4).toFixed(2)}%, 0) scale(${k.toFixed(4)})`;
107
+ }
108
+ };
109
+
110
+ // The chapter in front is the last one whose photograph has risen past half way.
111
+ let active = -1;
112
+ const mark = () => {
113
+ let a = 0;
114
+ for (let i = 1; i < prog.length; i += 1) if (prog[i].p >= 0.5) a = i;
115
+ if (a === active) return;
116
+ active = a;
117
+ items.forEach((c, j) => {
118
+ if (j === a) delete c.dataset.back;
119
+ else c.dataset.back = "";
120
+ });
121
+ };
122
+
123
+ layers.forEach((l, i) => {
124
+ l.style.zIndex = String(i + 1);
125
+ paint(i);
126
+ });
127
+ mark();
128
+
129
+ const tweens = items.slice(1).map((item, k) => {
130
+ const i = k + 1;
131
+ return gsap.to(prog[i], {
132
+ p: 1,
133
+ ease: "none",
134
+ onUpdate: () => {
135
+ paint(i);
136
+ mark();
137
+ },
138
+ scrollTrigger: { trigger: item, start: FROM, end: TO, scrub: reduce ? true : SMOOTH },
139
+ });
140
+ });
141
+
142
+ // The hairline beside the frame fills from the first chapter to the last.
143
+ const line = fill
144
+ ? gsap.fromTo(
145
+ fill,
146
+ { scaleY: 0 },
147
+ {
148
+ scaleY: 1,
149
+ ease: "none",
150
+ scrollTrigger: { trigger: list, start: "top 52%", end: "bottom 52%", scrub: reduce ? true : SMOOTH },
151
+ },
152
+ )
153
+ : null;
154
+
155
+ return () => {
156
+ for (const t of tweens) {
157
+ t.scrollTrigger?.kill();
158
+ t.kill();
159
+ }
160
+ line?.scrollTrigger?.kill();
161
+ line?.kill();
162
+ items.forEach((c) => delete c.dataset.back);
163
+ for (const l of layers) {
164
+ l.style.clipPath = "";
165
+ l.style.zIndex = "";
166
+ l.style.visibility = "";
167
+ }
168
+ for (const p of pics) if (p) p.style.transform = "";
169
+ if (fill) fill.style.transform = "";
170
+ };
171
+ });
172
+ return () => mm.revert();
173
+ },
174
+ { scope: stage, dependencies: [chapters.length] },
175
+ );
176
+
177
+ // With scripts on desktop the sticky frame stands in the left column and the chapters lose their inline
178
+ // photographs; without scripts (or on phones) every chapter keeps its own. The pre-paint class is `js` in a
179
+ // client project and `sq-js` on struq.nl.
180
+ const withFrame = "hidden lg:[.js_&]:block lg:[.sq-js_&]:block";
181
+ const inlineOnly = "lg:[.js_&]:hidden lg:[.sq-js_&]:hidden";
182
+
183
+ return (
184
+ <section
185
+ ref={stage}
186
+ data-section="sticky-chapters"
187
+ aria-labelledby={title ? "sticky-chapters-title" : undefined}
188
+ className={`relative bg-surface-0 px-6 py-20 lg:px-[4vw] lg:py-[10svh] ${className}`}
189
+ >
190
+ {title ? (
191
+ <h2 id="sticky-chapters-title" className="font-display mb-12 max-w-[16ch] text-h2 text-ink lg:mb-[4svh]">
192
+ {title}
193
+ </h2>
194
+ ) : null}
195
+
196
+ <div className="lg:grid lg:grid-cols-[minmax(0,1.08fr)_minmax(0,1fr)] lg:gap-x-[6vw]">
197
+ {/* The frame: it holds still while the chapters move past it. */}
198
+ <div className={withFrame}>
199
+ <div className="sticky top-[10svh] flex h-[80svh] gap-[1.4vw]">
200
+ <div data-frame="" className="relative h-full flex-1 overflow-hidden rounded-[0.2rem] bg-surface-2">
201
+ {chapters.map((c, i) => (
202
+ <div
203
+ key={c.title}
204
+ data-layer=""
205
+ className="absolute inset-0 overflow-hidden"
206
+ style={{ clipPath: i === 0 ? "inset(0% 0% 0% 0%)" : "inset(100% 0% 0% 0%)" }}
207
+ >
208
+ <div data-pic="" className="absolute inset-0">
209
+ <Image
210
+ src={c.image.src}
211
+ alt={c.image.alt}
212
+ fill
213
+ loading="lazy"
214
+ sizes="(min-width: 1024px) 48vw, 1px"
215
+ unoptimized={c.image.unoptimized}
216
+ className="object-cover"
217
+ />
218
+ </div>
219
+ </div>
220
+ ))}
221
+ </div>
222
+ {/* How far through the chapters: a hairline that fills, never a number. */}
223
+ <div aria-hidden className="relative h-full w-px bg-line">
224
+ <div data-fill="" className="absolute inset-0 origin-top scale-y-0 bg-ink" />
225
+ </div>
226
+ </div>
227
+ </div>
228
+
229
+ <ol data-chapters="" className="grid gap-16 lg:block">
230
+ {chapters.map((c) => (
231
+ <li
232
+ key={c.title}
233
+ data-chapter=""
234
+ className="group/item lg:grid lg:grid-cols-2 lg:items-center lg:gap-x-[4vw] lg:py-[8svh] lg:[.js_&]:flex lg:[.js_&]:min-h-[80svh] lg:[.js_&]:py-0 lg:[.sq-js_&]:flex lg:[.sq-js_&]:min-h-[80svh] lg:[.sq-js_&]:py-0"
235
+ >
236
+ <span className={`relative mb-6 block aspect-[4/5] w-full overflow-hidden rounded-[0.2rem] bg-surface-2 sm:aspect-[4/3] lg:mb-0 lg:aspect-[4/5] ${inlineOnly}`}>
237
+ <Image
238
+ src={c.image.src}
239
+ alt={c.image.alt}
240
+ fill
241
+ sizes="(min-width: 1024px) 44vw, 100vw"
242
+ unoptimized={c.image.unoptimized}
243
+ className="object-cover"
244
+ />
245
+ </span>
246
+ <div className="max-w-[30rem] transition-[opacity,translate] duration-500 ease-[cubic-bezier(0.16,1,0.3,1)] group-data-[back]/item:-translate-x-[0.8vw] group-data-[back]/item:opacity-[0.24]">
247
+ <h3 className="font-display text-h2 text-ink">{c.title}</h3>
248
+ <p className="mt-5 text-lead text-ink-muted">{c.text}</p>
249
+ </div>
250
+ </li>
251
+ ))}
252
+ </ol>
253
+ </div>
254
+ </section>
255
+ );
256
+ }
@@ -0,0 +1,269 @@
1
+ "use client";
2
+ // velocity-type (Struq section recipe)
3
+ // Two lines of display type on a moving belt. At rest they turn over slowly on their own, the first one way
4
+ // and the second the other; as you scroll they travel with it and answer its speed: the faster you go, the
5
+ // faster they run, the more they lean and the more they stretch; when you stop they settle back upright within about
6
+ // 0.4 seconds. Between the phrases small photographs can sit in the line as their own frames at cap height,
7
+ // travelling with the type; they are never masked into the letters.
8
+ //
9
+ // The travel is a running drift (following the way you last scrolled, gathering a flick) plus the scroll. The velocity is read on the GSAP ticker from the
10
+ // scroll position itself (so it falls to zero when the page stops, unlike a velocity that only updates on
11
+ // scroll events), clamped, and smoothed on a short time constant so it never jitters. One loop, transforms
12
+ // only, nothing pinned. Each line repeats its copy enough times to fill the screen twice and wraps by
13
+ // exactly one copy's width, measured after the fonts land and on every resize, so the loop has no seam.
14
+ //
15
+ // <VelocityType lines={[
16
+ // ["Huizen", images.card[0], "Interieurs", images.card[1], "Tuinen"],
17
+ // ["Licht eerst", images.column[0], "dan pas muren"],
18
+ // ]} />
19
+ //
20
+ // A hidden plain copy of each line is read by screen readers; the belt is aria-hidden. Reduced motion and pages
21
+ // without scripts: the lines stand still, set as composed type. Phones: the same travel with half the lean.
22
+ import Image from "next/image";
23
+ import { useRef } from "react";
24
+ import { gsap, ScrollTrigger, useGSAP } from "../kit/gsap";
25
+ import type { StruqImage } from "../kit/images";
26
+
27
+ type Piece = string | StruqImage;
28
+
29
+ type Props = {
30
+ /** Two lines (one works too): phrases, with photographs between them where wanted. */
31
+ lines: Piece[][];
32
+ className?: string;
33
+ };
34
+
35
+ /** Sideways travel per pixel scrolled: a normal pass carries two or three phrases by. */
36
+ const RATE = 1.1;
37
+ /** The belt turns over on its own at this speed (px/s), and runs faster by this share of the scroll speed.
38
+ * Both accumulate into the drift, so a fast flick carries the belt on and nothing ever slides back. */
39
+ const DRIFT = 75;
40
+ const BOOST = 0.25;
41
+ /** Scroll speed (px/s) at which the lean and the stretch are full; anything faster is clamped. */
42
+ const FAST = 600;
43
+ /** The most the type leans (degrees) and stretches (share of its width), desktop and phone. */
44
+ const LEAN = { wide: 12, narrow: 6 };
45
+ const STRETCH = 0.06;
46
+ /** The time constant of the smoothing (s): settled to under 5% within 0.4s of stopping. */
47
+ const TAU = 0.12;
48
+ /** Copies of each line: enough to cover the screen twice whatever the copy's length. */
49
+ const COPIES = 4;
50
+
51
+ const isImage = (p: Piece): p is StruqImage => typeof p !== "string";
52
+
53
+ export function VelocityType({ lines, className = "" }: Props) {
54
+ const stage = useRef<HTMLElement>(null);
55
+
56
+ useGSAP(
57
+ () => {
58
+ const el = stage.current;
59
+ if (!el) return;
60
+ const belts = Array.from(el.querySelectorAll<HTMLElement>("[data-belt]"));
61
+ const lenses = belts.map((b) => b.parentElement);
62
+ if (belts.length === 0) return;
63
+
64
+ // Each inline frame stays unseen until its photograph is decoded, so an empty box never reaches the
65
+ // screen (the pre-paint gate hides it, and shows it after 3s whatever happens).
66
+ for (const frame of Array.from(
67
+ el.querySelectorAll<HTMLElement>("[data-inline]"),
68
+ )) {
69
+ const img = frame.querySelector("img");
70
+ if (!img) continue;
71
+ const loaded = img.complete
72
+ ? Promise.resolve()
73
+ : new Promise<void>((res) => {
74
+ img.addEventListener("load", () => res(), { once: true });
75
+ img.addEventListener("error", () => res(), { once: true });
76
+ });
77
+ loaded
78
+ .then(() => img.decode())
79
+ .catch(() => undefined)
80
+ .then(() => {
81
+ frame.style.visibility = "visible";
82
+ });
83
+ }
84
+
85
+ const mm = gsap.matchMedia();
86
+ mm.add(
87
+ {
88
+ motion: "(prefers-reduced-motion: no-preference)",
89
+ wide: "(min-width: 768px)",
90
+ },
91
+ (ctx) => {
92
+ const { motion, wide } = ctx.conditions as {
93
+ motion: boolean;
94
+ wide: boolean;
95
+ };
96
+ // The belt is held unseen until it stands where the loop puts it (see the gate on it below).
97
+ const release = () => {
98
+ for (const b of belts) b.style.visibility = "visible";
99
+ };
100
+ if (!motion) {
101
+ release();
102
+ return;
103
+ }
104
+ const lean = wide ? LEAN.wide : LEAN.narrow;
105
+
106
+ // One copy's width, the distance the belt wraps by. Measured after the fonts land and on resize.
107
+ const widths = belts.map(() => 1);
108
+ const measure = () => {
109
+ belts.forEach((b, i) => {
110
+ const first = b.querySelector<HTMLElement>("[data-copy]");
111
+ widths[i] = Math.max(1, first?.offsetWidth ?? 1);
112
+ });
113
+ };
114
+ measure();
115
+ document.fonts?.ready.then(measure);
116
+ const ro = new ResizeObserver(measure);
117
+ belts.forEach((b) => {
118
+ const first = b.querySelector<HTMLElement>("[data-copy]");
119
+ if (first) ro.observe(first);
120
+ });
121
+
122
+ // Only while the section is near the screen does the loop do any work.
123
+ let near = false;
124
+ const watch = ScrollTrigger.create({
125
+ trigger: el,
126
+ start: "top bottom+=200",
127
+ end: "bottom top-=200",
128
+ onToggle: (self) => {
129
+ near = self.isActive;
130
+ },
131
+ });
132
+ near = watch.isActive;
133
+
134
+ let lastY = window.scrollY;
135
+ let v = 0;
136
+ let drift = 0;
137
+ let sense = 1;
138
+ const tick = (_time: number, deltaMs: number) => {
139
+ const y = window.scrollY;
140
+ const dt = Math.max(0.001, Math.min(0.1, deltaMs / 1000));
141
+ const raw = gsap.utils.clamp(-FAST * 2, FAST * 2, (y - lastY) / dt);
142
+ lastY = y;
143
+ // Smoothed on a short time constant: no jitter, and back to zero soon after the page stops.
144
+ v += (raw - v) * (1 - Math.exp(-dt / TAU));
145
+ if (Math.abs(v) < 0.5) v = 0;
146
+ if (!near) return;
147
+ // The drift follows the way the reader last scrolled, as a velocity marquee does, and gathers the
148
+ // extra speed of a flick, so the belt keeps turning at rest and never runs back after a flick.
149
+ if (Math.abs(raw) > 1) sense = raw > 0 ? 1 : -1;
150
+ drift += sense * (DRIFT + Math.abs(v) * BOOST) * dt;
151
+ place(y);
152
+ };
153
+
154
+ // Where each belt stands for the current scroll and drift, with its lean and stretch.
155
+ const place = (y: number) => {
156
+ const top = el.getBoundingClientRect().top + y;
157
+ const travel = (y - top) * RATE + drift;
158
+ const k = gsap.utils.clamp(-1, 1, v / FAST);
159
+ belts.forEach((b, i) => {
160
+ const dir = i % 2 === 0 ? -1 : 1;
161
+ const w = widths[i];
162
+ // Wrapped by exactly one copy, so the loop never shows a seam.
163
+ const x = gsap.utils.wrap(-w, 0, dir * travel);
164
+ // The type leans back against the way it is dragged (its top lags), and stretches with the speed.
165
+ const skew = k * lean * dir;
166
+ const sx = 1 + Math.abs(k) * STRETCH;
167
+ b.style.transform = `translate3d(${x.toFixed(2)}px, 0, 0)`;
168
+ // The lean and the stretch sit on a screen-wide lens around the belt, pivoting on the screen's
169
+ // centre, so the stretch never slides the visible type sideways.
170
+ const lens = lenses[i];
171
+ if (lens)
172
+ lens.style.transform = `skewX(${skew.toFixed(3)}deg) scaleX(${sx.toFixed(4)})`;
173
+ });
174
+ };
175
+ gsap.ticker.add(tick);
176
+
177
+ // Released only once the display face has landed and the belt is measured and placed with it, so the
178
+ // first frame anyone sees is already the loop's own; never a jump from the resting layout, never a
179
+ // fallback face's widths. The pre-paint gate shows it after 3s whatever happens.
180
+ let live = true;
181
+ Promise.race([
182
+ document.fonts?.ready ?? Promise.resolve(),
183
+ new Promise((r) => setTimeout(r, 1500)),
184
+ ]).then(() => {
185
+ if (!live) return;
186
+ measure();
187
+ place(window.scrollY);
188
+ release();
189
+ });
190
+
191
+ return () => {
192
+ live = false;
193
+ gsap.ticker.remove(tick);
194
+ watch.kill();
195
+ ro.disconnect();
196
+ for (const b of belts) b.style.transform = "";
197
+ for (const l of lenses) if (l) l.style.transform = "";
198
+ };
199
+ },
200
+ );
201
+ return () => mm.revert();
202
+ },
203
+ { scope: stage, dependencies: [lines.length] },
204
+ );
205
+
206
+ return (
207
+ <section
208
+ ref={stage}
209
+ data-section="velocity-type"
210
+ className={`relative overflow-hidden bg-surface-0 py-[18vw] md:py-[20svh] ${className}`}
211
+ >
212
+ {lines.map((line, li) => (
213
+ // One readable copy per row, for screen readers only; every copy on the belt is decoration and hidden.
214
+ // The row clips sideways only, so descenders below the tight leading are never cut.
215
+ <p
216
+ key={li}
217
+ className={`[overflow-x:clip] ${li > 0 ? "mt-[1.4vw]" : ""} ${li % 2 === 1 ? "pl-[18vw] motion-safe:pl-0" : "pl-[4vw] motion-safe:pl-0"}`}
218
+ >
219
+ <span className="sr-only">
220
+ {line.filter((p): p is string => !isImage(p)).join(", ")}
221
+ </span>
222
+ <span data-lens="" className="block origin-center">
223
+ <span
224
+ data-belt=""
225
+ className="flex w-max motion-safe:[.js_&]:invisible motion-safe:[.sq-js_&]:invisible motion-safe:[.js_&]:animate-[struq-show_0s_3s_forwards] motion-safe:[.sq-js_&]:animate-[struq-show_0s_3s_forwards]"
226
+ >
227
+ {Array.from({ length: COPIES }, (_, c) => (
228
+ <span
229
+ key={c}
230
+ data-copy={c === 0 ? "" : undefined}
231
+ aria-hidden
232
+ className={`flex shrink-0 items-center whitespace-nowrap pr-[0.32em] font-display text-[22vw] leading-[0.92] tracking-[-0.04em] text-ink md:text-[15vw] ${
233
+ li % 2 === 1
234
+ ? "font-accent font-light italic tracking-[-0.03em]"
235
+ : ""
236
+ }`}
237
+ >
238
+ {line.map((piece, pi) =>
239
+ isImage(piece) ? (
240
+ <span
241
+ key={pi}
242
+ data-inline=""
243
+ className="relative mx-[0.32em] inline-block aspect-[4/3] h-[0.62em] shrink-0 overflow-hidden rounded-[0.06em] not-italic [.js_&]:invisible [.sq-js_&]:invisible [.js_&]:animate-[struq-show_0s_3s_forwards] [.sq-js_&]:animate-[struq-show_0s_3s_forwards]"
244
+ >
245
+ <Image
246
+ src={piece.src}
247
+ alt=""
248
+ fill
249
+ loading="eager"
250
+ sizes="(min-width: 768px) 13vw, 18vw"
251
+ unoptimized={piece.unoptimized}
252
+ className="object-cover"
253
+ />
254
+ </span>
255
+ ) : (
256
+ <span key={pi} className="px-[0.12em]">
257
+ {piece}
258
+ </span>
259
+ ),
260
+ )}
261
+ </span>
262
+ ))}
263
+ </span>
264
+ </span>
265
+ </p>
266
+ ))}
267
+ </section>
268
+ );
269
+ }
@@ -0,0 +1,74 @@
1
+ "use client";
2
+ // toonstuk-fill-rows (Struq showpiece recipe)
3
+ // A list of services or chapters set as large hairline rows. On hover and on keyboard focus a row
4
+ // floods with the accent from the bottom edge, its title turns to the accent's ink and nudges in,
5
+ // and its detail slides in beside it. The rows enter with a staggered rise as the list arrives.
6
+ // Lead for an agency or a creative brand (or toonstuk-scrub-connector instead).
7
+ //
8
+ // <FillRows rows={[{ title: "Identiteit", text: "Een merk dat zich in een oogopslag laat herkennen.", href: "#identiteit" }]} />
9
+ //
10
+ // Reduced motion: the fill changes without travel. Phones: the detail sits under each title.
11
+ import Link from "next/link";
12
+ import { useRef } from "react";
13
+ import { gsap, useGSAP } from "../kit/gsap";
14
+
15
+ type Row = { title: string; text: string; href?: string };
16
+
17
+ export function FillRows({ rows, className = "" }: { rows: Row[]; className?: string }) {
18
+ const list = useRef<HTMLUListElement>(null);
19
+
20
+ useGSAP(
21
+ () => {
22
+ const el = list.current;
23
+ if (!el) return;
24
+ const mm = gsap.matchMedia();
25
+ mm.add("(prefers-reduced-motion: no-preference)", () => {
26
+ gsap.from(el.children, {
27
+ yPercent: 40,
28
+ autoAlpha: 0,
29
+ duration: 1,
30
+ ease: "expo.out",
31
+ stagger: 0.08,
32
+ scrollTrigger: { trigger: el, start: "top 85%", once: true },
33
+ });
34
+ });
35
+ return () => mm.revert();
36
+ },
37
+ { scope: list },
38
+ );
39
+
40
+ return (
41
+ <ul ref={list} data-showpiece="fill-rows" className={`border-t border-line ${className}`}>
42
+ {rows.map((row) => {
43
+ const inner = (
44
+ <>
45
+ <span
46
+ aria-hidden
47
+ className="absolute inset-0 origin-bottom scale-y-0 bg-accent transition-transform duration-700 ease-[cubic-bezier(0.16,1,0.3,1)] group-hover:scale-y-100 group-focus-visible:scale-y-100 motion-reduce:transition-none"
48
+ />
49
+ <span className="font-display relative block text-[11vw] leading-[1] tracking-[-0.03em] transition-[color,translate] duration-700 ease-[cubic-bezier(0.16,1,0.3,1)] group-hover:translate-x-[1.5vw] group-hover:text-accent-ink group-focus-visible:translate-x-[1.5vw] group-focus-visible:text-accent-ink motion-reduce:transition-none md:text-[6vw]">
50
+ {row.title}
51
+ </span>
52
+ <span className="relative block max-w-[34ch] text-body text-ink-muted transition-[color,opacity,translate] duration-700 ease-[cubic-bezier(0.16,1,0.3,1)] group-hover:text-accent-ink group-focus-visible:text-accent-ink motion-reduce:transition-none md:translate-y-4 md:opacity-0 md:group-hover:translate-y-0 md:group-hover:opacity-100 md:group-focus-visible:translate-y-0 md:group-focus-visible:opacity-100">
53
+ {row.text}
54
+ </span>
55
+ </>
56
+ );
57
+ const cls = "group relative grid gap-3 overflow-hidden px-6 py-8 outline-none md:grid-cols-[1fr_auto] md:items-end md:px-[5vw] md:py-[3vw]";
58
+ return (
59
+ <li key={row.title} className="border-b border-line">
60
+ {row.href ? (
61
+ <Link href={row.href} className={cls}>
62
+ {inner}
63
+ </Link>
64
+ ) : (
65
+ <div tabIndex={0} className={cls}>
66
+ {inner}
67
+ </div>
68
+ )}
69
+ </li>
70
+ );
71
+ })}
72
+ </ul>
73
+ );
74
+ }