@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,547 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
// fanned-cards (Struq section recipe)
|
|
3
|
+
// The offers, or a few cases, as a hand of cards that is played onto the table. The heading is the
|
|
4
|
+
// composition: its first line runs across the top at poster size, its italic second line stands at
|
|
5
|
+
// the bottom right, and the cards cross the type. The stage pins on the held opening frame: the
|
|
6
|
+
// cards, all one size and all photographs, fanned large in real 3D between the two lines, turned on a
|
|
7
|
+
// shared pivot, the hand turned and tipped back, the cards behind deeper in the room. Then the hand is
|
|
8
|
+
// played from the back: each card leaves the hand along an arc through space, swinging toward the lens
|
|
9
|
+
// and turning as it flies, and is laid down at its own scale, so the hand ends as a spread on the table:
|
|
10
|
+
// the lead card large in front, each card behind it smaller and partly under the one before, all of
|
|
11
|
+
// them laid over the first line of type. Each title rises out of its mask as its card lands, the lead
|
|
12
|
+
// paragraph last. The spread holds; when the pin lets go the cards are picked up off the table, each at
|
|
13
|
+
// its own speed and turn, while the type leaves with the page.
|
|
14
|
+
//
|
|
15
|
+
// Before the pin the first line rises out of its mask and the hand rises into the frame and opens, so
|
|
16
|
+
// every entrance is done before the first hold. One clock (Lenis on the GSAP ticker); every value is a
|
|
17
|
+
// plain number written as a transform, nothing else moves. The pin is 300%: the opening hand and the
|
|
18
|
+
// spread own long holds, the deal between them is one move.
|
|
19
|
+
//
|
|
20
|
+
// <FannedCards title="Vier vakken," accent="één hand." lead="..." items={[
|
|
21
|
+
// { image: images.column[0], title: "Interieur", text: "Binnen alles op maat, van trap tot lamp.", href: "#interieur" },
|
|
22
|
+
// ...three or four in all, the lead card first
|
|
23
|
+
// ]} />
|
|
24
|
+
//
|
|
25
|
+
// The layout in the markup is the spread, so without JavaScript and under reduced motion the section is
|
|
26
|
+
// that composed frame. Below 1024px it is the type, then the hand as a still fan of photographs, then
|
|
27
|
+
// the titles. Desktop cards lift a little on hover and focus.
|
|
28
|
+
import Image from "next/image";
|
|
29
|
+
import { useRef } from "react";
|
|
30
|
+
import { gsap, SplitText, useGSAP } from "../kit/gsap";
|
|
31
|
+
import type { StruqImage } from "../kit/images";
|
|
32
|
+
|
|
33
|
+
type Item = {
|
|
34
|
+
image: StruqImage;
|
|
35
|
+
title: string;
|
|
36
|
+
/** One line under the title. */
|
|
37
|
+
text: string;
|
|
38
|
+
href?: string;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
type Props = {
|
|
42
|
+
/** The heading's first line, set across the top. */
|
|
43
|
+
title: string;
|
|
44
|
+
/** The second line, in the display face's italic, at the bottom right: the one accent. */
|
|
45
|
+
accent?: string;
|
|
46
|
+
/** One or two sentences; they rise in as the last card lands. */
|
|
47
|
+
lead?: string;
|
|
48
|
+
/** Three or four cards, the lead card first. */
|
|
49
|
+
items: Item[];
|
|
50
|
+
className?: string;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const DEG = Math.PI / 180;
|
|
54
|
+
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
|
|
55
|
+
const unit = (v: number) => Math.min(1, Math.max(0, v));
|
|
56
|
+
const move2 = gsap.parseEase("power2.inOut");
|
|
57
|
+
const move3 = gsap.parseEase("power3.inOut");
|
|
58
|
+
const outExpo = gsap.parseEase("expo.out");
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The spread, in svh from the left gutter: each card's height, top and turn on the table. Each card after
|
|
62
|
+
* the first is laid partly under the one before (OVER of that card's width), smaller, so the hand recedes.
|
|
63
|
+
* The tops sit just over the first line's baseline, so every card crosses the type.
|
|
64
|
+
*/
|
|
65
|
+
type Slot = { h: number; top: number; r: number };
|
|
66
|
+
const SPREAD: Record<3 | 4, Slot[]> = {
|
|
67
|
+
4: [
|
|
68
|
+
{ h: 60, top: 22.5, r: -2.5 },
|
|
69
|
+
{ h: 44, top: 21, r: 2 },
|
|
70
|
+
{ h: 35, top: 20.5, r: -1.5 },
|
|
71
|
+
{ h: 28, top: 21.5, r: 3 },
|
|
72
|
+
],
|
|
73
|
+
3: [
|
|
74
|
+
{ h: 60, top: 22.5, r: -2.5 },
|
|
75
|
+
{ h: 45, top: 21, r: 2.5 },
|
|
76
|
+
{ h: 34, top: 21, r: -2 },
|
|
77
|
+
],
|
|
78
|
+
};
|
|
79
|
+
const OVER = 0.3;
|
|
80
|
+
/** Photographs are 4:5. */
|
|
81
|
+
const ASPECT = 0.8;
|
|
82
|
+
|
|
83
|
+
/** The hand: its card height (share of the screen), degrees between cards, the pivot below in card heights. */
|
|
84
|
+
const HAND = 0.62;
|
|
85
|
+
const SPREAD_DEG = 12;
|
|
86
|
+
const PIVOT = 1.45;
|
|
87
|
+
/** The hand turned and tipped back, in degrees; each card behind sits this much deeper. */
|
|
88
|
+
const TURN_Y = 30;
|
|
89
|
+
const TIP_X = 12;
|
|
90
|
+
const DEPTH = 130;
|
|
91
|
+
/** How far the deepest card in the hand recedes toward the ground colour. */
|
|
92
|
+
const SHADE = 0.32;
|
|
93
|
+
/** The deal: each card's share of it, how high its arc rises (share of the screen), how far toward the lens. */
|
|
94
|
+
const WINDOW = 0.64;
|
|
95
|
+
const ARC = 0.17;
|
|
96
|
+
const NEAR = 360;
|
|
97
|
+
const SWING = 24;
|
|
98
|
+
/** On release each card is picked up at its own speed (share of the screen) and turn. */
|
|
99
|
+
const LIFT = [0.55, 0.85, 1.05, 1.25];
|
|
100
|
+
const SPIN = [-6, 5, -7, 8];
|
|
101
|
+
/** The pointer turns the held hand by up to this many degrees. */
|
|
102
|
+
const TILT = 5;
|
|
103
|
+
|
|
104
|
+
const svh = (v: number) => `${v.toFixed(2)}svh`;
|
|
105
|
+
|
|
106
|
+
export function FannedCards({ title, accent, lead, items, className = "" }: Props) {
|
|
107
|
+
const stage = useRef<HTMLElement>(null);
|
|
108
|
+
const cards = items.slice(0, 4);
|
|
109
|
+
const n = cards.length;
|
|
110
|
+
const slots = SPREAD[n >= 4 ? 4 : 3].slice(0, n);
|
|
111
|
+
|
|
112
|
+
// The spread's geometry in svh, shared by the markup (the still) and the script (the landing places).
|
|
113
|
+
let x = 0;
|
|
114
|
+
const geo = slots.map((s, i) => {
|
|
115
|
+
const w = s.h * ASPECT;
|
|
116
|
+
const left = x;
|
|
117
|
+
x += w * (1 - OVER);
|
|
118
|
+
return { ...s, i, w, left, right: left + w, bottom: s.top + s.h };
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
useGSAP(
|
|
122
|
+
() => {
|
|
123
|
+
const el = stage.current;
|
|
124
|
+
if (!el) return;
|
|
125
|
+
const one = (s: string) => el.querySelector<HTMLElement>(s);
|
|
126
|
+
const all = (s: string) => Array.from(el.querySelectorAll<HTMLElement>(s));
|
|
127
|
+
const type = one("[data-type]");
|
|
128
|
+
const lineOne = one("[data-line-one]");
|
|
129
|
+
const lineTwo = one("[data-line-two]");
|
|
130
|
+
const deck = one("[data-deck]");
|
|
131
|
+
const leadBox = one("[data-lead]");
|
|
132
|
+
const rise = all("[data-rise]");
|
|
133
|
+
const cardEls = all("[data-card]");
|
|
134
|
+
const shades = cardEls.map((c) => c.querySelector<HTMLElement>("[data-shade]"));
|
|
135
|
+
const caps = all("[data-cap]");
|
|
136
|
+
const capLines = caps.map((c) => Array.from(c.querySelectorAll<HTMLElement>("[data-cap-line]")));
|
|
137
|
+
if (!type || !lineOne || !deck || cardEls.length === 0) return;
|
|
138
|
+
|
|
139
|
+
// The type fits its frame whatever the face: the first line within the width, the second clear of the
|
|
140
|
+
// lead card's title. Measured at the natural size, then scaled.
|
|
141
|
+
const fit = () => {
|
|
142
|
+
el.style.setProperty("--fit", "1");
|
|
143
|
+
const W = el.clientWidth;
|
|
144
|
+
const wide = window.matchMedia("(min-width: 1024px)").matches;
|
|
145
|
+
const one1 = lineOne.offsetWidth;
|
|
146
|
+
const two = lineTwo?.offsetWidth ?? 0;
|
|
147
|
+
const k = Math.min(
|
|
148
|
+
1,
|
|
149
|
+
one1 > 0 ? (wide ? W * 0.92 : W - 48) / one1 : 1,
|
|
150
|
+
two > 0 ? (wide ? W * 0.64 : W - 48) / two : 1,
|
|
151
|
+
);
|
|
152
|
+
if (k < 0.995) el.style.setProperty("--fit", String(Math.floor(k * 1000) / 1000));
|
|
153
|
+
};
|
|
154
|
+
fit();
|
|
155
|
+
document.fonts?.ready.then(fit);
|
|
156
|
+
window.addEventListener("resize", fit);
|
|
157
|
+
|
|
158
|
+
const mm = gsap.matchMedia();
|
|
159
|
+
mm.add("(min-width: 1024px) and (prefers-reduced-motion: no-preference)", () => {
|
|
160
|
+
el.dataset.motion = "1";
|
|
161
|
+
|
|
162
|
+
// Layout positions, untouched by transforms: offsets walk up to the stage.
|
|
163
|
+
const box = (node: HTMLElement) => {
|
|
164
|
+
let bx = 0;
|
|
165
|
+
let by = 0;
|
|
166
|
+
let cur: HTMLElement | null = node;
|
|
167
|
+
while (cur && cur !== el) {
|
|
168
|
+
bx += cur.offsetLeft;
|
|
169
|
+
by += cur.offsetTop;
|
|
170
|
+
cur = cur.offsetParent as HTMLElement | null;
|
|
171
|
+
}
|
|
172
|
+
return { x: bx, y: by, w: node.offsetWidth, h: node.offsetHeight };
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const g = {
|
|
176
|
+
W: 1,
|
|
177
|
+
H: 1,
|
|
178
|
+
hand: { x: 0, y: 0 },
|
|
179
|
+
radius: 0,
|
|
180
|
+
cards: [] as { sx: number; sy: number; k: number; a: number; r: number; start: number; dir: number }[],
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
// How far the held hand reaches down over the italic line: its lowest projected edge over that line,
|
|
184
|
+
// against the top of the line's accents. Read from the boxes the browser has drawn, with the hand
|
|
185
|
+
// set to its opening frame for the reading and put back after.
|
|
186
|
+
const overLineTwo = () => {
|
|
187
|
+
if (!lineTwo) return 0;
|
|
188
|
+
const er = el.getBoundingClientRect();
|
|
189
|
+
const lr = lineTwo.getBoundingClientRect();
|
|
190
|
+
const em = parseFloat(getComputedStyle(lineTwo).fontSize) || 100;
|
|
191
|
+
const limit = lr.top - er.top - em * 0.14;
|
|
192
|
+
const saved = { ...s };
|
|
193
|
+
Object.assign(s, { enter: 1, deal: 0, exit: 0, tx: 0, ty: 0 });
|
|
194
|
+
paint();
|
|
195
|
+
let low = -Infinity;
|
|
196
|
+
for (const c of cardEls) {
|
|
197
|
+
const r = c.getBoundingClientRect();
|
|
198
|
+
if (r.right > lr.left && r.left < lr.right) low = Math.max(low, r.bottom - er.top);
|
|
199
|
+
}
|
|
200
|
+
Object.assign(s, saved);
|
|
201
|
+
paint();
|
|
202
|
+
return low - limit;
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
// The hand sits between the two lines and never over the italic one, so "één" keeps its accents in
|
|
206
|
+
// every face: it rises first (a little more of the first line goes under it), then holds smaller.
|
|
207
|
+
const measure = () => {
|
|
208
|
+
fit();
|
|
209
|
+
g.W = el.clientWidth;
|
|
210
|
+
g.H = el.clientHeight;
|
|
211
|
+
layout(1, 0);
|
|
212
|
+
let over = overLineTwo();
|
|
213
|
+
if (over > 0) {
|
|
214
|
+
const lift = Math.min(over + 4, g.H * 0.07);
|
|
215
|
+
layout(1, lift);
|
|
216
|
+
over = overLineTwo();
|
|
217
|
+
if (over > 0) layout(Math.max(0.72, 1 - (over + 6) / (g.H * HAND)), lift);
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
const layout = (scale: number, lift: number) => {
|
|
222
|
+
const hh = g.H * HAND * scale;
|
|
223
|
+
const hw = hh * ASPECT;
|
|
224
|
+
const edge = ((n - 1) / 2) * SPREAD_DEG * DEG;
|
|
225
|
+
g.radius = hh * PIVOT;
|
|
226
|
+
// The hand's reach either side of its centre, a little less than flat since it is turned.
|
|
227
|
+
const half = 0.9 * (g.radius * Math.sin(edge) + (hw / 2) * Math.cos(edge) + (hh / 2) * Math.sin(edge));
|
|
228
|
+
const hx = Math.min(g.W * 0.6, g.W * 0.965 - half);
|
|
229
|
+
const hy = g.H * 0.5 - lift;
|
|
230
|
+
g.hand = { x: hx, y: hy + g.radius };
|
|
231
|
+
g.cards = cardEls.map((c, i) => {
|
|
232
|
+
const b = box(c);
|
|
233
|
+
const sx = b.x + b.w / 2;
|
|
234
|
+
const a = (i - (n - 1) / 2) * SPREAD_DEG;
|
|
235
|
+
const fx = g.hand.x + g.radius * Math.sin(a * DEG);
|
|
236
|
+
return {
|
|
237
|
+
sx,
|
|
238
|
+
sy: b.y + b.h / 2,
|
|
239
|
+
k: hh / Math.max(1, b.h),
|
|
240
|
+
a,
|
|
241
|
+
r: geo[i]?.r ?? 0,
|
|
242
|
+
// The hand is played from the back, so each card is laid down over the one before.
|
|
243
|
+
start: n > 1 ? ((n - 1 - i) * (1 - WINDOW)) / (n - 1) : 0,
|
|
244
|
+
dir: Math.sign(sx - fx) || 1,
|
|
245
|
+
};
|
|
246
|
+
});
|
|
247
|
+
// One shared lens, its vanishing point on the hand.
|
|
248
|
+
const db = box(deck);
|
|
249
|
+
deck.style.perspective = `${Math.round(Math.max(g.W, g.H) * 1.1)}px`;
|
|
250
|
+
deck.style.perspectiveOrigin = `${(hx - db.x).toFixed(1)}px ${(hy - db.y).toFixed(1)}px`;
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
// Every value the scroll and the pointer drive, as plain numbers.
|
|
254
|
+
const s = { enter: 0, deal: 0, exit: 0, tx: 0, ty: 0 };
|
|
255
|
+
let leadLines: HTMLElement[] = [];
|
|
256
|
+
|
|
257
|
+
const paint = () => {
|
|
258
|
+
const { H } = g;
|
|
259
|
+
// Before the pin: the first line rises, the hand rises into the frame and opens.
|
|
260
|
+
const typeIn = outExpo(unit((s.enter - 0.02) / 0.42));
|
|
261
|
+
for (const r of rise) r.style.transform = `translate3d(0, ${((1 - typeIn) * 112).toFixed(2)}%, 0)`;
|
|
262
|
+
const up = outExpo(unit((s.enter - 0.12) / 0.66));
|
|
263
|
+
const open = move3(unit((s.enter - 0.42) / 0.58));
|
|
264
|
+
|
|
265
|
+
for (let i = 0; i < cardEls.length; i += 1) {
|
|
266
|
+
const c = g.cards[i];
|
|
267
|
+
if (!c) continue;
|
|
268
|
+
// The card in the hand.
|
|
269
|
+
const a = c.a * open;
|
|
270
|
+
const x0 = g.hand.x + g.radius * Math.sin(a * DEG) + (1 - open) * i * 8;
|
|
271
|
+
const y0 = g.hand.y - g.radius * Math.cos(a * DEG) + (1 - up) * H * 1.0 - (1 - open) * i * 6;
|
|
272
|
+
const z0 = -i * DEPTH * (0.35 + 0.65 * open);
|
|
273
|
+
// The deal: along an arc through space, swinging toward the lens and turning as it flies.
|
|
274
|
+
const t = unit((s.deal - c.start) / WINDOW);
|
|
275
|
+
const e = move3(t);
|
|
276
|
+
const flat = move2(unit(t / 0.8));
|
|
277
|
+
const bump = Math.sin(Math.PI * e);
|
|
278
|
+
const xc = (x0 + c.sx) / 2;
|
|
279
|
+
const yc = (y0 + c.sy) / 2 - ARC * H;
|
|
280
|
+
const u = 1 - e;
|
|
281
|
+
const px = u * u * x0 + 2 * u * e * xc + e * e * c.sx;
|
|
282
|
+
const py = u * u * y0 + 2 * u * e * yc + e * e * c.sy;
|
|
283
|
+
// The release: picked up off the table, each at its own speed and turn.
|
|
284
|
+
const lift = s.exit * H * (LIFT[i] ?? 1);
|
|
285
|
+
const z = lerp(z0, 0, e) + NEAR * bump + s.exit * 140;
|
|
286
|
+
const ry = (-TURN_Y + s.ty) * (1 - flat) + SWING * bump * c.dir;
|
|
287
|
+
const rx = (TIP_X + s.tx) * (1 - flat) - 12 * bump;
|
|
288
|
+
const rz = lerp(a, c.r, e) + 9 * bump * c.dir + s.exit * (SPIN[i] ?? 0);
|
|
289
|
+
const k = lerp(c.k, 1, e);
|
|
290
|
+
cardEls[i].style.transform = `translate3d(${(px - c.sx).toFixed(2)}px, ${(py - c.sy - lift).toFixed(2)}px, ${z.toFixed(2)}px) rotateY(${ry.toFixed(3)}deg) rotateX(${rx.toFixed(3)}deg) rotateZ(${rz.toFixed(3)}deg) scale(${k.toFixed(4)})`;
|
|
291
|
+
const shade = shades[i];
|
|
292
|
+
if (shade) shade.style.opacity = (n > 1 ? (i / (n - 1)) * SHADE * (1 - e) : 0).toFixed(3);
|
|
293
|
+
// The card's title rises out of its mask as the card lands, and leaves with it.
|
|
294
|
+
const land = move3(unit((t - 0.7) / 0.3));
|
|
295
|
+
const cap = caps[i];
|
|
296
|
+
if (cap) cap.style.transform = `translate3d(0, ${(-lift).toFixed(2)}px, 0)`;
|
|
297
|
+
const ls = capLines[i] ?? [];
|
|
298
|
+
for (let j = 0; j < ls.length; j += 1) {
|
|
299
|
+
const lj = move3(unit((land - j * 0.18) / 0.82));
|
|
300
|
+
ls[j].style.transform = `translate3d(0, ${((1 - lj) * 112).toFixed(2)}%, 0)`;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
// The lead paragraph rises line by line as the last card lands.
|
|
304
|
+
const m = leadLines.length;
|
|
305
|
+
for (let j = 0; j < m; j += 1) {
|
|
306
|
+
const span = m > 1 ? 0.35 / (m - 1) : 0;
|
|
307
|
+
const e = move3(unit((s.deal - 0.66 - j * span * 0.34) / 0.34));
|
|
308
|
+
leadLines[j].style.transform = `translate3d(0, ${((1 - e) * 112).toFixed(2)}%, 0)`;
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
const leadP = leadBox?.querySelector("p");
|
|
313
|
+
const split = leadP
|
|
314
|
+
? SplitText.create(leadP, {
|
|
315
|
+
type: "lines",
|
|
316
|
+
mask: "lines",
|
|
317
|
+
linesClass: "struq-line",
|
|
318
|
+
autoSplit: true,
|
|
319
|
+
onSplit(self) {
|
|
320
|
+
leadLines = self.lines as HTMLElement[];
|
|
321
|
+
paint();
|
|
322
|
+
},
|
|
323
|
+
})
|
|
324
|
+
: null;
|
|
325
|
+
|
|
326
|
+
for (const c of cardEls) c.style.rotate = "none";
|
|
327
|
+
measure();
|
|
328
|
+
paint();
|
|
329
|
+
for (const node of [type, deck, leadBox, ...caps]) if (node) node.style.visibility = "visible";
|
|
330
|
+
|
|
331
|
+
// Before the pin: the type and the hand arrive with the scroll, done by the time it pins.
|
|
332
|
+
gsap.to(s, {
|
|
333
|
+
enter: 1,
|
|
334
|
+
ease: "none",
|
|
335
|
+
onUpdate: paint,
|
|
336
|
+
scrollTrigger: { trigger: el, start: "top bottom", end: "top top", scrub: 1 },
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
const tl = gsap.timeline({
|
|
340
|
+
defaults: { ease: "none" },
|
|
341
|
+
onUpdate: paint,
|
|
342
|
+
scrollTrigger: {
|
|
343
|
+
trigger: el,
|
|
344
|
+
start: "top top",
|
|
345
|
+
end: "+=300%",
|
|
346
|
+
pin: true,
|
|
347
|
+
scrub: 1,
|
|
348
|
+
onRefresh: () => {
|
|
349
|
+
measure();
|
|
350
|
+
paint();
|
|
351
|
+
},
|
|
352
|
+
},
|
|
353
|
+
});
|
|
354
|
+
// Hold on the open hand, the deal as one move, hold on the spread: the holds take more than the bar's
|
|
355
|
+
// half and three fifths of the move.
|
|
356
|
+
tl.to({}, { duration: 0.95 }).to(s, { deal: 1, duration: 1.65 }).to({}, { duration: 1.15 });
|
|
357
|
+
|
|
358
|
+
// The release: once the pin lets go the cards are picked up off the table.
|
|
359
|
+
const pinned = tl.scrollTrigger!;
|
|
360
|
+
gsap.to(s, {
|
|
361
|
+
exit: 1,
|
|
362
|
+
ease: "none",
|
|
363
|
+
onUpdate: paint,
|
|
364
|
+
scrollTrigger: {
|
|
365
|
+
start: () => pinned.end,
|
|
366
|
+
end: () => pinned.end + window.innerHeight,
|
|
367
|
+
scrub: 0.6,
|
|
368
|
+
},
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
// The pointer turns the held hand a little; the turn leaves each card as it is played.
|
|
372
|
+
const fine = window.matchMedia("(hover: hover) and (pointer: fine)").matches;
|
|
373
|
+
const toX = gsap.quickTo(s, "tx", { duration: 0.9, ease: "power3.out", onUpdate: paint });
|
|
374
|
+
const toY = gsap.quickTo(s, "ty", { duration: 0.9, ease: "power3.out", onUpdate: paint });
|
|
375
|
+
const onMove = (ev: PointerEvent) => {
|
|
376
|
+
toY((ev.clientX / window.innerWidth - 0.5) * 2 * TILT);
|
|
377
|
+
toX((ev.clientY / window.innerHeight - 0.5) * -1.4 * TILT);
|
|
378
|
+
};
|
|
379
|
+
if (fine) window.addEventListener("pointermove", onMove, { passive: true });
|
|
380
|
+
|
|
381
|
+
return () => {
|
|
382
|
+
window.removeEventListener("pointermove", onMove);
|
|
383
|
+
split?.revert();
|
|
384
|
+
delete el.dataset.motion;
|
|
385
|
+
for (const c of cardEls) {
|
|
386
|
+
c.style.transform = "";
|
|
387
|
+
c.style.rotate = "";
|
|
388
|
+
}
|
|
389
|
+
for (const sh of shades) if (sh) sh.style.opacity = "";
|
|
390
|
+
for (const r of rise) r.style.transform = "";
|
|
391
|
+
for (const cap of caps) cap.style.transform = "";
|
|
392
|
+
for (const ls of capLines) for (const l of ls) l.style.transform = "";
|
|
393
|
+
deck.style.perspective = "";
|
|
394
|
+
deck.style.perspectiveOrigin = "";
|
|
395
|
+
};
|
|
396
|
+
});
|
|
397
|
+
return () => {
|
|
398
|
+
window.removeEventListener("resize", fit);
|
|
399
|
+
mm.revert();
|
|
400
|
+
};
|
|
401
|
+
},
|
|
402
|
+
{ scope: stage, dependencies: [n] },
|
|
403
|
+
);
|
|
404
|
+
|
|
405
|
+
// Hidden until the script has set the opening frame, on desktop with motion only, and shown after 3s
|
|
406
|
+
// whatever happens, so a failed script can never leave the section empty. The page's pre-paint class is
|
|
407
|
+
// `js` in a client project and `sq-js` on struq.nl.
|
|
408
|
+
const gate =
|
|
409
|
+
"lg:motion-safe:[.js_&]:invisible lg:motion-safe:[.sq-js_&]:invisible lg:motion-safe:animate-[struq-show_0s_3s_forwards]";
|
|
410
|
+
// Masks clip only top and bottom, so a wide face never loses the end of a word.
|
|
411
|
+
const mask = "struq-line-mask block overflow-x-visible overflow-y-clip";
|
|
412
|
+
const display =
|
|
413
|
+
"font-display block w-fit whitespace-nowrap text-[length:calc(var(--d)*var(--fit,1))] leading-[0.92] tracking-[-0.04em]";
|
|
414
|
+
// A soft shadow made from the ground itself, deeper than the ground in light and dark palettes alike.
|
|
415
|
+
const shadow =
|
|
416
|
+
"0 2px 6px oklch(from var(--surface-0) calc(l * 0.3) c h / 0.3), 0 36px 64px -28px oklch(from var(--surface-0) calc(l * 0.22) c h / 0.62)";
|
|
417
|
+
|
|
418
|
+
return (
|
|
419
|
+
<section
|
|
420
|
+
ref={stage}
|
|
421
|
+
data-section="fanned-cards"
|
|
422
|
+
aria-labelledby="fanned-cards-title"
|
|
423
|
+
className={`relative overflow-hidden bg-surface-0 py-16 [--d:15vw] lg:h-svh lg:py-0 lg:[--d:min(12.5vw,19.5svh)] ${className}`}
|
|
424
|
+
>
|
|
425
|
+
<h2
|
|
426
|
+
id="fanned-cards-title"
|
|
427
|
+
data-type=""
|
|
428
|
+
className={`relative z-0 px-6 lg:static lg:px-0 ${gate}`}
|
|
429
|
+
>
|
|
430
|
+
<span className={`${mask} lg:absolute lg:left-[4vw] lg:top-[5svh]`}>
|
|
431
|
+
<span data-rise="" data-line-one="" className={display}>
|
|
432
|
+
{title}
|
|
433
|
+
</span>
|
|
434
|
+
</span>
|
|
435
|
+
{accent ? (
|
|
436
|
+
<span className={`${mask} ml-auto w-fit lg:absolute lg:bottom-[4svh] lg:right-[4vw]`}>
|
|
437
|
+
<span
|
|
438
|
+
data-line-two=""
|
|
439
|
+
className={`${display} font-accent font-light italic tracking-[-0.03em]`}
|
|
440
|
+
>
|
|
441
|
+
{accent}
|
|
442
|
+
</span>
|
|
443
|
+
</span>
|
|
444
|
+
) : null}
|
|
445
|
+
</h2>
|
|
446
|
+
|
|
447
|
+
{lead ? (
|
|
448
|
+
<div
|
|
449
|
+
data-lead=""
|
|
450
|
+
className={`relative mt-6 max-w-[34ch] px-6 lg:absolute lg:bottom-[calc(4svh+var(--d)*var(--fit,1)*0.92+2.2svh)] lg:right-[4vw] lg:mt-0 lg:w-[min(24vw,24ch)] lg:max-w-none lg:px-0 ${gate}`}
|
|
451
|
+
>
|
|
452
|
+
<p className="text-body text-ink-muted">{lead}</p>
|
|
453
|
+
</div>
|
|
454
|
+
) : null}
|
|
455
|
+
|
|
456
|
+
<div
|
|
457
|
+
data-deck=""
|
|
458
|
+
className={`relative z-10 mx-auto mt-10 h-[92vw] lg:absolute lg:inset-0 lg:mt-0 lg:h-auto ${gate}`}
|
|
459
|
+
>
|
|
460
|
+
{cards.map((item, i) => {
|
|
461
|
+
const s = geo[i];
|
|
462
|
+
const fan = (i - (n - 1) / 2) * 7;
|
|
463
|
+
const photo = (
|
|
464
|
+
<span
|
|
465
|
+
data-face=""
|
|
466
|
+
className="relative block h-full w-full overflow-hidden rounded-[0.2rem] bg-surface-2 transition-[translate] duration-[650ms] ease-[cubic-bezier(0.16,1,0.3,1)] lg:group-hover/card:-translate-y-3 lg:group-focus-visible/card:-translate-y-3"
|
|
467
|
+
style={{ boxShadow: shadow }}
|
|
468
|
+
>
|
|
469
|
+
<Image
|
|
470
|
+
src={item.image.src}
|
|
471
|
+
alt={item.image.alt}
|
|
472
|
+
fill
|
|
473
|
+
sizes="(min-width: 1024px) 36vw, 50vw"
|
|
474
|
+
unoptimized={item.image.unoptimized}
|
|
475
|
+
className="object-cover transition-transform duration-[900ms] ease-[cubic-bezier(0.16,1,0.3,1)] lg:group-hover/card:scale-[1.04]"
|
|
476
|
+
/>
|
|
477
|
+
<span data-shade="" aria-hidden className="pointer-events-none absolute inset-0 bg-surface-0 opacity-0" />
|
|
478
|
+
</span>
|
|
479
|
+
);
|
|
480
|
+
const shared = {
|
|
481
|
+
"data-card": "",
|
|
482
|
+
"aria-label": item.href ? item.title : undefined,
|
|
483
|
+
className:
|
|
484
|
+
"group/card absolute left-[21vw] top-[6vw] block aspect-[4/5] w-[58vw] origin-[50%_150%] rotate-[var(--fan)] outline-offset-4 lg:left-[calc(4vw+var(--x))] lg:top-[var(--y)] lg:aspect-auto lg:h-[var(--h)] lg:w-[var(--w)] lg:origin-center lg:rotate-[var(--r)]",
|
|
485
|
+
style: {
|
|
486
|
+
zIndex: n - i,
|
|
487
|
+
"--fan": `${fan}deg`,
|
|
488
|
+
"--x": svh(s.left),
|
|
489
|
+
"--y": svh(s.top),
|
|
490
|
+
"--w": svh(s.w),
|
|
491
|
+
"--h": svh(s.h),
|
|
492
|
+
"--r": `${s.r}deg`,
|
|
493
|
+
} as React.CSSProperties,
|
|
494
|
+
};
|
|
495
|
+
return item.href ? (
|
|
496
|
+
<a key={item.title} href={item.href} {...shared}>
|
|
497
|
+
{photo}
|
|
498
|
+
</a>
|
|
499
|
+
) : (
|
|
500
|
+
<div key={item.title} {...shared}>
|
|
501
|
+
{photo}
|
|
502
|
+
</div>
|
|
503
|
+
);
|
|
504
|
+
})}
|
|
505
|
+
</div>
|
|
506
|
+
|
|
507
|
+
{/* The titles: a list under the fan on phones; on desktop each under its own card, the last beside it. */}
|
|
508
|
+
<div className="relative z-20 mt-10 grid gap-6 px-6 lg:contents">
|
|
509
|
+
{cards.map((item, i) => {
|
|
510
|
+
const s = geo[i];
|
|
511
|
+
const prev = geo[i - 1];
|
|
512
|
+
const isLast = i === n - 1 && n > 1;
|
|
513
|
+
const lead0 = i === 0;
|
|
514
|
+
const pos = isLast
|
|
515
|
+
? { "--cx": svh(s.right + 2.5), "--cb": `calc(100svh - ${svh(s.bottom)})` }
|
|
516
|
+
: { "--cx": svh(prev ? prev.right + 1.6 : s.left), "--cy": svh(s.bottom + 1.8) };
|
|
517
|
+
return (
|
|
518
|
+
<div
|
|
519
|
+
key={item.title}
|
|
520
|
+
data-cap=""
|
|
521
|
+
className={`border-t border-line pt-3 lg:absolute lg:left-[calc(4vw+var(--cx))] lg:w-[min(29svh,21vw)] lg:border-0 lg:pt-0 ${
|
|
522
|
+
isLast ? "lg:bottom-[var(--cb)]" : "lg:top-[var(--cy)]"
|
|
523
|
+
} ${lead0 ? "lg:w-[min(34svh,26vw)]" : ""} ${gate}`}
|
|
524
|
+
style={pos as React.CSSProperties}
|
|
525
|
+
>
|
|
526
|
+
<span className={mask}>
|
|
527
|
+
<span
|
|
528
|
+
data-cap-line=""
|
|
529
|
+
className={`font-display block text-h3 text-ink hyphens-auto ${
|
|
530
|
+
lead0 ? "lg:text-[clamp(2rem,1rem_+_1.7vw,3rem)] lg:leading-[1.05]" : ""
|
|
531
|
+
}`}
|
|
532
|
+
>
|
|
533
|
+
{item.title}
|
|
534
|
+
</span>
|
|
535
|
+
</span>
|
|
536
|
+
<span className={`${mask} mt-1.5`}>
|
|
537
|
+
<span data-cap-line="" className="block text-small text-ink-muted">
|
|
538
|
+
{item.text}
|
|
539
|
+
</span>
|
|
540
|
+
</span>
|
|
541
|
+
</div>
|
|
542
|
+
);
|
|
543
|
+
})}
|
|
544
|
+
</div>
|
|
545
|
+
</section>
|
|
546
|
+
);
|
|
547
|
+
}
|