@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
|
@@ -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
|
+
}
|