Vastgezet podium waarin een video of foto door een vorm heen zichtbaar is; terwijl je scrolt groeit de vorm tot hij het scherm vult en het beeld zoomt licht in. Hoge sectie met sticky podium — geen pin-spacer, geen GSAP. De vorm is een SVG-data-URI van het project.
Scroll in dit paneel ↓
…en verder.
Een vorm (hier de standaarddruppel) groeit met de scroll open tot het beeld het hele podium vult; het beeld zoomt licht in.
blocks/scroll/scroll-mask-reveal.tsx
/**
* ScrollMaskReveal — een vastgezet podium waarin het beeld (video of foto)
* door een vorm heen zichtbaar is; terwijl je scrolt groeit die vorm tot hij
* het hele scherm vult en het beeld zelf licht inzoomt. De sectie is hoog,
* het podium plakt (sticky) — geen pin-spacer, geen GSAP.
*
* Scrub: de scrollpositie is de klok (REGISTRY_PLAN §2.2). De maskgrootte
* volgt een macht van de voortgang (langzaam beginnen, dan openbarsten), net
* als de bron; de vorm is een SVG als data-URI en komt van het project — de
* standaard is een zachte druppel, geen merkvorm.
*
* Onder prefers-reduced-motion staat het masker vanaf het begin open: het
* beeld is inhoud, het openen een effect.
*/
"use client";
import { useLayoutEffect, useRef, useState } from "react";
import type * as React from "react";
import { motion, useReducedMotion, useScroll, useTransform, type UseScrollOptions } from "framer-motion";
export interface ScrollMaskRevealProps extends Omit<React.HTMLAttributes<HTMLElement>, "children"> {
/** Het beeld dat door de vorm zichtbaar wordt: een <video> of <img> die zijn ouder vult. */
children: React.ReactNode;
/** SVG als data-URI (of elke CSS-url) voor mask-image; zonder: een zachte druppel. */
mask?: string;
/** Beginmaat van de vorm in px; zonder: 30% van de podiumbreedte (260–420). */
startSize?: number;
/** Hoeveel px de vorm er tijdens de scroll bij krijgt. */
growBy?: number;
/** Hoeveel het beeld inzoomt aan het eind (1 = niet). */
zoom?: number;
/** Hoogte van de scrollsectie als CSS-lengte — hoe langer, hoe trager het openen. */
scrollLength?: string;
/** Hoogte van het plakkende podium; standaard de viewport (100dvh). */
stageHeight?: string;
/** Het scrollende element als de sectie niet in de pagina scrolt (moet position: relative hebben). */
container?: UseScrollOptions["container"];
/** Kleine hoekmarkeringen in de hoeken van het podium. */
corners?: boolean;
/** Watermerk-tekst midden op het podium, heel licht (bv. de merknaam). */
watermark?: string;
}
const STANDAARD_VORM = `data:image/svg+xml;utf8,${encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><path d="M50 4c22 0 40 20 40 44 0 26-18 48-40 48S10 74 10 48C10 24 28 4 50 4z"/></svg>',
)}`;
export function ScrollMaskReveal({
children,
mask = STANDAARD_VORM,
startSize,
growBy = 4500,
zoom = 1.22,
scrollLength = "360vh",
stageHeight,
container,
corners = true,
watermark,
className,
style,
...rest
}: ScrollMaskRevealProps) {
const sectie = useRef<HTMLElement>(null);
const podium = useRef<HTMLDivElement>(null);
const reduced = useReducedMotion() ?? false;
const [podiumBreedte, setPodiumBreedte] = useState(1200);
useLayoutEffect(() => {
const el = podium.current;
if (!el) return;
const meet = () => setPodiumBreedte(el.clientWidth);
meet();
const ro = new ResizeObserver(meet);
ro.observe(el);
return () => ro.disconnect();
}, []);
const begin = startSize ?? Math.min(420, Math.max(260, podiumBreedte * 0.3));
const { scrollYProgress } = useScroll({ target: sectie, offset: ["start start", "end end"], container });
// Traag beginnen, dan openbarsten: macht 2.3 op de voortgang, uit de bron.
const maskMaat = useTransform(scrollYProgress, (p) => (reduced ? `${begin + growBy}px` : `${begin + Math.pow(p, 2.3) * growBy}px`));
const scale = useTransform(scrollYProgress, [0, 1], [1, reduced ? 1 : zoom]);
return (
<section
{...rest}
ref={sectie}
className={["fp-maskreveal", className].filter(Boolean).join(" ")}
style={{ ...style, ["--fp-maskreveal-lengte" as string]: scrollLength, ...(stageHeight ? { ["--fp-maskreveal-podium" as string]: stageHeight } : {}) } as React.CSSProperties}
>
<div ref={podium} className="fp-maskreveal__podium">
{corners && (
<>
<span className="fp-maskreveal__hoek fp-maskreveal__hoek--lb" aria-hidden="true" />
<span className="fp-maskreveal__hoek fp-maskreveal__hoek--rb" aria-hidden="true" />
<span className="fp-maskreveal__hoek fp-maskreveal__hoek--lo" aria-hidden="true" />
<span className="fp-maskreveal__hoek fp-maskreveal__hoek--ro" aria-hidden="true" />
</>
)}
{watermark && (
<span className="fp-maskreveal__watermerk" aria-hidden="true">
{watermark}
</span>
)}
<motion.div
className="fp-maskreveal__masker"
style={{ maskImage: `url("${mask}")`, WebkitMaskImage: `url("${mask}")`, maskSize: maskMaat, WebkitMaskSize: maskMaat }}
>
<motion.div className="fp-maskreveal__beeld" style={{ scale }}>
{children}
</motion.div>
</motion.div>
</div>
</section>
);
}
blocks/scroll/scroll.css
/**
* Scroll-blocks: scroll-mask-reveal.
* Importeren in de app-root (de registry-site doet dat in app/globals.css).
*/
/* ── ScrollMaskReveal ──────────────────────────────────────────────────── */
/* De hoge sectie: het podium plakt erin en laat los als de sectie voorbij is. */
.fp-maskreveal {
position: relative; width: 100%;
min-height: var(--fp-maskreveal-lengte, 360vh);
background: var(--background); color: var(--foreground);
}
.fp-maskreveal__podium {
position: sticky; top: 0;
display: flex; align-items: center; justify-content: center;
width: 100%; height: var(--fp-maskreveal-podium, 100dvh); overflow: hidden;
user-select: none;
}
/* Hoekmarkeringen: twee randen van een vierkantje, in de tekstkleur, difference-blend zodat ze op elk beeld leesbaar blijven. */
.fp-maskreveal__hoek {
position: absolute; z-index: 3; width: 1rem; height: 1rem; pointer-events: none;
border-style: solid; border-color: currentColor; border-width: 0;
mix-blend-mode: difference;
}
.fp-maskreveal__hoek--lb { top: 10px; left: 10px; border-top-width: 1px; border-left-width: 1px; }
.fp-maskreveal__hoek--rb { top: 10px; right: 10px; border-top-width: 1px; border-right-width: 1px; }
.fp-maskreveal__hoek--lo { bottom: 10px; left: 10px; border-bottom-width: 1px; border-left-width: 1px; }
.fp-maskreveal__hoek--ro { bottom: 10px; right: 10px; border-bottom-width: 1px; border-right-width: 1px; }
.fp-maskreveal__watermerk {
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
font: inherit; font-weight: 800; font-size: 20vw; line-height: 1; letter-spacing: -0.04em; text-transform: uppercase;
opacity: 0.03; pointer-events: none;
}
/* Het masker: gecentreerde vorm, de maat komt per frame van de scroll (mask-size is verf, geen layout). */
.fp-maskreveal__masker {
position: absolute; inset: 0; z-index: 2;
-webkit-mask-repeat: no-repeat; mask-repeat: no-repeat;
-webkit-mask-position: 50% 50%; mask-position: 50% 50%;
}
.fp-maskreveal__beeld { position: absolute; inset: 0; transform-origin: 50% 50%; will-change: transform; }
.fp-maskreveal__beeld > * { display: block; width: 100%; height: 100%; object-fit: cover; }