@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,128 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
// toonstuk-hero-opener (Struq showpiece recipe): the full-bleed cinematic opener
|
|
3
|
+
// The first screen is the photo (or the WebGL scene over it), edge to edge, with the display line
|
|
4
|
+
// set across it at 14 to 20vw: either one poster line along the held-clear bottom ("across"), or
|
|
5
|
+
// the name split to the left and right edges with the photo between ("split"). One short lead and
|
|
6
|
+
// one confident rectangular CTA sit above the line. On scroll the opener hands over with a real
|
|
7
|
+
// move: it pins, scales down and darkens while the next section slides up over it.
|
|
8
|
+
//
|
|
9
|
+
// <HeroOpener image={images.hero[0]} title="Koken op" accent="vuur." lead="..." cta={{ href: "#reserveren", label: "Reserveer een tafel" }} header={<SiteHeader />} />
|
|
10
|
+
// <HeroOpener image={images.hero[0]} split={["Vuur", "Noord"]} lead="..." cta={...} />
|
|
11
|
+
//
|
|
12
|
+
// The line is the display face at poster size, with at most one italic accent word. The flat scrim
|
|
13
|
+
// keeps the copy at 4.5:1; never a gradient. Reduced motion and phones: the composed still, no pin.
|
|
14
|
+
// The section after the opener slides over it: globals.css gives it a ground and a higher layer.
|
|
15
|
+
import { useRef } from "react";
|
|
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
|
+
|
|
22
|
+
type Props = {
|
|
23
|
+
/** The beeldbank or client photo. Without one (imageFit "none"), the opener is typographic on the inverse ground over the dots scene. */
|
|
24
|
+
image?: StruqImage;
|
|
25
|
+
/** The poster line ("across"). */
|
|
26
|
+
title?: string;
|
|
27
|
+
/** One word in the display face's italic, after the title. */
|
|
28
|
+
accent?: string;
|
|
29
|
+
/** Two words for the split wordmark; replaces title. */
|
|
30
|
+
split?: [string, string];
|
|
31
|
+
lead: string;
|
|
32
|
+
cta: { href: string; label: string };
|
|
33
|
+
/** The site header (with the data-wordmark name), set over the photo. */
|
|
34
|
+
header?: React.ReactNode;
|
|
35
|
+
/** 0.2 to 0.5: the flat scrim under the copy. */
|
|
36
|
+
scrim?: number;
|
|
37
|
+
/**
|
|
38
|
+
* The photo's calm side, where the lead reads. Defaults to the image's own negativeSpace from the
|
|
39
|
+
* beeldbank. With no calm left or right side, the lead gets a fixed backing and a step up in size.
|
|
40
|
+
*/
|
|
41
|
+
calm?: "left" | "right" | "none";
|
|
42
|
+
className?: string;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export function HeroOpener({ image, title, accent, split, lead, cta, header, scrim = 0.34, calm, className = "" }: Props) {
|
|
46
|
+
// The lead goes on the photo's calm side; the CTA takes the other. Over a busy frame (no calm
|
|
47
|
+
// side known) the lead gets a flat backing at a fixed opacity and is set a step larger.
|
|
48
|
+
const side = calm ?? (image?.negativeSpace === "left" || image?.negativeSpace === "right" ? image.negativeSpace : image ? "none" : "left");
|
|
49
|
+
const stage = useRef<HTMLElement>(null);
|
|
50
|
+
|
|
51
|
+
useGSAP(
|
|
52
|
+
() => {
|
|
53
|
+
const el = stage.current;
|
|
54
|
+
if (!el) return;
|
|
55
|
+
const q = gsap.utils.selector(el);
|
|
56
|
+
const mm = gsap.matchMedia();
|
|
57
|
+
mm.add("(prefers-reduced-motion: no-preference)", () => {
|
|
58
|
+
if (split) {
|
|
59
|
+
gsap.from(q("[data-split-word]"), { yPercent: 110, duration: 1.4, ease: "expo.out", stagger: 0.12, delay: 0.25 });
|
|
60
|
+
}
|
|
61
|
+
gsap.from(q("[data-opener-copy] > *"), { y: 32, autoAlpha: 0, duration: 1, ease: "expo.out", stagger: 0.1, delay: 0.7 });
|
|
62
|
+
});
|
|
63
|
+
mm.add("(min-width: 768px) and (prefers-reduced-motion: no-preference)", () => {
|
|
64
|
+
// The handover: the opener holds, then scales down and darkens under the next section.
|
|
65
|
+
gsap.timeline({
|
|
66
|
+
scrollTrigger: { trigger: el, start: "top top", end: "bottom top", pin: true, pinSpacing: false, scrub: true },
|
|
67
|
+
})
|
|
68
|
+
.to(q("[data-opener-inner]"), { scale: 0.84, yPercent: -4, ease: "none" }, 0)
|
|
69
|
+
.to(q("[data-opener-dim]"), { opacity: 0.6, ease: "none" }, 0);
|
|
70
|
+
});
|
|
71
|
+
return () => mm.revert();
|
|
72
|
+
},
|
|
73
|
+
{ scope: stage },
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
// At poster size the words need air of their own: tight tracking pulls the letters together, and
|
|
77
|
+
// word spacing in em keeps the gap in step with the size for any face.
|
|
78
|
+
const poster = "font-display block text-[19vw] leading-[0.92] tracking-[-0.045em] [word-spacing:0.09em] md:text-[16vw]";
|
|
79
|
+
return (
|
|
80
|
+
<section ref={stage} data-showpiece="hero-opener" className={`relative h-svh overflow-hidden ${image ? "bg-black text-on-photo" : "scene-inverse"} ${className}`}>
|
|
81
|
+
<div data-opener-inner="" className="absolute inset-0 origin-top">
|
|
82
|
+
{image ? <HeroCanvas scene="ripple" image={image} grade={0.15} priority /> : <HeroCanvas scene="dots" />}
|
|
83
|
+
{image ? <div aria-hidden className="absolute inset-0 bg-black" style={{ opacity: scrim }} /> : null}
|
|
84
|
+
<div data-opener-dim="" aria-hidden className="absolute inset-0 bg-black opacity-0" />
|
|
85
|
+
{header ? <div className="absolute inset-x-0 top-0 z-20">{header}</div> : null}
|
|
86
|
+
{split ? (
|
|
87
|
+
<h1 aria-label={split.join(" ")} className="absolute inset-x-0 top-1/2 z-10 flex -translate-y-1/2 justify-between px-6 md:px-[3vw]">
|
|
88
|
+
{split.map((word) => (
|
|
89
|
+
<span key={word} className="block overflow-hidden pb-[0.06em]">
|
|
90
|
+
<span data-split-word="" data-quiet="" className={poster}>
|
|
91
|
+
{word}
|
|
92
|
+
</span>
|
|
93
|
+
</span>
|
|
94
|
+
))}
|
|
95
|
+
</h1>
|
|
96
|
+
) : null}
|
|
97
|
+
<div className="absolute inset-x-0 bottom-0 z-10 flex flex-col gap-[3vh] px-6 pb-[3vh] md:px-[3vw]">
|
|
98
|
+
<div data-opener-copy="" className={`flex flex-col gap-6 md:flex-row md:items-end md:justify-between ${side === "right" ? "md:flex-row-reverse md:text-right" : ""}`}>
|
|
99
|
+
{side === "none" ? (
|
|
100
|
+
<p data-quiet="" className="relative isolate max-w-[30ch] text-h3">
|
|
101
|
+
<span aria-hidden className="absolute -inset-x-5 -inset-y-4 -z-10 bg-black opacity-55" />
|
|
102
|
+
{lead}
|
|
103
|
+
</p>
|
|
104
|
+
) : (
|
|
105
|
+
<p data-quiet="" className="max-w-[32ch] text-lead">{lead}</p>
|
|
106
|
+
)}
|
|
107
|
+
<div>
|
|
108
|
+
<MagneticButton href={cta.href} size="lg">
|
|
109
|
+
{cta.label}
|
|
110
|
+
</MagneticButton>
|
|
111
|
+
</div>
|
|
112
|
+
</div>
|
|
113
|
+
{!split && title ? (
|
|
114
|
+
<SplitHeadline as="h1" className={poster}>
|
|
115
|
+
{title}
|
|
116
|
+
{accent ? (
|
|
117
|
+
<>
|
|
118
|
+
{" "}
|
|
119
|
+
<em className="font-accent font-light italic tracking-[-0.02em] [word-spacing:0.09em]">{accent}</em>
|
|
120
|
+
</>
|
|
121
|
+
) : null}
|
|
122
|
+
</SplitHeadline>
|
|
123
|
+
) : null}
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
</section>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
// toonstuk-image-to-fullbleed (Struq showpiece recipe)
|
|
3
|
+
// A small image in the middle of the ground, the name split to either side of it. As you scroll
|
|
4
|
+
// the stage pins, the image unclips to the full viewport while the two words travel apart and off
|
|
5
|
+
// the edges, the photo settles from a push-in, and one closing line rises over it. Then it holds.
|
|
6
|
+
// Lead for a product launch (with the hero scene) and for a studio portfolio.
|
|
7
|
+
//
|
|
8
|
+
// <ImageToFullbleed image={images.hero[0]} left="Zout" right="Noord" line="Koken met wat het seizoen geeft." />
|
|
9
|
+
//
|
|
10
|
+
// Reduced motion and phones: the composed still, the photo full bleed with both words and the line.
|
|
11
|
+
import Image from "next/image";
|
|
12
|
+
import { useRef } from "react";
|
|
13
|
+
import { gsap, useGSAP } from "../kit/gsap";
|
|
14
|
+
import type { StruqImage } from "../kit/images";
|
|
15
|
+
|
|
16
|
+
type Props = {
|
|
17
|
+
image: StruqImage;
|
|
18
|
+
left: string;
|
|
19
|
+
right: string;
|
|
20
|
+
/** One closing line over the open photo. */
|
|
21
|
+
line?: string;
|
|
22
|
+
className?: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export function ImageToFullbleed({ image, left, right, line, className = "" }: Props) {
|
|
26
|
+
const stage = useRef<HTMLElement>(null);
|
|
27
|
+
|
|
28
|
+
useGSAP(
|
|
29
|
+
() => {
|
|
30
|
+
const el = stage.current;
|
|
31
|
+
if (!el) return;
|
|
32
|
+
const q = gsap.utils.selector(el);
|
|
33
|
+
const mm = gsap.matchMedia();
|
|
34
|
+
mm.add("(min-width: 768px) and (prefers-reduced-motion: no-preference)", () => {
|
|
35
|
+
el.dataset.motion = "1";
|
|
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();
|
|
45
|
+
gsap.set(q("[data-photo]"), { scale: 1.35 });
|
|
46
|
+
gsap.set(q("[data-line]"), { yPercent: 100, autoAlpha: 0 });
|
|
47
|
+
gsap.set(q("[data-scrim]"), { autoAlpha: 0 }); // the scrim ends at its class opacity, never solid
|
|
48
|
+
const tl = gsap.timeline({
|
|
49
|
+
defaults: { ease: "none" },
|
|
50
|
+
scrollTrigger: { trigger: el, start: "top top", end: "+=220%", pin: true, scrub: 1 },
|
|
51
|
+
});
|
|
52
|
+
tl.to({}, { duration: 0.12 }) // hold on the small frame
|
|
53
|
+
.to(clip, { y: 0, x: 0, duration: 1, ease: "power2.inOut", onUpdate: paint })
|
|
54
|
+
.to(q("[data-photo]"), { scale: 1, duration: 1, ease: "power2.inOut" }, "<")
|
|
55
|
+
.to(q("[data-left]"), { xPercent: -120, duration: 0.9, ease: "power2.in" }, "<")
|
|
56
|
+
.to(q("[data-right]"), { xPercent: 120, duration: 0.9, ease: "power2.in" }, "<")
|
|
57
|
+
.to(q("[data-scrim]"), { autoAlpha: 0.3, duration: 0.4 }, "-=0.3")
|
|
58
|
+
.to(q("[data-line]"), { yPercent: 0, autoAlpha: 1, duration: 0.45, ease: "expo.out" }, "-=0.2")
|
|
59
|
+
.to({}, { duration: 0.35 }); // hold on the open frame
|
|
60
|
+
return () => {
|
|
61
|
+
delete el.dataset.motion;
|
|
62
|
+
frame.style.clipPath = "";
|
|
63
|
+
};
|
|
64
|
+
});
|
|
65
|
+
return () => mm.revert();
|
|
66
|
+
},
|
|
67
|
+
{ scope: stage },
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<section ref={stage} data-showpiece="image-to-fullbleed" className={`relative h-svh overflow-hidden bg-surface-0 ${className}`}>
|
|
72
|
+
<div data-frame="" className="absolute inset-0 overflow-hidden">
|
|
73
|
+
<div data-photo="" className="absolute inset-0">
|
|
74
|
+
<Image src={image.src} alt={image.alt} fill sizes="100vw" unoptimized={image.unoptimized} className="object-cover" />
|
|
75
|
+
</div>
|
|
76
|
+
<div data-scrim="" aria-hidden className="absolute inset-0 bg-black opacity-30" />
|
|
77
|
+
</div>
|
|
78
|
+
<div className="pointer-events-none absolute inset-x-0 top-1/2 flex -translate-y-1/2 items-center justify-between px-6 text-on-photo md:px-[4vw] motion-safe:md:text-ink">
|
|
79
|
+
<span data-left="" className="font-display block text-[18vw] leading-[0.85] tracking-[-0.04em] md:text-[12vw]">{left}</span>
|
|
80
|
+
<span data-right="" className="font-display block text-[18vw] leading-[0.85] tracking-[-0.04em] md:text-[12vw]">{right}</span>
|
|
81
|
+
</div>
|
|
82
|
+
{line ? (
|
|
83
|
+
<p data-line="" className="font-display text-h2 absolute bottom-[10vh] left-6 max-w-[18ch] text-on-photo md:left-[8vw]">
|
|
84
|
+
{line}
|
|
85
|
+
</p>
|
|
86
|
+
) : null}
|
|
87
|
+
</section>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
// toonstuk-panel-wipe (Struq showpiece recipe)
|
|
3
|
+
// Two ground-colour panels meet in the middle, each carrying one word of the headline. As you
|
|
4
|
+
// scroll the stage pins, the panels part to the edges and reveal a full-bleed photo behind them,
|
|
5
|
+
// the photo settling from a push-in; the panels then close again over it, so the headline carries
|
|
6
|
+
// you into the next scene. A scene change by mask, never a fade between boxes.
|
|
7
|
+
// Lead for restaurants and hospitality (with toonstuk-manifesto-scrub as the supporting move).
|
|
8
|
+
//
|
|
9
|
+
// <PanelWipe image={images.band[0]} left="Vuur" right="Zout" caption="De oven brandt vanaf vier uur." />
|
|
10
|
+
//
|
|
11
|
+
// Reduced motion and phones: the composed still, both words above the full-bleed photo.
|
|
12
|
+
import Image from "next/image";
|
|
13
|
+
import { useRef } from "react";
|
|
14
|
+
import { gsap, useGSAP } from "../kit/gsap";
|
|
15
|
+
import type { StruqImage } from "../kit/images";
|
|
16
|
+
|
|
17
|
+
type Props = {
|
|
18
|
+
image: StruqImage;
|
|
19
|
+
left: string;
|
|
20
|
+
right: string;
|
|
21
|
+
/** One quiet line on the open photo. */
|
|
22
|
+
caption?: string;
|
|
23
|
+
/** Close the panels again at the end (default), or leave the photo open. */
|
|
24
|
+
close?: boolean;
|
|
25
|
+
className?: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export function PanelWipe({ image, left, right, caption, close = true, className = "" }: Props) {
|
|
29
|
+
const stage = useRef<HTMLElement>(null);
|
|
30
|
+
|
|
31
|
+
useGSAP(
|
|
32
|
+
() => {
|
|
33
|
+
const el = stage.current;
|
|
34
|
+
if (!el) return;
|
|
35
|
+
const q = gsap.utils.selector(el);
|
|
36
|
+
const mm = gsap.matchMedia();
|
|
37
|
+
mm.add("(min-width: 768px) and (prefers-reduced-motion: no-preference)", () => {
|
|
38
|
+
gsap.set(q("[data-photo]"), { scale: 1.25 });
|
|
39
|
+
gsap.set(q("[data-caption]"), { autoAlpha: 0, yPercent: 60 });
|
|
40
|
+
const tl = gsap.timeline({
|
|
41
|
+
defaults: { ease: "none" },
|
|
42
|
+
scrollTrigger: { trigger: el, start: "top top", end: close ? "+=260%" : "+=170%", pin: true, scrub: 1 },
|
|
43
|
+
});
|
|
44
|
+
tl.to({}, { duration: 0.15 })
|
|
45
|
+
.to(q("[data-panel-left]"), { xPercent: -100, duration: 1, ease: "power3.inOut" })
|
|
46
|
+
.to(q("[data-panel-right]"), { xPercent: 100, duration: 1, ease: "power3.inOut" }, "<")
|
|
47
|
+
.to(q("[data-photo]"), { scale: 1, duration: 1.2, ease: "power2.out" }, "<")
|
|
48
|
+
.to(q("[data-caption]"), { autoAlpha: 1, yPercent: 0, duration: 0.4, ease: "expo.out" }, "-=0.3")
|
|
49
|
+
.to({}, { duration: 0.5 });
|
|
50
|
+
if (close) {
|
|
51
|
+
tl.to(q("[data-caption]"), { autoAlpha: 0, duration: 0.2 })
|
|
52
|
+
.to(q("[data-panel-left]"), { xPercent: 0, duration: 1, ease: "power3.inOut" }, "<")
|
|
53
|
+
.to(q("[data-panel-right]"), { xPercent: 0, duration: 1, ease: "power3.inOut" }, "<")
|
|
54
|
+
.to({}, { duration: 0.15 });
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
return () => mm.revert();
|
|
58
|
+
},
|
|
59
|
+
{ scope: stage },
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const word = "font-display block text-[15vw] leading-[0.85] tracking-[-0.04em] md:text-[11vw]";
|
|
63
|
+
return (
|
|
64
|
+
<section ref={stage} data-showpiece="panel-wipe" className={`relative overflow-hidden bg-surface-0 motion-safe:md:h-svh ${className}`}>
|
|
65
|
+
{/* Still layout (phones, reduced motion): the words, then the photo. */}
|
|
66
|
+
<div className="flex justify-between px-6 pb-8 pt-24 md:hidden motion-reduce:md:flex">
|
|
67
|
+
<span className={word}>{left}</span>
|
|
68
|
+
<span className={word}>{right}</span>
|
|
69
|
+
</div>
|
|
70
|
+
<div className="relative aspect-[4/5] overflow-hidden motion-safe:md:absolute motion-safe:md:inset-0 motion-safe:md:aspect-auto motion-reduce:md:aspect-[16/9]">
|
|
71
|
+
<div data-photo="" className="absolute inset-0">
|
|
72
|
+
<Image src={image.src} alt={image.alt} fill sizes="100vw" unoptimized={image.unoptimized} className="object-cover" />
|
|
73
|
+
</div>
|
|
74
|
+
{caption ? (
|
|
75
|
+
<p data-caption="" className="absolute z-10 bottom-[8vh] left-6 max-w-[34ch] text-lead text-on-photo md:left-[8vw]">
|
|
76
|
+
<span aria-hidden className="absolute -inset-x-4 -inset-y-3 -z-10 bg-black opacity-35" />
|
|
77
|
+
{caption}
|
|
78
|
+
</p>
|
|
79
|
+
) : null}
|
|
80
|
+
</div>
|
|
81
|
+
<div data-panel-left="" className="absolute inset-y-0 left-0 hidden w-[calc(50%+1px)] items-center justify-end bg-surface-0 pr-[1.5vw] motion-safe:md:flex">
|
|
82
|
+
<span className={word}>{left}</span>
|
|
83
|
+
</div>
|
|
84
|
+
<div data-panel-right="" className="absolute inset-y-0 right-0 hidden w-[calc(50%+1px)] items-center justify-start bg-surface-0 pl-[1.5vw] motion-safe:md:flex">
|
|
85
|
+
<span className={word}>{right}</span>
|
|
86
|
+
</div>
|
|
87
|
+
</section>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
// toonstuk-pixel-resolve (Struq showpiece recipe)
|
|
3
|
+
// The hero photo arrives as coarse pixels and resolves to sharp in a few held steps, on a stepped
|
|
4
|
+
// 2D canvas over the real image (no WebGL needed). When the last step lands, the canvas is hidden
|
|
5
|
+
// and the plain next/image is what stays, so the page always ends on the real photo.
|
|
6
|
+
// A supporting move for a product launch or a creative brand's hero.
|
|
7
|
+
//
|
|
8
|
+
// <PixelResolve image={images.hero[0]} priority className="h-svh" />
|
|
9
|
+
//
|
|
10
|
+
// Reduced motion, phones and any failure: the plain image, immediately.
|
|
11
|
+
import Image from "next/image";
|
|
12
|
+
import { useEffect, useRef } from "react";
|
|
13
|
+
import type { StruqImage } from "../kit/images";
|
|
14
|
+
|
|
15
|
+
/** Pixel sizes in CSS pixels, coarse to fine. */
|
|
16
|
+
const STEPS = [64, 40, 24, 14, 8, 4];
|
|
17
|
+
|
|
18
|
+
type Props = {
|
|
19
|
+
image: StruqImage;
|
|
20
|
+
className?: string;
|
|
21
|
+
priority?: boolean;
|
|
22
|
+
sizes?: string;
|
|
23
|
+
/** Milliseconds each step holds. */
|
|
24
|
+
hold?: number;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export function PixelResolve({ image, className = "", priority = false, sizes = "100vw", hold = 170 }: Props) {
|
|
28
|
+
const box = useRef<HTMLDivElement>(null);
|
|
29
|
+
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
30
|
+
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
const el = box.current;
|
|
33
|
+
const canvas = canvasRef.current;
|
|
34
|
+
if (!el || !canvas) return;
|
|
35
|
+
// React owns the canvas, so it is hidden, never removed.
|
|
36
|
+
const hideCanvas = () => {
|
|
37
|
+
canvas.style.display = "none";
|
|
38
|
+
};
|
|
39
|
+
const still = window.matchMedia("(prefers-reduced-motion: reduce), (max-width: 767px)").matches;
|
|
40
|
+
if (still) {
|
|
41
|
+
hideCanvas();
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
let cancelled = false;
|
|
45
|
+
const timers: number[] = [];
|
|
46
|
+
const img = new window.Image();
|
|
47
|
+
img.decoding = "async";
|
|
48
|
+
img.src = image.src;
|
|
49
|
+
img.onerror = () => hideCanvas();
|
|
50
|
+
img.onload = () => {
|
|
51
|
+
if (cancelled) return;
|
|
52
|
+
const w = el.clientWidth;
|
|
53
|
+
const h = el.clientHeight;
|
|
54
|
+
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
|
55
|
+
canvas.width = Math.round(w * dpr);
|
|
56
|
+
canvas.height = Math.round(h * dpr);
|
|
57
|
+
const ctx = canvas.getContext("2d");
|
|
58
|
+
const small = document.createElement("canvas");
|
|
59
|
+
const sctx = small.getContext("2d");
|
|
60
|
+
if (!ctx || !sctx) return hideCanvas();
|
|
61
|
+
// object-fit: cover, by hand
|
|
62
|
+
const scale = Math.max(w / img.naturalWidth, h / img.naturalHeight);
|
|
63
|
+
const sw = w / scale;
|
|
64
|
+
const sh = h / scale;
|
|
65
|
+
const sx = (img.naturalWidth - sw) / 2;
|
|
66
|
+
const sy = (img.naturalHeight - sh) / 2;
|
|
67
|
+
const draw = (px: number) => {
|
|
68
|
+
small.width = Math.max(1, Math.round(w / px));
|
|
69
|
+
small.height = Math.max(1, Math.round(h / px));
|
|
70
|
+
sctx.drawImage(img, sx, sy, sw, sh, 0, 0, small.width, small.height);
|
|
71
|
+
ctx.imageSmoothingEnabled = false;
|
|
72
|
+
ctx.drawImage(small, 0, 0, canvas.width, canvas.height);
|
|
73
|
+
};
|
|
74
|
+
STEPS.forEach((px, i) => timers.push(window.setTimeout(() => !cancelled && draw(px), 120 + i * hold)));
|
|
75
|
+
timers.push(
|
|
76
|
+
window.setTimeout(() => {
|
|
77
|
+
if (cancelled) return;
|
|
78
|
+
canvas.style.transition = "opacity 400ms cubic-bezier(0.16, 1, 0.3, 1)";
|
|
79
|
+
canvas.style.opacity = "0";
|
|
80
|
+
timers.push(window.setTimeout(() => hideCanvas(), 450));
|
|
81
|
+
}, 120 + STEPS.length * hold),
|
|
82
|
+
);
|
|
83
|
+
draw(STEPS[0]);
|
|
84
|
+
canvas.style.opacity = "1";
|
|
85
|
+
};
|
|
86
|
+
return () => {
|
|
87
|
+
cancelled = true;
|
|
88
|
+
timers.forEach((t) => window.clearTimeout(t));
|
|
89
|
+
};
|
|
90
|
+
}, [image.src, hold]);
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<div ref={box} data-showpiece="pixel-resolve" className={`relative overflow-hidden bg-surface-2 ${className}`}>
|
|
94
|
+
<Image
|
|
95
|
+
src={image.src}
|
|
96
|
+
alt={image.alt}
|
|
97
|
+
fill
|
|
98
|
+
sizes={sizes}
|
|
99
|
+
loading={priority ? "eager" : "lazy"}
|
|
100
|
+
fetchPriority={priority ? "high" : "auto"}
|
|
101
|
+
unoptimized={image.unoptimized}
|
|
102
|
+
className="object-cover"
|
|
103
|
+
/>
|
|
104
|
+
<canvas ref={canvasRef} aria-hidden className="pointer-events-none absolute inset-0 h-full w-full opacity-0" />
|
|
105
|
+
</div>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
// toonstuk-project-stack (Struq showpiece recipe)
|
|
3
|
+
// The work, one project per screen. The stage pins, each next project slides up over the last
|
|
4
|
+
// one full bleed, while the one beneath recedes (scale and a darker veil), so the stack reads as
|
|
5
|
+
// layered rather than cut. Over the photos the cursor turns into a round "Bekijk" label
|
|
6
|
+
// (data-cursor="Bekijk"; the Cursor recipe renders it). Names are set large, the meta small.
|
|
7
|
+
// Lead for an architect or a studio portfolio.
|
|
8
|
+
//
|
|
9
|
+
// <ProjectStack items={[{ image: images.hero[0], title: "De Spinnerij", meta: "Transformatie, Charlois", href: "/werk/spinnerij" }]} />
|
|
10
|
+
//
|
|
11
|
+
// Only real projects from the brief, with the client's own photos. Beeldbank photos never stand in
|
|
12
|
+
// for a client's project: without project photos, use a different showpiece.
|
|
13
|
+
// Reduced motion and phones: the composed still, the projects one under the other.
|
|
14
|
+
import Image from "next/image";
|
|
15
|
+
import Link from "next/link";
|
|
16
|
+
import { useRef } from "react";
|
|
17
|
+
import { gsap, useGSAP } from "../kit/gsap";
|
|
18
|
+
import type { StruqImage } from "../kit/images";
|
|
19
|
+
|
|
20
|
+
type Item = { image: StruqImage; title: string; meta?: string; href?: string };
|
|
21
|
+
|
|
22
|
+
export function ProjectStack({ items, label = "Bekijk", className = "" }: { items: Item[]; label?: string; className?: string }) {
|
|
23
|
+
const stage = useRef<HTMLElement>(null);
|
|
24
|
+
|
|
25
|
+
useGSAP(
|
|
26
|
+
() => {
|
|
27
|
+
const el = stage.current;
|
|
28
|
+
if (!el) return;
|
|
29
|
+
const cards = gsap.utils.toArray<HTMLElement>("[data-card]", el);
|
|
30
|
+
if (cards.length < 2) return;
|
|
31
|
+
const mm = gsap.matchMedia();
|
|
32
|
+
mm.add("(min-width: 768px) and (prefers-reduced-motion: no-preference)", () => {
|
|
33
|
+
gsap.set(cards.slice(1), { yPercent: 100 });
|
|
34
|
+
const tl = gsap.timeline({
|
|
35
|
+
defaults: { ease: "none" },
|
|
36
|
+
scrollTrigger: { trigger: el, start: "top top", end: () => `+=${(cards.length - 1) * 110}%`, pin: true, scrub: 1 },
|
|
37
|
+
});
|
|
38
|
+
cards.slice(1).forEach((card, i) => {
|
|
39
|
+
const below = cards[i];
|
|
40
|
+
tl.to({}, { duration: 0.25 }) // hold on each project
|
|
41
|
+
.to(card, { yPercent: 0, duration: 1, ease: "power2.inOut" })
|
|
42
|
+
.to(below, { scale: 0.9, duration: 1, ease: "power2.inOut" }, "<")
|
|
43
|
+
.to(below.querySelector("[data-veil]"), { opacity: 0.55, duration: 1 }, "<")
|
|
44
|
+
.fromTo(card.querySelector("[data-card-photo]"), { scale: 1.2 }, { scale: 1, duration: 1.2, ease: "power2.out" }, "<");
|
|
45
|
+
});
|
|
46
|
+
tl.to({}, { duration: 0.25 });
|
|
47
|
+
});
|
|
48
|
+
return () => mm.revert();
|
|
49
|
+
},
|
|
50
|
+
{ scope: stage },
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<section
|
|
55
|
+
ref={stage}
|
|
56
|
+
data-showpiece="project-stack"
|
|
57
|
+
className={`relative flex flex-col gap-6 bg-ink px-6 py-24 motion-safe:md:block motion-safe:md:h-svh motion-safe:md:overflow-hidden motion-safe:md:p-0 ${className}`}
|
|
58
|
+
>
|
|
59
|
+
{items.map((item, i) => {
|
|
60
|
+
const body = (
|
|
61
|
+
<>
|
|
62
|
+
<div data-card-photo="" className="absolute inset-0">
|
|
63
|
+
<Image src={item.image.src} alt={item.image.alt} fill sizes="100vw" unoptimized={item.image.unoptimized} className="object-cover" />
|
|
64
|
+
</div>
|
|
65
|
+
<div aria-hidden className="absolute inset-0 bg-black opacity-25" />
|
|
66
|
+
<div data-veil="" aria-hidden className="absolute inset-0 bg-black opacity-0" />
|
|
67
|
+
<div className="absolute inset-x-6 bottom-8 flex flex-col gap-2 text-on-photo md:inset-x-[5vw] md:bottom-[8vh] md:flex-row md:items-end md:justify-between">
|
|
68
|
+
<span className="font-display block text-[12vw] leading-[0.9] tracking-[-0.03em] md:text-[9vw]">{item.title}</span>
|
|
69
|
+
{item.meta ? <span className="text-small md:pb-[1.2vw]">{item.meta}</span> : null}
|
|
70
|
+
</div>
|
|
71
|
+
</>
|
|
72
|
+
);
|
|
73
|
+
const cls = "relative block aspect-[4/5] overflow-hidden motion-safe:md:absolute motion-safe:md:inset-0 motion-safe:md:aspect-auto";
|
|
74
|
+
return item.href ? (
|
|
75
|
+
<Link key={item.title} href={item.href} data-card="" data-cursor={label} className={cls} style={{ zIndex: i + 1 }}>
|
|
76
|
+
{body}
|
|
77
|
+
</Link>
|
|
78
|
+
) : (
|
|
79
|
+
<div key={item.title} data-card="" data-cursor={label} className={cls} style={{ zIndex: i + 1 }}>
|
|
80
|
+
{body}
|
|
81
|
+
</div>
|
|
82
|
+
);
|
|
83
|
+
})}
|
|
84
|
+
</section>
|
|
85
|
+
);
|
|
86
|
+
}
|