@struq/skill 0.3.0 → 0.4.1
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 +4 -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 +4 -4
- 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
|
@@ -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
|
+
}
|
package/skills/struq/{cinematic/showpieces/toonstuk-fill-rows.tsx → blocks/showpieces/fill-rows.tsx}
RENAMED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// Reduced motion: the fill changes without travel. Phones: the detail sits under each title.
|
|
11
11
|
import Link from "next/link";
|
|
12
12
|
import { useRef } from "react";
|
|
13
|
-
import { gsap, useGSAP } from "
|
|
13
|
+
import { gsap, useGSAP } from "../kit/gsap";
|
|
14
14
|
|
|
15
15
|
type Row = { title: string; text: string; href?: string };
|
|
16
16
|
|
|
@@ -13,11 +13,11 @@
|
|
|
13
13
|
// keeps the copy at 4.5:1; never a gradient. Reduced motion and phones: the composed still, no pin.
|
|
14
14
|
// The section after the opener slides over it: globals.css gives it a ground and a higher layer.
|
|
15
15
|
import { useRef } from "react";
|
|
16
|
-
import { HeroCanvas } from "
|
|
17
|
-
import { MagneticButton } from "
|
|
18
|
-
import { SplitHeadline } from "
|
|
19
|
-
import { gsap, useGSAP } from "
|
|
20
|
-
import type { StruqImage } from "
|
|
16
|
+
import { HeroCanvas } from "../kit/HeroCanvas";
|
|
17
|
+
import { MagneticButton } from "../kit/MagneticButton";
|
|
18
|
+
import { SplitHeadline } from "../kit/SplitHeadline";
|
|
19
|
+
import { gsap, useGSAP } from "../kit/gsap";
|
|
20
|
+
import type { StruqImage } from "../kit/images";
|
|
21
21
|
|
|
22
22
|
type Props = {
|
|
23
23
|
/** The beeldbank or client photo. Without one (imageFit "none"), the opener is typographic on the inverse ground over the dots scene. */
|
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
// Reduced motion and phones: the composed still, the photo full bleed with both words and the line.
|
|
11
11
|
import Image from "next/image";
|
|
12
12
|
import { useRef } from "react";
|
|
13
|
-
import { gsap, useGSAP } from "
|
|
14
|
-
import type { StruqImage } from "
|
|
13
|
+
import { gsap, useGSAP } from "../kit/gsap";
|
|
14
|
+
import type { StruqImage } from "../kit/images";
|
|
15
15
|
|
|
16
16
|
type Props = {
|
|
17
17
|
image: StruqImage;
|
|
@@ -33,7 +33,15 @@ export function ImageToFullbleed({ image, left, right, line, className = "" }: P
|
|
|
33
33
|
const mm = gsap.matchMedia();
|
|
34
34
|
mm.add("(min-width: 768px) and (prefers-reduced-motion: no-preference)", () => {
|
|
35
35
|
el.dataset.motion = "1";
|
|
36
|
-
|
|
36
|
+
// The clip is driven from two numbers, never tweened as a string: the browser shortens a symmetric
|
|
37
|
+
// inset(31% 40% 31% 40%) to inset(31% 40%), GSAP then pairs two start numbers with four end numbers,
|
|
38
|
+
// and two edges jump open in one frame while the other two slide.
|
|
39
|
+
const frame = q("[data-frame]")[0] as HTMLElement;
|
|
40
|
+
const clip = { y: 31, x: 40 };
|
|
41
|
+
const paint = () => {
|
|
42
|
+
frame.style.clipPath = `inset(${clip.y}% ${clip.x}% ${clip.y}% ${clip.x}%)`;
|
|
43
|
+
};
|
|
44
|
+
paint();
|
|
37
45
|
gsap.set(q("[data-photo]"), { scale: 1.35 });
|
|
38
46
|
gsap.set(q("[data-line]"), { yPercent: 100, autoAlpha: 0 });
|
|
39
47
|
gsap.set(q("[data-scrim]"), { autoAlpha: 0 }); // the scrim ends at its class opacity, never solid
|
|
@@ -42,14 +50,17 @@ export function ImageToFullbleed({ image, left, right, line, className = "" }: P
|
|
|
42
50
|
scrollTrigger: { trigger: el, start: "top top", end: "+=220%", pin: true, scrub: 1 },
|
|
43
51
|
});
|
|
44
52
|
tl.to({}, { duration: 0.12 }) // hold on the small frame
|
|
45
|
-
.to(
|
|
53
|
+
.to(clip, { y: 0, x: 0, duration: 1, ease: "power2.inOut", onUpdate: paint })
|
|
46
54
|
.to(q("[data-photo]"), { scale: 1, duration: 1, ease: "power2.inOut" }, "<")
|
|
47
55
|
.to(q("[data-left]"), { xPercent: -120, duration: 0.9, ease: "power2.in" }, "<")
|
|
48
56
|
.to(q("[data-right]"), { xPercent: 120, duration: 0.9, ease: "power2.in" }, "<")
|
|
49
57
|
.to(q("[data-scrim]"), { autoAlpha: 0.3, duration: 0.4 }, "-=0.3")
|
|
50
58
|
.to(q("[data-line]"), { yPercent: 0, autoAlpha: 1, duration: 0.45, ease: "expo.out" }, "-=0.2")
|
|
51
59
|
.to({}, { duration: 0.35 }); // hold on the open frame
|
|
52
|
-
return () =>
|
|
60
|
+
return () => {
|
|
61
|
+
delete el.dataset.motion;
|
|
62
|
+
frame.style.clipPath = "";
|
|
63
|
+
};
|
|
53
64
|
});
|
|
54
65
|
return () => mm.revert();
|
|
55
66
|
},
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
// toonstuk-manifesto-scrub (Struq showpiece recipe)
|
|
3
|
+
// One large statement paragraph that fills word by word as it scrolls through the screen, from a
|
|
4
|
+
// faint ink to full ink. Type only: the photos that used to open inside the line were dropped on
|
|
5
|
+
// 2026-11-06 (Roy: they read as glitchy), so the statement carries the move on its own.
|
|
6
|
+
// Lead for editorial sites; the supporting move for restaurants and hospitality.
|
|
7
|
+
//
|
|
8
|
+
// <ManifestoScrub text="Wij koken met wat de zee en het land die week geven, en verder niets." />
|
|
9
|
+
//
|
|
10
|
+
// Reduced motion and phones: the composed still, the whole paragraph in full ink.
|
|
11
|
+
import { useRef } from "react";
|
|
12
|
+
import { gsap, useGSAP } from "../kit/gsap";
|
|
13
|
+
|
|
14
|
+
type Props = { text: string; className?: string };
|
|
15
|
+
|
|
16
|
+
export function ManifestoScrub({ text, className = "" }: Props) {
|
|
17
|
+
const stage = useRef<HTMLElement>(null);
|
|
18
|
+
|
|
19
|
+
useGSAP(
|
|
20
|
+
() => {
|
|
21
|
+
const el = stage.current;
|
|
22
|
+
if (!el) return;
|
|
23
|
+
const q = gsap.utils.selector(el);
|
|
24
|
+
const mm = gsap.matchMedia();
|
|
25
|
+
mm.add("(min-width: 768px) and (prefers-reduced-motion: no-preference)", () => {
|
|
26
|
+
gsap.set(q("[data-word]"), { opacity: 0.16 });
|
|
27
|
+
const tl = gsap.timeline({
|
|
28
|
+
defaults: { ease: "none" },
|
|
29
|
+
scrollTrigger: { trigger: el, start: "top top", end: "+=200%", pin: true, scrub: 1 },
|
|
30
|
+
});
|
|
31
|
+
q("[data-word]").forEach((node: Element, i: number) => {
|
|
32
|
+
tl.to(node, { opacity: 1, duration: 0.3 }, i * 0.12);
|
|
33
|
+
});
|
|
34
|
+
tl.to({}, { duration: 0.6 });
|
|
35
|
+
});
|
|
36
|
+
return () => mm.revert();
|
|
37
|
+
},
|
|
38
|
+
{ scope: stage },
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<section ref={stage} data-showpiece="manifesto-scrub" className={`relative flex items-center bg-surface-0 px-6 py-32 md:min-h-svh md:px-[8vw] md:py-0 ${className}`}>
|
|
43
|
+
<p className="font-display max-w-[22ch] text-[9vw] leading-[1.02] tracking-[-0.025em] md:text-[5.2vw]">
|
|
44
|
+
{text.split(/(\s+)/).map((w, j) => (w.trim() ? <span key={j} data-word="">{w}</span> : w))}
|
|
45
|
+
</p>
|
|
46
|
+
</section>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
// Reduced motion and phones: the composed still, both words above the full-bleed photo.
|
|
12
12
|
import Image from "next/image";
|
|
13
13
|
import { useRef } from "react";
|
|
14
|
-
import { gsap, useGSAP } from "
|
|
15
|
-
import type { StruqImage } from "
|
|
14
|
+
import { gsap, useGSAP } from "../kit/gsap";
|
|
15
|
+
import type { StruqImage } from "../kit/images";
|
|
16
16
|
|
|
17
17
|
type Props = {
|
|
18
18
|
image: StruqImage;
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// Reduced motion, phones and any failure: the plain image, immediately.
|
|
11
11
|
import Image from "next/image";
|
|
12
12
|
import { useEffect, useRef } from "react";
|
|
13
|
-
import type { StruqImage } from "
|
|
13
|
+
import type { StruqImage } from "../kit/images";
|
|
14
14
|
|
|
15
15
|
/** Pixel sizes in CSS pixels, coarse to fine. */
|
|
16
16
|
const STEPS = [64, 40, 24, 14, 8, 4];
|