@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
|
@@ -14,8 +14,8 @@
|
|
|
14
14
|
import Image from "next/image";
|
|
15
15
|
import Link from "next/link";
|
|
16
16
|
import { useRef } from "react";
|
|
17
|
-
import { gsap, useGSAP } from "
|
|
18
|
-
import type { StruqImage } from "
|
|
17
|
+
import { gsap, useGSAP } from "../kit/gsap";
|
|
18
|
+
import type { StruqImage } from "../kit/images";
|
|
19
19
|
|
|
20
20
|
type Item = { image: StruqImage; title: string; meta?: string; href?: string };
|
|
21
21
|
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
// toonstuk-scrub-connector (Struq showpiece recipe)
|
|
3
|
+
// Two words and one bar that joins them. The words start far apart, one high on the left, one low
|
|
4
|
+
// on the right, divided by a tall upright slab of ink. As you scroll the stage pins and the bar
|
|
5
|
+
// becomes the showpiece: the slab swings a third of a turn and runs out past both edges as one
|
|
6
|
+
// slash through both words (their letters turn to the ground colour where it crosses). It then
|
|
7
|
+
// draws back to the words' inner ends and carries them in, turning to the accent as it travels,
|
|
8
|
+
// and seats long and slightly rising between them. The joined pair holds, and the pin lets the
|
|
9
|
+
// whole picture scroll away. The pin is 300%: every key frame holds, the moves are short.
|
|
10
|
+
// The bar is drawn from plain numbers on every frame, never by tweening a clip-path string.
|
|
11
|
+
// Lead for an agency or a creative brand (or toonstuk-fill-rows instead).
|
|
12
|
+
//
|
|
13
|
+
// <ScrubConnector left="Kijken" right="Maken" leftText="Elk project begint met wat er al staat." rightText="Daarna tekenen we pas." />
|
|
14
|
+
//
|
|
15
|
+
// Reduced motion: the joined pair, the accent bar rising between the words. Phones: the words
|
|
16
|
+
// stacked, joined by an upright accent bar with the two lines of copy beside it.
|
|
17
|
+
import { useRef } from "react";
|
|
18
|
+
import { gsap, useGSAP } from "../kit/gsap";
|
|
19
|
+
|
|
20
|
+
type Props = {
|
|
21
|
+
left: string;
|
|
22
|
+
right: string;
|
|
23
|
+
leftText?: string;
|
|
24
|
+
rightText?: string;
|
|
25
|
+
className?: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
type Tone = "base" | "ink" | "accent";
|
|
29
|
+
|
|
30
|
+
const DEG = Math.PI / 180;
|
|
31
|
+
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
|
|
32
|
+
|
|
33
|
+
/** The landed bar rises by this angle: its ends meet the words 0.12em below and above the line (atan(0.24 / 2.44)). */
|
|
34
|
+
const RISE = 5.6;
|
|
35
|
+
/** How far each word sits below (first) or above (second) the shared line, in em of the display size. */
|
|
36
|
+
const STEP = 0.12;
|
|
37
|
+
/** The opening slab: its height as a share of the screen, its thickness in em. */
|
|
38
|
+
const SLAB = 0.66;
|
|
39
|
+
const SLAB_T = 0.3;
|
|
40
|
+
/** The slash: its thickness in em, and how far past the two words it runs, as a multiple of their distance. */
|
|
41
|
+
const SLASH_T = 0.42;
|
|
42
|
+
const SLASH = 2.4;
|
|
43
|
+
|
|
44
|
+
export function ScrubConnector({ left, right, leftText, rightText, className = "" }: Props) {
|
|
45
|
+
const stage = useRef<HTMLElement>(null);
|
|
46
|
+
|
|
47
|
+
useGSAP(
|
|
48
|
+
() => {
|
|
49
|
+
const el = stage.current;
|
|
50
|
+
if (!el) return;
|
|
51
|
+
const q = (s: string) => Array.from(el.querySelectorAll<HTMLElement>(s));
|
|
52
|
+
const base = q("[data-row='base']")[0];
|
|
53
|
+
const still = base?.querySelector<HTMLElement>("[data-still]");
|
|
54
|
+
const wordL = base?.querySelector<HTMLElement>("[data-word='l']");
|
|
55
|
+
const wordR = base?.querySelector<HTMLElement>("[data-word='r']");
|
|
56
|
+
const sideL0 = base?.querySelector<HTMLElement>("[data-side='l']");
|
|
57
|
+
const sideR0 = base?.querySelector<HTMLElement>("[data-side='r']");
|
|
58
|
+
const gap = base?.querySelector<HTMLElement>("[data-gap]");
|
|
59
|
+
if (!base || !still || !wordL || !wordR || !sideL0 || !sideR0 || !gap) return;
|
|
60
|
+
|
|
61
|
+
// Fit: the pair never wraps or overflows, whatever the face, and a narrow face grows (up to a third) so the
|
|
62
|
+
// joined pair still spans the frame. Measured at the natural size, then scaled.
|
|
63
|
+
const fit = () => {
|
|
64
|
+
el.style.setProperty("--fit", "1");
|
|
65
|
+
const avail = base.clientWidth;
|
|
66
|
+
const wide = window.matchMedia("(min-width: 768px)").matches;
|
|
67
|
+
const need = wide
|
|
68
|
+
? sideL0.offsetWidth + gap.offsetWidth + sideR0.offsetWidth
|
|
69
|
+
: Math.max(wordL.offsetWidth, wordR.offsetWidth);
|
|
70
|
+
if (need <= 0) return;
|
|
71
|
+
const k = Math.min(wide ? 1.33 : 1, avail / need);
|
|
72
|
+
if (Math.abs(k - 1) > 0.005) el.style.setProperty("--fit", String(Math.floor(k * 1000) / 1000));
|
|
73
|
+
};
|
|
74
|
+
fit();
|
|
75
|
+
document.fonts?.ready.then(fit);
|
|
76
|
+
window.addEventListener("resize", fit);
|
|
77
|
+
|
|
78
|
+
const mm = gsap.matchMedia();
|
|
79
|
+
mm.add("(min-width: 768px) and (prefers-reduced-motion: no-preference)", () => {
|
|
80
|
+
el.dataset.motion = "1";
|
|
81
|
+
const sidesL = q("[data-side='l']");
|
|
82
|
+
const sidesR = q("[data-side='r']");
|
|
83
|
+
const inkLayer = q("[data-layer='ink']")[0];
|
|
84
|
+
const accentLayer = q("[data-layer='accent']")[0];
|
|
85
|
+
|
|
86
|
+
// Layout positions, untouched by transforms: offsets walk up to the stage.
|
|
87
|
+
const box = (n: HTMLElement) => {
|
|
88
|
+
let x = 0;
|
|
89
|
+
let y = 0;
|
|
90
|
+
let cur: HTMLElement | null = n;
|
|
91
|
+
while (cur && cur !== el) {
|
|
92
|
+
x += cur.offsetLeft;
|
|
93
|
+
y += cur.offsetTop;
|
|
94
|
+
cur = cur.offsetParent as HTMLElement | null;
|
|
95
|
+
}
|
|
96
|
+
return { x, y, w: n.offsetWidth, h: n.offsetHeight };
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
// Each word has a home point on the shared line (its centre, at its place beside the bar). The bar hangs
|
|
100
|
+
// on two anchors that ride with the words: their centres while it slashes, their inner ends once seated.
|
|
101
|
+
const g = {
|
|
102
|
+
H: 1,
|
|
103
|
+
offL: { x: 0, y: 0 }, offR: { x: 0, y: 0 },
|
|
104
|
+
homeL: { x: 0, y: 0 }, homeR: { x: 0, y: 0 },
|
|
105
|
+
endL: { x: 0, y: 0 }, endR: { x: 0, y: 0 },
|
|
106
|
+
l0: 0, t0: 0, t1: 0, t2: 0,
|
|
107
|
+
};
|
|
108
|
+
const measure = () => {
|
|
109
|
+
fit();
|
|
110
|
+
const W = el.clientWidth;
|
|
111
|
+
g.H = el.clientHeight;
|
|
112
|
+
const em = parseFloat(getComputedStyle(wordL).fontSize) || 100;
|
|
113
|
+
const step = STEP * em; // the sides carry this as their CSS translate, below and above the line
|
|
114
|
+
const b = box(still);
|
|
115
|
+
const line = b.y + b.h / 2;
|
|
116
|
+
const wl = box(wordL);
|
|
117
|
+
const wr = box(wordR);
|
|
118
|
+
const sl = box(sideL0);
|
|
119
|
+
const sr = box(sideR0);
|
|
120
|
+
g.homeL = { x: wl.x + wl.w / 2, y: line + step };
|
|
121
|
+
g.homeR = { x: wr.x + wr.w / 2, y: line - step };
|
|
122
|
+
// The seated bar is the still bar's own box turned by RISE about its centre; its ends, relative to homes.
|
|
123
|
+
const hx = (Math.cos(RISE * DEG) * b.w) / 2;
|
|
124
|
+
const hy = (Math.sin(RISE * DEG) * b.w) / 2;
|
|
125
|
+
const cx = b.x + b.w / 2;
|
|
126
|
+
g.endL = { x: cx - hx - g.homeL.x, y: line + hy - g.homeL.y };
|
|
127
|
+
g.endR = { x: cx + hx - g.homeR.x, y: line - hy - g.homeR.y };
|
|
128
|
+
g.t2 = b.h;
|
|
129
|
+
const m = W * 0.05;
|
|
130
|
+
// The start: the first word high on the left, the second low on the right, both whole in the frame.
|
|
131
|
+
g.offL = {
|
|
132
|
+
x: m - sl.x,
|
|
133
|
+
y: Math.max(g.H * 0.27 - g.homeL.y, g.H * 0.08 - (sl.y + step)),
|
|
134
|
+
};
|
|
135
|
+
g.offR = {
|
|
136
|
+
x: W - m - (sr.x + sr.w),
|
|
137
|
+
y: Math.min(g.H * 0.7 - g.homeR.y, g.H * 0.93 - (sr.y + sr.h - step)),
|
|
138
|
+
};
|
|
139
|
+
g.l0 = g.H * SLAB;
|
|
140
|
+
g.t0 = SLAB_T * em;
|
|
141
|
+
g.t1 = SLASH_T * em;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
// Every value the scroll drives, as plain numbers.
|
|
145
|
+
const s = { sweep: 0, reach: SLASH, pull: 0, anchor: 0, thin: 0, fill: 0 };
|
|
146
|
+
|
|
147
|
+
// A bar about its centre, `from` and `to` the stretch of its length that is drawn (0 is the end the angle
|
|
148
|
+
// points away from).
|
|
149
|
+
const quad = (cx: number, cy: number, ang: number, len: number, t: number, from = 0, to = 1) => {
|
|
150
|
+
const dx = Math.cos(ang);
|
|
151
|
+
const dy = Math.sin(ang);
|
|
152
|
+
const a = -len / 2 + len * from;
|
|
153
|
+
const z = -len / 2 + len * to;
|
|
154
|
+
const o = t / 2;
|
|
155
|
+
const p = (k: number, n: number) => `${(cx + dx * k - dy * n).toFixed(2)}px ${(cy + dy * k + dx * n).toFixed(2)}px`;
|
|
156
|
+
return `polygon(${p(a, -o)}, ${p(z, -o)}, ${p(z, o)}, ${p(a, o)})`;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const paint = () => {
|
|
160
|
+
// The words: from their corners to their places, carried in by the bar.
|
|
161
|
+
const lx = g.offL.x * (1 - s.pull);
|
|
162
|
+
const ly = g.offL.y * (1 - s.pull);
|
|
163
|
+
const rx = g.offR.x * (1 - s.pull);
|
|
164
|
+
const ry = g.offR.y * (1 - s.pull);
|
|
165
|
+
const tL = `translate3d(${lx.toFixed(2)}px, ${ly.toFixed(2)}px, 0)`;
|
|
166
|
+
const tR = `translate3d(${rx.toFixed(2)}px, ${ry.toFixed(2)}px, 0)`;
|
|
167
|
+
for (const n of sidesL) n.style.transform = tL;
|
|
168
|
+
for (const n of sidesR) n.style.transform = tR;
|
|
169
|
+
|
|
170
|
+
// The rod between the two anchors. Its angle points from the second word back to the first, so the first
|
|
171
|
+
// word's end is the far end of its length.
|
|
172
|
+
const ax = g.homeL.x + lx + g.endL.x * s.anchor;
|
|
173
|
+
const ay = g.homeL.y + ly + g.endL.y * s.anchor;
|
|
174
|
+
const bx = g.homeR.x + rx + g.endR.x * s.anchor;
|
|
175
|
+
const by = g.homeR.y + ry + g.endR.y * s.anchor;
|
|
176
|
+
const rodAng = Math.atan2(by - ay, bx - ax) / DEG + 180;
|
|
177
|
+
const rodLen = Math.hypot(bx - ax, by - ay) * s.reach;
|
|
178
|
+
const rodT = lerp(g.t1, g.t2, s.thin);
|
|
179
|
+
// Before the slash the bar is the upright slab between the words, swinging onto the rod.
|
|
180
|
+
const ang = lerp(90, rodAng, s.sweep) * DEG;
|
|
181
|
+
const len = lerp(g.l0, rodLen, s.sweep);
|
|
182
|
+
const t = lerp(g.t0, rodT, s.sweep);
|
|
183
|
+
const cx = (ax + bx) / 2;
|
|
184
|
+
const cy = (ay + by) / 2;
|
|
185
|
+
inkLayer.style.clipPath = quad(cx, cy, ang, len, t);
|
|
186
|
+
// The accent runs along the bar from the first word's end while it travels.
|
|
187
|
+
accentLayer.style.clipPath = s.fill > 0 ? quad(cx, cy, ang, len, t + 1, 1 - s.fill, 1) : "polygon(0 0, 0 0, 0 0)";
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
measure();
|
|
191
|
+
paint();
|
|
192
|
+
|
|
193
|
+
const tl = gsap.timeline({
|
|
194
|
+
defaults: { ease: "none" },
|
|
195
|
+
onUpdate: paint,
|
|
196
|
+
scrollTrigger: {
|
|
197
|
+
trigger: el,
|
|
198
|
+
start: "top top",
|
|
199
|
+
end: "+=300%",
|
|
200
|
+
pin: true,
|
|
201
|
+
scrub: 1,
|
|
202
|
+
onRefreshInit: () => {
|
|
203
|
+
for (const n of [...sidesL, ...sidesR]) n.style.transform = "";
|
|
204
|
+
},
|
|
205
|
+
onRefresh: () => {
|
|
206
|
+
measure();
|
|
207
|
+
paint();
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
});
|
|
211
|
+
// Hold: the two words apart, the slab standing between them.
|
|
212
|
+
tl.to({}, { duration: 0.6 })
|
|
213
|
+
// The showpiece: the slab swings a third of a turn, thickens and runs out past both edges, onto the
|
|
214
|
+
// line through both words.
|
|
215
|
+
.to(s, { sweep: 1, duration: 1.2, ease: "power2.inOut" })
|
|
216
|
+
// Hold on the slash through both words.
|
|
217
|
+
.to({}, { duration: 0.9 })
|
|
218
|
+
// The join: the bar draws back to the words' inner ends and carries them in to their places, turning
|
|
219
|
+
// to the accent and thinning as it goes, and seats long and rising between them.
|
|
220
|
+
.to(s, { pull: 1, duration: 1.3, ease: "power3.inOut" })
|
|
221
|
+
.to(s, { anchor: 1, reach: 1, duration: 0.7, ease: "power3.inOut" }, "<")
|
|
222
|
+
.to(s, { thin: 1, duration: 1.3, ease: "power2.inOut" }, "<")
|
|
223
|
+
.to(s, { fill: 1, duration: 1.1, ease: "power2.inOut" }, "<0.1")
|
|
224
|
+
// Hold on the joined pair; the pin then lets the whole picture scroll away.
|
|
225
|
+
.to({}, { duration: 1.4 });
|
|
226
|
+
|
|
227
|
+
return () => {
|
|
228
|
+
delete el.dataset.motion;
|
|
229
|
+
for (const n of [...sidesL, ...sidesR]) n.style.transform = "";
|
|
230
|
+
for (const n of [inkLayer, accentLayer]) n.style.clipPath = "";
|
|
231
|
+
};
|
|
232
|
+
});
|
|
233
|
+
return () => {
|
|
234
|
+
window.removeEventListener("resize", fit);
|
|
235
|
+
mm.revert();
|
|
236
|
+
};
|
|
237
|
+
},
|
|
238
|
+
{ scope: stage },
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
// One fluid display size: 19vw on phones, about 10vw on desktop, scaled by --fit so the pair spans the frame. The
|
|
242
|
+
// bar, the gap, the step between the words and the copy's clearance below the descenders all follow it in em.
|
|
243
|
+
const word =
|
|
244
|
+
"font-display block w-fit whitespace-nowrap text-[length:var(--cw)] leading-[0.9] tracking-[-0.035em]";
|
|
245
|
+
|
|
246
|
+
const row = (tone: Tone) => {
|
|
247
|
+
const wordTone = tone === "base" ? "text-ink" : tone === "ink" ? "text-surface-0" : "text-accent-ink";
|
|
248
|
+
const copyTone = tone === "base" ? "text-ink-muted" : wordTone;
|
|
249
|
+
const copy = `max-w-[26ch] text-small ${copyTone}`;
|
|
250
|
+
return (
|
|
251
|
+
<div
|
|
252
|
+
data-row={tone}
|
|
253
|
+
className="grid w-full grid-cols-[auto_1fr] gap-x-5 md:flex md:items-start md:justify-center md:gap-x-0"
|
|
254
|
+
>
|
|
255
|
+
<div data-side="l" className="contents md:flex md:flex-col md:items-start md:translate-y-[calc(var(--cw)*0.12)]">
|
|
256
|
+
<span data-word="l" className={`${word} col-span-2 ${wordTone}`}>
|
|
257
|
+
{left}
|
|
258
|
+
</span>
|
|
259
|
+
{leftText ? (
|
|
260
|
+
<p className={`${copy} col-start-2 row-start-2 mt-[calc(var(--cw)*0.32)] self-start md:mt-[calc(var(--cw)*0.36)]`}>
|
|
261
|
+
{leftText}
|
|
262
|
+
</p>
|
|
263
|
+
) : null}
|
|
264
|
+
</div>
|
|
265
|
+
<span
|
|
266
|
+
data-gap=""
|
|
267
|
+
aria-hidden
|
|
268
|
+
className="font-display col-start-1 row-span-2 row-start-2 my-[0.1em] ml-[0.05em] block min-h-[28svh] w-[0.1em] text-[length:var(--cw)] leading-[0.9] md:my-0 md:ml-0 md:min-h-0 md:w-[2.8em] md:shrink-0 md:px-[0.18em]"
|
|
269
|
+
>
|
|
270
|
+
<span
|
|
271
|
+
data-still=""
|
|
272
|
+
className={`block h-full w-full bg-accent md:inline-block md:h-[0.18em] md:-rotate-[5.6deg] ${
|
|
273
|
+
tone === "base" ? "group-data-[motion]:invisible" : "invisible"
|
|
274
|
+
}`}
|
|
275
|
+
style={{ verticalAlign: "calc(0.5ex - 0.09em)" }}
|
|
276
|
+
/>
|
|
277
|
+
</span>
|
|
278
|
+
<div data-side="r" className="contents md:flex md:flex-col md:items-end md:text-right md:-translate-y-[calc(var(--cw)*0.12)]">
|
|
279
|
+
<span data-word="r" className={`${word} col-span-2 row-start-4 ${wordTone}`}>
|
|
280
|
+
{right}
|
|
281
|
+
</span>
|
|
282
|
+
{rightText ? (
|
|
283
|
+
<p className={`${copy} col-start-2 row-start-3 self-end pb-1 md:mt-[calc(var(--cw)*0.36)] md:pb-0`}>{rightText}</p>
|
|
284
|
+
) : null}
|
|
285
|
+
</div>
|
|
286
|
+
</div>
|
|
287
|
+
);
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
const layer = "pointer-events-none absolute inset-0 hidden items-center px-[5vw] group-data-[motion]:md:flex";
|
|
291
|
+
return (
|
|
292
|
+
<section
|
|
293
|
+
ref={stage}
|
|
294
|
+
data-showpiece="scrub-connector"
|
|
295
|
+
style={{ "--cw": "calc(var(--cw-base) * var(--fit, 1))" } as React.CSSProperties}
|
|
296
|
+
className={`group relative flex items-center overflow-hidden bg-surface-0 px-6 py-24 [--cw-base:19vw] md:h-svh md:px-[5vw] md:py-0 md:[--cw-base:clamp(3rem,1rem_+_10.5vw,14rem)] ${className}`}
|
|
297
|
+
>
|
|
298
|
+
{row("base")}
|
|
299
|
+
<div data-layer="ink" aria-hidden className={`${layer} bg-ink`}>
|
|
300
|
+
{row("ink")}
|
|
301
|
+
</div>
|
|
302
|
+
<div data-layer="accent" aria-hidden className={`${layer} bg-accent`}>
|
|
303
|
+
{row("accent")}
|
|
304
|
+
</div>
|
|
305
|
+
</section>
|
|
306
|
+
);
|
|
307
|
+
}
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
//
|
|
11
11
|
// Reduced motion and phones: the name stands still at full width; the clock still ticks.
|
|
12
12
|
import { useEffect, useRef, useState } from "react";
|
|
13
|
-
import { gsap, SplitText, useGSAP } from "
|
|
13
|
+
import { gsap, SplitText, useGSAP } from "../kit/gsap";
|
|
14
14
|
|
|
15
15
|
type Props = {
|
|
16
16
|
name: string;
|