@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,96 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
// components/struq/MediaFrame.tsx (Struq recipe)
|
|
3
|
+
// The only way an image goes on the page. It always holds a real image from lib/struq-images.ts
|
|
4
|
+
// (written by scripts/images.mjs), so a frame can never ship empty.
|
|
5
|
+
//
|
|
6
|
+
// - reveal "scroll": the frame opens bottom-up like a curtain as it enters, the photo settles from 1.3x.
|
|
7
|
+
// - reveal "load": the same curtain on mount, for the hero.
|
|
8
|
+
// - parallax: the photo pans inside the frame while the frame scrolls past (scrubbed).
|
|
9
|
+
//
|
|
10
|
+
// <MediaFrame image={images.hero[0]} ratio="16/9" priority sizes="100vw" reveal="load" />
|
|
11
|
+
// <MediaFrame image={images.card[2]} ratio="3/2" sizes="(min-width: 768px) 40vw, 100vw" />
|
|
12
|
+
import Image from "next/image";
|
|
13
|
+
import { useRef } from "react";
|
|
14
|
+
import { gsap, useGSAP } from "./gsap";
|
|
15
|
+
import type { StruqImage } from "./images";
|
|
16
|
+
|
|
17
|
+
type Props = {
|
|
18
|
+
image: StruqImage;
|
|
19
|
+
/** CSS aspect ratio, for example "16/9", "3/2", "4/5", "1/1", "21/9". Defaults to the file's own. */
|
|
20
|
+
ratio?: string;
|
|
21
|
+
className?: string;
|
|
22
|
+
sizes?: string;
|
|
23
|
+
/** The page's one LCP image (the hero). Exactly one per page. */
|
|
24
|
+
priority?: boolean;
|
|
25
|
+
reveal?: "scroll" | "load" | "none";
|
|
26
|
+
parallax?: boolean;
|
|
27
|
+
/** Override the alt, for example a translation on a non-Dutch site. "" marks it decorative. */
|
|
28
|
+
alt?: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export function MediaFrame({
|
|
32
|
+
image,
|
|
33
|
+
ratio,
|
|
34
|
+
className = "",
|
|
35
|
+
sizes = "100vw",
|
|
36
|
+
priority = false,
|
|
37
|
+
reveal = "scroll",
|
|
38
|
+
parallax = true,
|
|
39
|
+
alt,
|
|
40
|
+
}: Props) {
|
|
41
|
+
const frame = useRef<HTMLElement>(null);
|
|
42
|
+
const inner = useRef<HTMLDivElement>(null);
|
|
43
|
+
|
|
44
|
+
useGSAP(
|
|
45
|
+
() => {
|
|
46
|
+
const el = frame.current;
|
|
47
|
+
const photo = inner.current;
|
|
48
|
+
if (!el || !photo) return;
|
|
49
|
+
const mm = gsap.matchMedia();
|
|
50
|
+
mm.add("(prefers-reduced-motion: no-preference)", () => {
|
|
51
|
+
if (reveal !== "none") {
|
|
52
|
+
const trigger = reveal === "scroll" ? { trigger: el, start: "top 88%", once: true } : undefined;
|
|
53
|
+
gsap.fromTo(
|
|
54
|
+
el,
|
|
55
|
+
{ clipPath: "inset(100% 0% 0% 0%)" },
|
|
56
|
+
{ clipPath: "inset(0% 0% 0% 0%)", duration: 1.4, ease: "power4.inOut", delay: reveal === "load" ? 0.2 : 0, scrollTrigger: trigger },
|
|
57
|
+
);
|
|
58
|
+
gsap.fromTo(photo, { scale: 1.3 }, { scale: parallax ? 1.12 : 1, duration: 1.8, ease: "expo.out", delay: reveal === "load" ? 0.2 : 0, scrollTrigger: trigger });
|
|
59
|
+
}
|
|
60
|
+
if (parallax) {
|
|
61
|
+
gsap.fromTo(
|
|
62
|
+
photo,
|
|
63
|
+
{ yPercent: -5 },
|
|
64
|
+
{ yPercent: 5, ease: "none", scrollTrigger: { trigger: el, start: "top bottom", end: "bottom top", scrub: true } },
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
el.dataset.ready = "1";
|
|
69
|
+
return () => mm.revert();
|
|
70
|
+
},
|
|
71
|
+
{ scope: frame },
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<figure
|
|
76
|
+
ref={frame}
|
|
77
|
+
data-frame={reveal === "none" ? "still" : "mask"}
|
|
78
|
+
className={`relative overflow-hidden bg-surface-2 ${className}`}
|
|
79
|
+
style={{ aspectRatio: ratio ?? `${image.width}/${image.height}` }}
|
|
80
|
+
>
|
|
81
|
+
<div ref={inner} className="absolute inset-0" style={{ scale: parallax ? "1.12" : undefined }}>
|
|
82
|
+
<Image
|
|
83
|
+
src={image.src}
|
|
84
|
+
alt={alt ?? image.alt}
|
|
85
|
+
fill
|
|
86
|
+
sizes={sizes}
|
|
87
|
+
// eager + high fetch priority works on every Next.js major ("priority" is deprecated in 16).
|
|
88
|
+
loading={priority ? "eager" : "lazy"}
|
|
89
|
+
fetchPriority={priority ? "high" : "auto"}
|
|
90
|
+
unoptimized={image.unoptimized}
|
|
91
|
+
className="object-cover"
|
|
92
|
+
/>
|
|
93
|
+
</div>
|
|
94
|
+
</figure>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
// components/struq/PinnedZoom.tsx (Struq recipe): SHOWPIECE
|
|
3
|
+
// The stage pins; a small framed photo opens to full bleed while the statement above it lifts
|
|
4
|
+
// away and a closing line arrives over the photo. One scrubbed timeline, so it plays with the
|
|
5
|
+
// scroll in both directions. Under reduced motion it is a still full-bleed photo with the text.
|
|
6
|
+
//
|
|
7
|
+
// <PinnedZoom
|
|
8
|
+
// image={images.band[0]}
|
|
9
|
+
// statement="Veertig plekken, één kaart."
|
|
10
|
+
// closing="Reserveer voor vanavond, of voor zaterdag."
|
|
11
|
+
// />
|
|
12
|
+
import Image from "next/image";
|
|
13
|
+
import { useRef } from "react";
|
|
14
|
+
import { gsap, useGSAP } from "./gsap";
|
|
15
|
+
import type { StruqImage } from "./images";
|
|
16
|
+
|
|
17
|
+
type Props = {
|
|
18
|
+
image: StruqImage;
|
|
19
|
+
statement: string;
|
|
20
|
+
closing?: string;
|
|
21
|
+
className?: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export function PinnedZoom({ image, statement, closing, className = "" }: Props) {
|
|
25
|
+
const stage = useRef<HTMLElement>(null);
|
|
26
|
+
|
|
27
|
+
useGSAP(
|
|
28
|
+
() => {
|
|
29
|
+
const el = stage.current;
|
|
30
|
+
if (!el) return;
|
|
31
|
+
const q = gsap.utils.selector(el);
|
|
32
|
+
const mm = gsap.matchMedia();
|
|
33
|
+
mm.add("(prefers-reduced-motion: no-preference)", () => {
|
|
34
|
+
// Two numbers drive the clip, never a tweened string: the browser shortens a symmetric inset to two
|
|
35
|
+
// values, GSAP pairs them with four end values, and two edges jump open in one frame.
|
|
36
|
+
const frame = q("[data-zoom-frame]")[0] as HTMLElement;
|
|
37
|
+
const clip = { y: 22, x: 30 };
|
|
38
|
+
const paint = () => {
|
|
39
|
+
frame.style.clipPath = `inset(${clip.y}% ${clip.x}% ${clip.y}% ${clip.x}%)`;
|
|
40
|
+
};
|
|
41
|
+
paint();
|
|
42
|
+
gsap.set(q("[data-zoom-closing]"), { autoAlpha: 0, yPercent: 40 });
|
|
43
|
+
const tl = gsap.timeline({
|
|
44
|
+
defaults: { ease: "none" },
|
|
45
|
+
scrollTrigger: { trigger: el, start: "top top", end: "+=160%", pin: true, scrub: 1 },
|
|
46
|
+
});
|
|
47
|
+
tl.to(clip, { y: 0, x: 0, duration: 1, onUpdate: paint }, 0)
|
|
48
|
+
.fromTo(q("[data-zoom-photo]"), { scale: 1.35 }, { scale: 1, duration: 1 }, 0)
|
|
49
|
+
.to(q("[data-zoom-statement]"), { yPercent: -60, autoAlpha: 0, duration: 0.45 }, 0)
|
|
50
|
+
.to(q("[data-zoom-closing]"), { yPercent: 0, autoAlpha: 1, duration: 0.35 }, 0.62);
|
|
51
|
+
return () => {
|
|
52
|
+
frame.style.clipPath = "";
|
|
53
|
+
};
|
|
54
|
+
});
|
|
55
|
+
// Still version: the photo is full bleed from the start, so the statement turns light.
|
|
56
|
+
mm.add("(prefers-reduced-motion: reduce)", () => {
|
|
57
|
+
gsap.set(q("[data-zoom-statement]"), { color: "var(--on-photo)" });
|
|
58
|
+
});
|
|
59
|
+
return () => mm.revert();
|
|
60
|
+
},
|
|
61
|
+
{ scope: stage },
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<section ref={stage} className={`relative h-svh overflow-hidden bg-surface-0 ${className}`}>
|
|
66
|
+
<div data-zoom-frame="" className="absolute inset-0 overflow-hidden">
|
|
67
|
+
<div data-zoom-photo="" className="absolute inset-0">
|
|
68
|
+
<Image src={image.src} alt={image.alt} fill sizes="100vw" unoptimized={image.unoptimized} className="object-cover" />
|
|
69
|
+
</div>
|
|
70
|
+
{/* A flat black scrim, so the closing line reads on any photo, on light and dark palettes. */}
|
|
71
|
+
<div aria-hidden className="absolute inset-0 bg-black opacity-35" />
|
|
72
|
+
</div>
|
|
73
|
+
<p
|
|
74
|
+
data-zoom-statement=""
|
|
75
|
+
className="font-display text-h2 absolute inset-x-6 top-[9vh] mx-auto max-w-[22ch] text-center text-ink md:inset-x-[8vw]"
|
|
76
|
+
>
|
|
77
|
+
{statement}
|
|
78
|
+
</p>
|
|
79
|
+
{closing ? (
|
|
80
|
+
<p
|
|
81
|
+
data-zoom-closing=""
|
|
82
|
+
className="font-display text-h2 absolute inset-x-6 bottom-[12vh] max-w-[20ch] text-on-photo md:left-[8vw]"
|
|
83
|
+
>
|
|
84
|
+
{closing}
|
|
85
|
+
</p>
|
|
86
|
+
) : null}
|
|
87
|
+
</section>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
// components/struq/Reveal.tsx (Struq recipe)
|
|
3
|
+
// Staggered rise for a group of text blocks: every direct child rises 48px from a fade as the
|
|
4
|
+
// group scrolls into view. Opacity AND transform, never opacity alone, expo.out, fired once.
|
|
5
|
+
//
|
|
6
|
+
// <Reveal className="grid gap-6">
|
|
7
|
+
// <h2 className="text-h2">...</h2>
|
|
8
|
+
// <p className="text-lead">...</p>
|
|
9
|
+
// </Reveal>
|
|
10
|
+
//
|
|
11
|
+
// Before hydration the children are hidden only when JavaScript runs (the `js` class from the
|
|
12
|
+
// layout), and a CSS failsafe shows them after 3s if hydration never comes (globals.css).
|
|
13
|
+
import { useRef } from "react";
|
|
14
|
+
import { gsap, useGSAP } from "./gsap";
|
|
15
|
+
|
|
16
|
+
type Props = {
|
|
17
|
+
children: React.ReactNode;
|
|
18
|
+
className?: string;
|
|
19
|
+
as?: "div" | "section" | "header" | "footer" | "ul" | "ol";
|
|
20
|
+
/** Distance each child rises, in px. 24 to 80. */
|
|
21
|
+
y?: number;
|
|
22
|
+
stagger?: number;
|
|
23
|
+
delay?: number;
|
|
24
|
+
/** "scroll" waits for the viewport; "load" plays on mount (for the hero). */
|
|
25
|
+
on?: "scroll" | "load";
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export function Reveal({ children, className = "", as = "div", y = 48, stagger = 0.08, delay = 0, on = "scroll" }: Props) {
|
|
29
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
30
|
+
|
|
31
|
+
useGSAP(
|
|
32
|
+
() => {
|
|
33
|
+
const el = ref.current;
|
|
34
|
+
if (!el) return;
|
|
35
|
+
const items = Array.from(el.children);
|
|
36
|
+
const mm = gsap.matchMedia();
|
|
37
|
+
mm.add(
|
|
38
|
+
{ motion: "(prefers-reduced-motion: no-preference)", reduce: "(prefers-reduced-motion: reduce)" },
|
|
39
|
+
(ctx) => {
|
|
40
|
+
const reduce = ctx.conditions?.reduce;
|
|
41
|
+
gsap.from(items, {
|
|
42
|
+
y: reduce ? 0 : y,
|
|
43
|
+
autoAlpha: 0,
|
|
44
|
+
duration: reduce ? 0.5 : 0.9,
|
|
45
|
+
ease: "expo.out",
|
|
46
|
+
stagger,
|
|
47
|
+
delay,
|
|
48
|
+
scrollTrigger: on === "scroll" ? { trigger: el, start: "top 85%", once: true } : undefined,
|
|
49
|
+
});
|
|
50
|
+
},
|
|
51
|
+
);
|
|
52
|
+
el.dataset.ready = "1";
|
|
53
|
+
return () => mm.revert();
|
|
54
|
+
},
|
|
55
|
+
{ scope: ref },
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
const Tag = as as "div";
|
|
59
|
+
return (
|
|
60
|
+
<Tag ref={ref} data-reveal="" className={className}>
|
|
61
|
+
{children}
|
|
62
|
+
</Tag>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
// components/struq/SmoothScroll.tsx (Struq recipe)
|
|
3
|
+
// Lenis smooth scroll on the GSAP ticker: one requestAnimationFrame for the whole page.
|
|
4
|
+
// Mount it once, in app/layout.tsx, around {children}.
|
|
5
|
+
//
|
|
6
|
+
// Why it is built this way:
|
|
7
|
+
// - autoRaf false plus gsap.ticker: Lenis and ScrollTrigger sample scroll on the same frame,
|
|
8
|
+
// so scrubbed timelines never micro-stutter.
|
|
9
|
+
// - lagSmoothing(0): GSAP never "catches up" after a long frame, which would jump scrubs.
|
|
10
|
+
// - Lenis is created inside the effect, so there is no ref race that can leave the wheel dead.
|
|
11
|
+
// - Reduced motion: no Lenis, native scroll. ScrollTrigger keeps working on native scroll.
|
|
12
|
+
import Lenis from "lenis";
|
|
13
|
+
import "lenis/dist/lenis.css";
|
|
14
|
+
import { useEffect } from "react";
|
|
15
|
+
import { gsap, ScrollTrigger } from "./gsap";
|
|
16
|
+
|
|
17
|
+
let current: Lenis | null = null;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The page's Lenis instance, or null under reduced motion and before mount.
|
|
21
|
+
* An overlay menu calls getLenis()?.stop() when it opens and getLenis()?.start() on EVERY close path.
|
|
22
|
+
*/
|
|
23
|
+
export function getLenis() {
|
|
24
|
+
return current;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function SmoothScroll({ children }: { children: React.ReactNode }) {
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
ScrollTrigger.config({ ignoreMobileResize: true });
|
|
30
|
+
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
31
|
+
|
|
32
|
+
// Fonts change line breaks and section heights: re-measure every trigger once they land.
|
|
33
|
+
const refresh = () => ScrollTrigger.refresh();
|
|
34
|
+
document.fonts?.ready.then(refresh);
|
|
35
|
+
const late = window.setTimeout(refresh, 600);
|
|
36
|
+
|
|
37
|
+
if (reduce) {
|
|
38
|
+
return () => window.clearTimeout(late);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const lenis = new Lenis({ lerp: 0.1, autoRaf: false, anchors: true });
|
|
42
|
+
const raf = (time: number) => lenis.raf(time * 1000); // GSAP gives seconds, Lenis wants ms
|
|
43
|
+
lenis.on("scroll", ScrollTrigger.update);
|
|
44
|
+
gsap.ticker.add(raf);
|
|
45
|
+
gsap.ticker.lagSmoothing(0);
|
|
46
|
+
current = lenis;
|
|
47
|
+
|
|
48
|
+
return () => {
|
|
49
|
+
window.clearTimeout(late);
|
|
50
|
+
gsap.ticker.remove(raf);
|
|
51
|
+
lenis.destroy();
|
|
52
|
+
current = null;
|
|
53
|
+
};
|
|
54
|
+
}, []);
|
|
55
|
+
|
|
56
|
+
return <>{children}</>;
|
|
57
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
// components/struq/SplitHeadline.tsx (Struq recipe)
|
|
3
|
+
// The hero headline: SplitText cuts it into lines, each line rises out of its own mask.
|
|
4
|
+
// autoSplit re-cuts it when the width or the webfont changes, so mobile line breaks stay right.
|
|
5
|
+
//
|
|
6
|
+
// <SplitHeadline as="h1" className="font-display text-display max-w-[14ch]">
|
|
7
|
+
// Vis en groente, van het seizoen.
|
|
8
|
+
// </SplitHeadline>
|
|
9
|
+
//
|
|
10
|
+
// Pass plain text (or text with <em>): SplitText needs text nodes, not components.
|
|
11
|
+
import { useRef } from "react";
|
|
12
|
+
import { gsap, SplitText, useGSAP } from "./gsap";
|
|
13
|
+
|
|
14
|
+
type Props = {
|
|
15
|
+
children: React.ReactNode;
|
|
16
|
+
as?: "h1" | "h2" | "h3" | "p";
|
|
17
|
+
className?: string;
|
|
18
|
+
/** "load" for the hero, "scroll" for a statement further down. */
|
|
19
|
+
on?: "load" | "scroll";
|
|
20
|
+
delay?: number;
|
|
21
|
+
/** Mark it as copy the hero canvas hushes behind. On by default for the h1. */
|
|
22
|
+
quiet?: boolean;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export function SplitHeadline({ children, as = "h1", className = "", on = "load", delay = 0.15, quiet = as === "h1" }: Props) {
|
|
26
|
+
const ref = useRef<HTMLHeadingElement>(null);
|
|
27
|
+
|
|
28
|
+
useGSAP(
|
|
29
|
+
() => {
|
|
30
|
+
const el = ref.current;
|
|
31
|
+
if (!el) return;
|
|
32
|
+
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
33
|
+
|
|
34
|
+
if (reduce) {
|
|
35
|
+
el.dataset.ready = "1";
|
|
36
|
+
gsap.from(el, { autoAlpha: 0, duration: 0.6 });
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const split = SplitText.create(el, {
|
|
41
|
+
type: "lines",
|
|
42
|
+
mask: "lines",
|
|
43
|
+
linesClass: "struq-line",
|
|
44
|
+
autoSplit: true,
|
|
45
|
+
onSplit(self) {
|
|
46
|
+
return gsap.from(self.lines, {
|
|
47
|
+
yPercent: 115,
|
|
48
|
+
duration: 1.25,
|
|
49
|
+
ease: "expo.out",
|
|
50
|
+
stagger: 0.09,
|
|
51
|
+
delay,
|
|
52
|
+
scrollTrigger: on === "scroll" ? { trigger: el, start: "top 80%", once: true } : undefined,
|
|
53
|
+
});
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
el.dataset.ready = "1";
|
|
57
|
+
return () => split.revert();
|
|
58
|
+
},
|
|
59
|
+
{ scope: ref },
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const Tag = as as "h1";
|
|
63
|
+
return (
|
|
64
|
+
<Tag ref={ref} data-split="" data-quiet={quiet ? "" : undefined} className={className}>
|
|
65
|
+
{children}
|
|
66
|
+
</Tag>
|
|
67
|
+
);
|
|
68
|
+
}
|