← alle entries

scroll-mask-reveal

FC-0068Media & galerijscroll-mask-revealblockv1.0.0

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.

zelfde component — andere tokens
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 open­barsten), 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; }