dither-hero
FC-0063heroblockv1.0.0Grote afgeronde kaart als oproep: label met pulserende stip, kop, toelichting en actie; op de achtergrond een gerasterde vlek in de merkkleur die traag drijft en versnelt onder de muis. Zonder shader: SVG-stippenraster + drijvend mask.
Nieuw: onderhoud inbegrepenJouw woorden,
Jouw woorden,
op hun best.
Sluit je aan bij honderden ondernemers die hun site door ons laten bouwen én bijhouden. Rustig, precies, en helemaal van jou.
Kom op de kaart: de gerasterde vlek versnelt.
blocks/hero/dither-hero.tsx
/**
* DitherHero — een grote afgeronde kaart als oproep: label met een pulserende
* stip, kop, toelichting en een actie; op de achtergrond een gerasterde
* (dithered) vlek in de merkkleur die traag drijft en sneller gaat als je
* erop komt.
*
* De bron rekende de vlek met een WebGL-shader (@paper-design/shaders-react,
* 4x4-dithering, 'warp'). Die dependency is vervallen: hier is het raster
* een SVG-stippenpatroon in --primary, gemaskerd door een radiaal verloop
* waarvan het middelpunt met Framer drijft (twee MotionValues → één
* mask-image-string). Geen canvas, geen shader; wel dezelfde indruk.
* Reduced motion: de vlek staat stil.
*/
"use client";
import { useEffect, useState } from "react";
import type * as React from "react";
import { animate, motion, useMotionValue, useReducedMotion, useTransform } from "framer-motion";
import { useSpringTransition } from "../../primitives/motion";
export interface DitherHeroProps extends Omit<React.HTMLAttributes<HTMLElement>, "title"> {
eyebrow?: React.ReactNode;
title: React.ReactNode;
description?: React.ReactNode;
/** De actie — het project levert de knop. */
action?: React.ReactNode;
/** Seconden per drijfronde in rust; hover verdrievoudigt het tempo. */
drift?: number;
/** Minimale hoogte, structureel (default 37.5rem). */
minHeight?: string;
}
/* 4x4-raster: één stip per cel, als data-URI zonder kleur (currentColor via mask). */
const RASTER = "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='4' height='4'%3E%3Ccircle cx='2' cy='2' r='1'/%3E%3C/svg%3E\")";
export function DitherHero({ eyebrow, title, description, action, drift = 14, minHeight = "37.5rem", className, style, ...rest }: DitherHeroProps) {
const reduced = useReducedMotion() ?? false;
const [hover, setHover] = useState(false);
const pop = useSpringTransition("pop", { mass: "content" });
const t = useMotionValue(0);
/* De vlek loopt een lissajous-baan; hover = drie keer zo snel, zonder sprong. */
const seconden = hover ? drift / 3 : drift;
useEffect(() => {
if (reduced) return;
const start = t.get() % 1;
const ctrl = animate(t, [start, start + 1], { duration: seconden, ease: "linear", repeat: Infinity, repeatType: "loop" });
return () => ctrl.stop();
}, [seconden, reduced, t]);
const masker = useTransform(t, (v) => {
const x = 50 + 28 * Math.sin(v * Math.PI * 2);
const y = 50 + 22 * Math.cos(v * Math.PI * 2 * 1.3);
return `radial-gradient(60% 55% at ${x.toFixed(1)}% ${y.toFixed(1)}%, currentColor 0%, transparent 70%)`;
});
return (
<section
{...rest}
className={["fp-dither", className].filter(Boolean).join(" ")}
style={{ ...style, minHeight }}
onPointerEnter={(e) => { setHover(true); rest.onPointerEnter?.(e); }}
onPointerLeave={(e) => { setHover(false); rest.onPointerLeave?.(e); }}
>
<motion.span aria-hidden="true" className="fp-dither__vlek" style={{ maskImage: masker, WebkitMaskImage: masker, backgroundImage: RASTER }} />
<div className="fp-dither__inhoud">
{eyebrow && (
<span className="fp-dither__label">
<span className="fp-dither__stip-vak" aria-hidden="true">
<motion.span className="fp-dither__ping" animate={reduced ? { opacity: 0 } : { scale: [1, 2.2], opacity: [0.75, 0] }} transition={reduced ? { duration: 0 } : { duration: 1.4, repeat: Infinity, ease: "easeOut" }} />
<span className="fp-dither__stip" />
</span>
{eyebrow}
</span>
)}
<h2 className="fp-dither__titel">{title}</h2>
{description && <p className="fp-dither__tekst">{description}</p>}
{action && <motion.div className="fp-dither__actie" whileHover={reduced ? undefined : { scale: 1.04 }} transition={pop}>{action}</motion.div>}
</div>
</section>
);
}
blocks/hero/hero.css
/**
* Hero-blocks: dither-hero, spotlight-hero, hero-carousel, coverflow-carousel.
* Importeren in de app-root (de registry-site doet dat in app/globals.css).
*/
/* ── DitherHero ─────────────────────────────────────────────────────────── */
.fp-dither {
position: relative; overflow: hidden;
display: flex; flex-direction: column; align-items: center; justify-content: center;
width: 100%; padding: 3rem 1.5rem;
background: var(--card); color: var(--card-foreground, var(--foreground));
border: 1px solid var(--border); border-radius: calc(var(--radius) * 6);
}
/* Het raster: stippen in de merkkleur, alleen zichtbaar binnen de drijvende vlek (mask). */
.fp-dither__vlek {
position: absolute; inset: 0; pointer-events: none;
color: var(--primary); background-color: var(--primary);
background-size: 4px 4px; background-blend-mode: normal;
opacity: 0.4;
-webkit-mask-repeat: no-repeat; mask-repeat: no-repeat;
}
.fp-dither__inhoud { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; max-width: 56rem; text-align: center; }
.fp-dither__label {
display: inline-flex; align-items: center; gap: 0.5rem; margin-bottom: 2rem;
padding: 0.375rem 1rem; font-size: 0.875rem; font-weight: 500; color: var(--primary);
background: color-mix(in srgb, var(--primary) 5%, transparent); border: 1px solid color-mix(in srgb, var(--primary) 10%, transparent); border-radius: 999px;
-webkit-backdrop-filter: blur(4px); backdrop-filter: blur(4px);
}
.fp-dither__stip-vak { position: relative; display: inline-flex; width: 0.5rem; height: 0.5rem; }
.fp-dither__ping, .fp-dither__stip { position: absolute; inset: 0; border-radius: 50%; background: var(--primary); }
.fp-dither__titel { margin: 0 0 2rem; font: inherit; font-weight: 500; font-size: clamp(2.5rem, 7vw, 5.5rem); line-height: 1.05; letter-spacing: -0.02em; }
.fp-dither__tekst { margin: 0 0 3rem; max-width: 42rem; font-size: clamp(1rem, 1.5vw, 1.25rem); line-height: 1.6; color: var(--muted-foreground); }
.fp-dither__actie { display: inline-flex; }
/* ── SpotlightHero ──────────────────────────────────────────────────────── */
.fp-spot {
position: relative; overflow: hidden;
display: flex; width: 100%;
background: color-mix(in srgb, var(--background) 96%, var(--foreground)); color: var(--foreground);
border: 1px solid var(--border); border-radius: var(--radius);
}
.fp-spot__licht {
position: absolute; left: 0; top: 0; pointer-events: none;
width: var(--fp-spot-maat); height: var(--fp-spot-maat); border-radius: 50%;
background: radial-gradient(circle at center, color-mix(in srgb, var(--foreground) 45%, transparent), color-mix(in srgb, var(--foreground) 15%, transparent) 40%, transparent 80%);
filter: blur(24px);
}
.fp-spot__tekst { position: relative; z-index: 1; flex: 1; display: flex; flex-direction: column; justify-content: center; padding: 2rem; }
.fp-spot__titel { margin: 0; font: inherit; font-weight: 700; font-size: clamp(2rem, 4vw, 3rem); line-height: 1.1; letter-spacing: -0.02em; }
.fp-spot__sub { margin: 1rem 0 0; max-width: 32rem; color: var(--muted-foreground); }
.fp-spot__actie { margin-top: 1.5rem; display: flex; }
.fp-spot__scene { position: relative; flex: 1; min-width: 0; }
@media (max-width: 640px) { .fp-spot { flex-direction: column; } .fp-spot__scene { min-height: 14rem; } }
/* ── HeroCarousel ───────────────────────────────────────────────────────── */
.fp-hcar {
position: relative; overflow: hidden; width: 100%; height: 100%; min-height: 24rem;
background: var(--background); color: var(--foreground); user-select: none; outline: none;
}
.fp-hcar:focus-visible { box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--foreground) 40%, transparent); }
.fp-hcar__achter { position: absolute; inset: 0; }
.fp-hcar__achter-foto { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
/* Luminantie van de foto, tint van het accent. */
.fp-hcar__tint { position: absolute; inset: 0; background: var(--fp-hcar-accent); }
.fp-hcar__tint--kleur { mix-blend-mode: color; }
.fp-hcar__tint--donker { mix-blend-mode: multiply; opacity: 0.55; }
.fp-hcar__was { position: absolute; inset: 0; background: linear-gradient(to bottom, color-mix(in srgb, var(--background) 40%, transparent), transparent 40%, transparent 60%, color-mix(in srgb, var(--background) 45%, transparent)); }
.fp-hcar__korrel { position: absolute; inset: 0; pointer-events: none; opacity: 0.22; mix-blend-mode: overlay; background-size: 180px 180px; }
.fp-hcar__balk { position: absolute; inset: var(--fp-hcar-top) 0 auto 0; display: flex; align-items: center; justify-content: center; gap: clamp(1.25rem, 6vw, 4rem); font-size: calc(var(--fp-hcar-label) * 1.15); }
.fp-hcar__knop { appearance: none; border: 0; background: transparent; padding: 0; cursor: pointer; font: inherit; font-size: inherit; color: inherit; opacity: 0.9; transition: opacity var(--motion-instant-duration) var(--motion-instant), transform var(--motion-instant-duration) var(--motion-instant); }
.fp-hcar__knop:hover { opacity: 1; }
.fp-hcar__knop[data-pressed] { transform: scale(0.96); }
.fp-hcar__merk { font-weight: 600; letter-spacing: 0.06em; font-size: calc(var(--fp-hcar-label) * 1.35); }
.fp-hcar__kop { position: absolute; inset: 0 0 auto 0; height: var(--fp-hcar-strook-top); display: flex; flex-direction: column; justify-content: flex-end; padding: 0 var(--fp-hcar-pad) 1.75%; }
.fp-hcar__kop-rij { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 0.5rem 6vw; width: 100%; }
.fp-hcar__titel { margin: 0; font: inherit; font-weight: 600; font-size: var(--fp-hcar-titel); line-height: 0.88; letter-spacing: -0.03em; }
.fp-hcar__regel { display: block; overflow: hidden; }
.fp-hcar__regel-tekst { display: block; }
.fp-hcar__credit { margin: 0; font-size: var(--fp-hcar-label); text-transform: uppercase; letter-spacing: 0.14em; font-variant-numeric: tabular-nums; }
.fp-hcar__feiten { margin-left: auto; display: flex; align-items: flex-end; gap: clamp(1rem, 5.5vw, 4rem); }
.fp-hcar__feit { font-size: var(--fp-hcar-label); text-transform: uppercase; letter-spacing: 0.14em; white-space: nowrap; font-variant-numeric: tabular-nums; }
.fp-hcar__strook { position: absolute; inset: var(--fp-hcar-strook-top) 0 auto 0; height: var(--fp-hcar-kaart-h); }
.fp-hcar__spoor { display: flex; align-items: flex-start; gap: var(--fp-hcar-gap); cursor: grab; }
.fp-hcar__spoor[data-slepen] { cursor: grabbing; }
.fp-hcar__kaart {
position: relative; flex-shrink: 0; overflow: hidden;
width: var(--fp-hcar-kaart-w); height: var(--fp-hcar-kaart-h);
appearance: none; border: 0; padding: 0; cursor: pointer;
background: color-mix(in srgb, var(--foreground) 5%, transparent);
}
.fp-hcar__kaart:focus-visible { outline: 2px solid var(--ring, var(--info)); outline-offset: 2px; }
/* De halve kaart toont het bovenste deel; het beeld staat iets boven het midden zodat een gezicht blijft. */
.fp-hcar__kaart-foto { width: 100%; height: 100%; object-fit: cover; object-position: 50% 26%; }
.fp-hcar__kaart-schaduw { position: absolute; inset: 0; background: var(--background); }
.fp-hcar__rail { position: absolute; left: var(--fp-hcar-pad); bottom: var(--fp-hcar-onder); width: var(--fp-hcar-rail); }
.fp-hcar__rail-cijfers { display: flex; justify-content: space-between; font-size: var(--fp-hcar-label); font-variant-numeric: tabular-nums; opacity: 0.8; }
.fp-hcar__rail-lijn { position: relative; margin-top: 0.5rem; height: 1px; width: 100%; background: color-mix(in srgb, var(--foreground) 25%, transparent); }
.fp-hcar__rail-marker { position: absolute; inset: 0 auto 0 0; width: calc(100% / var(--fp-hcar-n, 1)); background: var(--foreground); }
/* ── CoverflowCarousel ──────────────────────────────────────────────────── */
.fp-cover {
--fp-cover-h: calc(var(--fp-cover-w) * 1.515);
position: relative; overflow: hidden; user-select: none; outline: none;
display: flex; align-items: center; justify-content: center;
width: 100%; min-height: calc(var(--fp-cover-h) + 15rem); padding: 3rem 0;
background: var(--background); color: var(--foreground);
}
.fp-cover:focus-visible { box-shadow: inset 0 0 0 2px var(--ring, var(--info)); }
.fp-cover__achter { position: absolute; inset: 0; overflow: hidden; pointer-events: none; }
.fp-cover__achter-foto { width: 100%; height: 100%; object-fit: cover; filter: brightness(0.22) blur(32px); transform: scale(1.15); }
.fp-cover__achter-was { position: absolute; inset: 0; background: radial-gradient(circle at center, color-mix(in srgb, var(--background) 30%, transparent), color-mix(in srgb, var(--background) 92%, transparent)); }
.fp-cover__inhoud { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; width: 100%; max-width: 72rem; padding: 0 1rem; }
.fp-cover__eyebrow { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 2rem; }
.fp-cover__eyebrow-tekst { margin: 0; font: inherit; font-size: 0.75rem; font-weight: 700; letter-spacing: 0.3em; text-transform: uppercase; color: var(--primary); }
.fp-cover__lijn { width: 2.25rem; height: 1px; background: linear-gradient(90deg, transparent, var(--primary)); }
.fp-cover__lijn--rechts { background: linear-gradient(90deg, var(--primary), transparent); }
.fp-cover__podium { position: relative; display: flex; align-items: center; justify-content: center; width: 100%; height: var(--fp-cover-h); margin-bottom: 2rem; perspective: 1400px; }
.fp-cover__kaart {
position: absolute; overflow: hidden;
width: var(--fp-cover-w); height: var(--fp-cover-h);
border-radius: calc(var(--radius) * 2);
background: var(--card); border: 1px solid color-mix(in srgb, var(--foreground) 12%, transparent);
transform-origin: center center; cursor: pointer;
box-shadow: 0 15px 35px color-mix(in srgb, var(--background) 50%, transparent);
transition:
transform var(--motion-smooth-duration) var(--motion-smooth),
opacity var(--motion-smooth-duration) var(--motion-smooth),
filter var(--motion-smooth-duration) var(--motion-smooth),
box-shadow var(--motion-smooth-duration) var(--motion-smooth);
}
.fp-cover__kaart[data-positie="weg"] { transform: scale(0.4); opacity: 0; z-index: 0; filter: brightness(0.4) blur(2px); pointer-events: none; }
.fp-cover__kaart[data-positie="midden"] { transform: none; opacity: 1; z-index: 30; filter: none; cursor: default; box-shadow: 0 25px 60px color-mix(in srgb, var(--background) 90%, transparent), 0 0 35px color-mix(in srgb, var(--primary) 25%, transparent); }
.fp-cover__kaart[data-positie="r1"] { transform: translateX(calc(var(--fp-cover-w) * 0.86)) scale(0.84) rotateY(-24deg); opacity: 0.65; z-index: 20; filter: brightness(0.75); }
.fp-cover__kaart[data-positie="r2"] { transform: translateX(calc(var(--fp-cover-w) * 1.55)) scale(0.68) rotateY(-38deg); opacity: 0.38; z-index: 10; filter: brightness(0.55) blur(1px); }
.fp-cover__kaart[data-positie="l1"] { transform: translateX(calc(var(--fp-cover-w) * -0.86)) scale(0.84) rotateY(24deg); opacity: 0.65; z-index: 20; filter: brightness(0.75); }
.fp-cover__kaart[data-positie="l2"] { transform: translateX(calc(var(--fp-cover-w) * -1.55)) scale(0.68) rotateY(38deg); opacity: 0.38; z-index: 10; filter: brightness(0.55) blur(1px); }
.fp-cover__foto { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
.fp-cover__vignet { position: absolute; inset: 0; pointer-events: none; z-index: 1; background: linear-gradient(180deg, color-mix(in srgb, var(--background) 40%, transparent) 0%, color-mix(in srgb, var(--background) 10%, transparent) 25%, color-mix(in srgb, var(--background) 68%, transparent) 60%, color-mix(in srgb, var(--background) 96%, transparent) 100%); }
.fp-cover__tekst {
position: relative; z-index: 2; display: flex; flex-direction: column; justify-content: space-between;
width: 100%; height: 100%; padding: 1.25rem 1.125rem 1.375rem; text-align: center;
opacity: 0; transform: translateY(16px); pointer-events: none;
transition: opacity var(--motion-smooth-duration) var(--motion-smooth), transform var(--motion-smooth-duration) var(--motion-smooth);
}
.fp-cover__kaart[data-positie="midden"] .fp-cover__tekst { opacity: 1; transform: none; pointer-events: auto; }
.fp-cover__tag { align-self: flex-end; font-size: 0.78rem; font-weight: 600; letter-spacing: 0.06em; text-shadow: 0 2px 6px color-mix(in srgb, var(--background) 80%, transparent); }
.fp-cover__onder { display: flex; flex-direction: column; align-items: center; gap: 0.1875rem; margin-top: auto; }
.fp-cover__titel { margin: 0; font: inherit; font-size: 1.65rem; font-weight: 900; text-transform: uppercase; letter-spacing: 0.04em; line-height: 1.1; text-shadow: 0 3px 12px color-mix(in srgb, var(--background) 95%, transparent); }
.fp-cover__sub { font-size: 1.1rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; line-height: 1.2; text-shadow: 0 3px 10px color-mix(in srgb, var(--background) 90%, transparent); }
.fp-cover__streep { width: 2.125rem; height: 2px; margin: 0.3125rem auto 0.25rem; border-radius: 999px; background: var(--primary); box-shadow: 0 0 8px color-mix(in srgb, var(--primary) 70%, transparent); }
.fp-cover__omschrijving { margin: 0 0 0.625rem; max-width: 17.5rem; font-size: 0.82rem; font-style: italic; line-height: 1.3; text-shadow: 0 2px 8px color-mix(in srgb, var(--background) 90%, transparent); }
.fp-cover__cta {
display: inline-flex; align-items: center; gap: 0.375rem; padding: 0.4375rem 1.125rem;
appearance: none; border: 0; cursor: pointer; text-decoration: none;
font: inherit; font-size: 0.72rem; font-weight: 800; letter-spacing: 0.14em; text-transform: uppercase;
color: var(--primary-foreground); background: linear-gradient(135deg, var(--primary), color-mix(in srgb, var(--primary) 80%, var(--background)));
border-radius: 999px; box-shadow: 0 4px 14px color-mix(in srgb, var(--background) 40%, transparent), 0 0 15px color-mix(in srgb, var(--primary) 30%, transparent);
transition: transform var(--motion-instant-duration) var(--motion-instant);
}
.fp-cover__cta:hover { transform: translateY(-1px); }
.fp-cover__cta[data-pressed] { transform: scale(0.97); }
.fp-cover__pijl {
position: absolute; top: 50%; transform: translateY(-50%); z-index: 40;
display: flex; align-items: center; justify-content: center; width: 2.875rem; height: 2.875rem;
appearance: none; cursor: pointer; border-radius: 50%;
color: var(--foreground); background: color-mix(in srgb, var(--background) 55%, transparent);
border: 1px solid color-mix(in srgb, var(--foreground) 20%, transparent);
-webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px);
box-shadow: var(--shadow-overlay);
transition: background-color var(--motion-instant-duration) var(--motion-instant), transform var(--motion-instant-duration) var(--motion-instant);
}
.fp-cover__pijl--links { left: 1.5rem; }
.fp-cover__pijl--rechts { right: 1.5rem; }
.fp-cover__pijl:hover { background: color-mix(in srgb, var(--background) 75%, transparent); }
.fp-cover__pijl[data-pressed] { transform: translateY(-50%) scale(0.94); }
.fp-cover__pijl:focus-visible { outline: 2px solid var(--ring, var(--info)); outline-offset: 2px; }
.fp-cover__stippen { display: flex; align-items: center; justify-content: center; gap: 1.375rem; }
.fp-cover__stip {
appearance: none; border: 0; cursor: pointer; padding: 0;
width: 0.5rem; height: 0.5rem; border-radius: 999px;
background: color-mix(in srgb, var(--foreground) 25%, transparent);
transform-origin: center;
transition: transform var(--motion-default-duration) var(--motion-default), background-color var(--motion-default-duration) var(--motion-default);
}
/* De actieve stip rekt uit via schaal — niet via breedte. */
.fp-cover__stip[aria-current] { background: var(--primary); transform: scaleX(3.5); box-shadow: 0 0 10px color-mix(in srgb, var(--primary) 70%, transparent); }
.fp-cover__stip:focus-visible { outline: 2px solid var(--ring, var(--info)); outline-offset: 3px; }