background-paths
FC-0031hero-backgroundblockv1.0.0Hero met 72 trage, dunne lijnen die als stromen over het vlak trekken; de titel komt letter voor letter omhoog, de actie eronder is een slot. Lijnen kleuren met currentColor, dus het blok herskint mee.
blocks/background-paths/background-paths.tsx
/**
* BackgroundPaths — een hero met 72 dunne, trage lijnen die als stromen
* over het vlak trekken; de titel komt letter voor letter omhoog, de actie
* eronder is een slot. De lijnen kleuren met de tekstkleur (currentColor),
* dus het blok herskint mee zonder één letterlijke kleur.
*
* Bewust anders dan de bron: de duur per lijn was Math.random() (hydratie-
* verschil server/client); nu deterministisch uit de index. De knop uit de
* bron (shadcn Button met radix-slot) is vervallen — het blok kiest geen knop,
* het project levert er een via `action` (Button, PressDepth, of een link).
* Reduced motion: lijnen staan stil op 40% zichtbaar, titel staat er direct.
*/
"use client";
import type * as React from "react";
import { motion, useReducedMotion } from "framer-motion";
import { useSpringTransition } from "../../primitives/motion";
interface PadenProps {
richting: 1 | -1;
reduced: boolean;
}
function Paden({ richting, reduced }: PadenProps) {
const paden = Array.from({ length: 36 }, (_, i) => ({
id: i,
d: `M-${380 - i * 5 * richting} -${189 + i * 6}C-${380 - i * 5 * richting} -${189 + i * 6} -${312 - i * 5 * richting} ${216 - i * 6} ${152 - i * 5 * richting} ${343 - i * 6}C${616 - i * 5 * richting} ${470 - i * 6} ${684 - i * 5 * richting} ${875 - i * 6} ${684 - i * 5 * richting} ${875 - i * 6}`,
breedte: 0.5 + i * 0.03,
opacity: 0.1 + i * 0.03,
duur: 20 + ((i * 7) % 10),
}));
return (
<svg className="fp-paths__svg" viewBox="0 0 696 316" fill="none" aria-hidden="true" focusable="false">
{paden.map((p) => (
<motion.path
key={p.id}
d={p.d}
stroke="currentColor"
strokeWidth={p.breedte}
strokeOpacity={p.opacity}
initial={reduced ? { pathLength: 1, opacity: 0.4 } : { pathLength: 0.3, opacity: 0.6 }}
animate={reduced ? { pathLength: 1, opacity: 0.4 } : { pathLength: 1, opacity: [0.3, 0.6, 0.3], pathOffset: [0, 1, 0] }}
transition={reduced ? { duration: 0 } : { duration: p.duur, repeat: Infinity, ease: "linear" }}
/>
))}
</svg>
);
}
export interface BackgroundPathsProps extends Omit<React.HTMLAttributes<HTMLElement>, "title"> {
title: string;
/** De actie onder de titel — het project levert de knop. */
action?: React.ReactNode;
/** Hoogte van het vlak, structureel (default min(100svh, 40rem)). */
height?: string;
}
export function BackgroundPaths({ title, action, height = "min(100svh, 40rem)", className, style, ...rest }: BackgroundPathsProps) {
const reduced = useReducedMotion() ?? false;
const veer = useSpringTransition("smooth", { mass: "content" });
const woorden = title.split(" ");
return (
<section {...rest} className={["fp-paths", className].filter(Boolean).join(" ")} style={{ ...style, minHeight: height }}>
<div className="fp-paths__achter" aria-hidden="true">
<Paden richting={1} reduced={reduced} />
<Paden richting={-1} reduced={reduced} />
</div>
<motion.div
className="fp-paths__voor"
initial={reduced ? false : { opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ ...veer, delay: 0.1 }}
>
<h1 className="fp-paths__titel">
{woorden.map((woord, w) => (
<span key={w} className="fp-paths__woord">
{Array.from(woord).map((letter, l) => (
<motion.span
key={`${w}-${l}`}
className="fp-paths__letter"
initial={reduced ? false : { y: 100, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ ...veer, delay: w * 0.1 + l * 0.03 }}
>
{letter}
</motion.span>
))}
</span>
))}
</h1>
{action && <div className="fp-paths__actie">{action}</div>}
</motion.div>
</section>
);
}
blocks/background-paths/background-paths.css
/**
* BackgroundPaths — hero met stromende lijnen. Importeren in de app-root
* (de registry-site doet dat in app/globals.css).
*/
.fp-paths {
position: relative; overflow: hidden;
display: flex; align-items: center; justify-content: center;
width: 100%;
background: var(--background); color: var(--foreground);
}
.fp-paths__achter { position: absolute; inset: 0; pointer-events: none; }
.fp-paths__svg { position: absolute; inset: 0; width: 100%; height: 100%; }
.fp-paths__voor {
position: relative; z-index: 1;
width: 100%; max-width: 56rem; margin: 0 auto;
padding: 0 1.5rem; text-align: center;
}
.fp-paths__titel {
margin: 0 0 2rem;
font: inherit; font-weight: 700; letter-spacing: -0.04em; line-height: 1;
font-size: clamp(2.75rem, 8vw, 6rem);
}
.fp-paths__woord { display: inline-block; margin-right: 0.25em; }
.fp-paths__woord:last-child { margin-right: 0; }
/* De letters komen van onderen; de kop knipt ze niet af (overflow blijft zichtbaar). */
.fp-paths__letter { display: inline-block; }
.fp-paths__actie { display: inline-flex; }