Start
Voor één website die gewoon moet werken.
€49/ maand
Maandelijks gefactureerd
- Eén website
- Basisstatistieken
- Reactie binnen 48 uur
- Maandelijkse back-up
Prijssectie op een sterrenveld: kop en toelichting, een maand/jaar-schakelaar met glijdende pil, en drie kaarten (de populaire iets hoger met dubbele rand in de merkkleur en een label erboven). Prijzen veren naar hun nieuwe waarde bij het wisselen; de sterren twinkelen en trekken zacht naar de cursor. Kaarten komen omhoog zodra ze in beeld komen.
Wissel de periode: de prijzen veren naar hun nieuwe waarde en onPeriodChange telt (0×) — daar hangt het project zijn viering aan. Beweeg over het vlak: de sterren trekken naar de cursor.
/**
* StarfieldPricing — prijssectie op een sterrenveld: kop en toelichting,
* een maand/jaar-schakelaar met een glijdende pil, en drie kaarten (de
* populaire iets hoger, met een dubbele rand in de merkkleur en een label
* erboven). Prijzen veren naar hun nieuwe waarde bij het wisselen. De
* sterren twinkelen en worden zacht naar de cursor getrokken. Kaarten komen
* omhoog zodra ze in beeld komen (scroll: trigger).
*
* Geoogst uit 21st.dev Pricing (ravikatiyar162): canvas-confetti → callback
* `onPeriodChange` (het project kiest de viering), @number-flow/react →
* rolling-number (framer spring), 150 Star-componenten met elk twee
* useSprings en een React-effect per muisbeweging → één rAF-lus die de
* transform direct op de stippen zet (geen state per beweging), Math.random
* → vaste PRNG (geen hydration-verschil), shadcn Button/cva/next-link →
* button-stylesheet op linkComponent, lucide Check/Star → inline paden,
* USD → Intl via `format`, dark:bg-neutral-950 → --background.
*/
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import type * as React from "react";
import { motion, useReducedMotion } from "framer-motion";
import { useSpringTransition } from "../../primitives/motion";
import { usePress } from "../../primitives/ui/press";
import { RollingNumber } from "./rolling-number";
export interface PricingPlan {
id: string;
name: string;
description?: string;
/** Prijs per periode: sleutel = period-id. */
price: Record<string, number>;
features: readonly string[];
cta: string;
href: string;
popular?: boolean;
}
export interface PricingPeriod {
id: string;
label: string;
/** Extra tekst achter het label ('bespaar 20%'). */
note?: string;
/** Tekst onder de prijs ('per maand'). */
suffix?: string;
/** Regel onder de prijs ('jaarlijks gefactureerd'). */
billing?: string;
}
export interface StarfieldPricingProps extends Omit<React.HTMLAttributes<HTMLElement>, "title" | "children"> {
title: React.ReactNode;
description?: React.ReactNode;
plans: readonly PricingPlan[];
/** Default: maand + jaar. */
periods?: readonly PricingPeriod[];
defaultPeriod?: string;
onPeriodChange?: (id: string) => void;
popularLabel?: string;
format?: Intl.NumberFormatOptions;
locale?: string;
/** Aantal sterren. Default 120. */
stars?: number;
linkComponent?: React.ElementType;
}
const PERIODEN: PricingPeriod[] = [
{ id: "maand", label: "Per maand", suffix: "/ maand", billing: "Maandelijks gefactureerd" },
{ id: "jaar", label: "Per jaar", note: "bespaar 20%", suffix: "/ maand", billing: "Jaarlijks gefactureerd" },
];
/** Vaste pseudo-random reeks: server en client tekenen hetzelfde veld. */
function prng(seed: number) {
let s = seed >>> 0;
return () => {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function Sterrenveld({ aantal }: { aantal: number }) {
const veld = useRef<HTMLDivElement>(null);
const reduced = useReducedMotion() ?? false;
const sterren = useMemo(() => {
const r = prng(aantal * 7 + 1);
return Array.from({ length: aantal }, (_, i) => ({ id: i, x: r() * 100, y: r() * 100, maat: 1 + r() * 2, duur: 2 + r() * 3, vertraging: r() * 5 }));
}, [aantal]);
useEffect(() => {
const el = veld.current;
if (!el || reduced) return;
const stippen = Array.from(el.querySelectorAll<HTMLElement>(".fp-starprice__ster"));
let doel: { x: number; y: number } | null = null;
let raf = 0;
const teken = () => {
raf = 0;
const rect = el.getBoundingClientRect();
for (const s of stippen) {
const sx = rect.left + (Number(s.dataset.x) / 100) * rect.width;
const sy = rect.top + (Number(s.dataset.y) / 100) * rect.height;
let dx = 0, dy = 0;
if (doel) {
const ax = doel.x - sx, ay = doel.y - sy;
const afstand = Math.hypot(ax, ay);
const straal = 600;
if (afstand < straal) { const kracht = (1 - afstand / straal) * 0.5; dx = ax * kracht; dy = ay * kracht; }
}
s.style.transform = `translate(${dx.toFixed(1)}px, ${dy.toFixed(1)}px)`;
}
};
const plan = () => { if (!raf) raf = requestAnimationFrame(teken); };
const beweeg = (e: PointerEvent) => { doel = { x: e.clientX, y: e.clientY }; plan(); };
const weg = () => { doel = null; plan(); };
const ouder = el.parentElement ?? el;
ouder.addEventListener("pointermove", beweeg);
ouder.addEventListener("pointerleave", weg);
return () => { ouder.removeEventListener("pointermove", beweeg); ouder.removeEventListener("pointerleave", weg); cancelAnimationFrame(raf); };
}, [reduced, sterren]);
return (
<div ref={veld} className="fp-starprice__veld" aria-hidden="true">
{sterren.map((s) => (
<i
key={s.id}
className="fp-starprice__ster"
data-x={s.x.toFixed(2)}
data-y={s.y.toFixed(2)}
style={{ left: `${s.x}%`, top: `${s.y}%`, width: s.maat, height: s.maat, animationDuration: `${s.duur}s`, animationDelay: `${s.vertraging}s` }}
/>
))}
</div>
);
}
/** Eén periode-tab: echte knop met usePress, de pil glijdt mee via layoutId. */
function PeriodeTab({ p, actief, onPress, pil }: { p: PricingPeriod; actief: boolean; onPress: () => void; pil: ReturnType<typeof useSpringTransition> }) {
const { pressed, pressProps } = usePress({ onPress });
return (
<button {...pressProps} type="button" role="tab" aria-selected={actief} className="fp-starprice__tab" data-pressed={pressed ? "" : undefined}>
{actief && <motion.span layoutId="fp-starprice-pil" className="fp-starprice__pil" transition={pil} />}
<span className="fp-starprice__tab-tekst">{p.label}{p.note && <span className="fp-starprice__note"> ({p.note})</span>}</span>
</button>
);
}
const Vink = () => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M5 12l5 5L19 7" /></svg>;
const Ster = () => <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 2.5l2.9 6.1 6.6.8-4.9 4.6 1.3 6.5L12 17.3l-5.9 3.2 1.3-6.5L2.5 9.4l6.6-.8z" /></svg>;
export function StarfieldPricing({ title, description, plans, periods = PERIODEN, defaultPeriod, onPeriodChange, popularLabel = "Meest gekozen", format, locale, stars = 120, linkComponent: Link = "a", className, ...rest }: StarfieldPricingProps) {
const [periode, setPeriode] = useState(defaultPeriod ?? periods[0]?.id ?? "");
const actief = periods.find((p) => p.id === periode) ?? periods[0];
const reduced = useReducedMotion() ?? false;
const veer = useSpringTransition("smooth", { mass: "content" });
const pil = useSpringTransition("snappy");
const kies = (id: string) => {
if (id === periode) return;
setPeriode(id);
onPeriodChange?.(id);
};
return (
<section {...rest} className={["fp-starprice", className].filter(Boolean).join(" ")}>
<Sterrenveld aantal={stars} />
<div className="fp-starprice__inhoud">
<header className="fp-starprice__kop">
<h2 className="fp-starprice__titel">{title}</h2>
{description && <p className="fp-starprice__toelichting">{description}</p>}
</header>
<div className="fp-starprice__schakelaar" role="tablist" aria-label="Periode">
{periods.map((p) => (
<PeriodeTab key={p.id} p={p} actief={p.id === periode} onPress={() => kies(p.id)} pil={pil} />
))}
</div>
<div className="fp-starprice__kaarten">
{plans.map((plan, i) => (
<motion.article
key={plan.id}
className={["fp-starprice__kaart", plan.popular ? "fp-starprice__kaart--populair" : ""].filter(Boolean).join(" ")}
initial={reduced ? false : { y: 50, opacity: 0 }}
whileInView={{ y: 0, opacity: 1 }}
viewport={{ once: true, amount: 0.2 }}
transition={{ ...veer, delay: i * 0.15 }}
>
{plan.popular && <span className="fp-starprice__label"><Ster />{popularLabel}</span>}
<h3 className="fp-starprice__naam">{plan.name}</h3>
{plan.description && <p className="fp-starprice__omschrijving">{plan.description}</p>}
<p className="fp-starprice__prijs">
<RollingNumber value={plan.price[periode] ?? 0} format={format} locale={locale} className="fp-starprice__bedrag" />
{actief?.suffix && <span className="fp-starprice__suffix">{actief.suffix}</span>}
</p>
{actief?.billing && <p className="fp-starprice__billing">{actief.billing}</p>}
<ul className="fp-starprice__lijst">
{plan.features.map((f) => <li key={f}><Vink />{f}</li>)}
</ul>
<Link href={plan.href} className={["fp-btn", plan.popular ? "fp-btn--primary" : "fp-btn--ghost", "fp-starprice__cta"].join(" ")}>{plan.cta}</Link>
</motion.article>
))}
</div>
</div>
</section>
);
}
/**
* RollingNumber — een getal dat naar zijn nieuwe waarde veert in plaats van
* te springen (de plek van @number-flow/react in de prijs-entries): één
* MotionValue met een spring op de smooth-token, geformatteerd via Intl.
* Reduced motion = directe zet. Gedeeld door starfield-pricing en
* tiers-pricing.
*/
"use client";
import { useEffect } from "react";
import type * as React from "react";
import { motion, useMotionValue, useSpring, useTransform } from "framer-motion";
import { useSpringTransition } from "../../primitives/motion";
export interface RollingNumberProps extends Omit<React.HTMLAttributes<HTMLSpanElement>, "children"> {
value: number;
/** Intl.NumberFormat-opties; default valuta EUR zonder decimalen. */
format?: Intl.NumberFormatOptions;
locale?: string;
}
export function RollingNumber({ value, format, locale, className, ...rest }: RollingNumberProps) {
const veer = useSpringTransition("smooth", { mass: "content" });
const duur = "duration" in veer && typeof veer.duration === "number" ? veer.duration * 1000 : 0;
const bron = useMotionValue(value);
const gedempt = useSpring(bron, duur > 0 ? { duration: duur, bounce: 0 } : { duration: 1, bounce: 0 });
const tekst = useTransform(gedempt, (v) =>
new Intl.NumberFormat(locale, { style: "currency", currency: "EUR", maximumFractionDigits: 0, ...format }).format(Math.round(v)),
);
useEffect(() => {
bron.set(value);
}, [value, bron]);
return (
<motion.span {...(rest as React.ComponentProps<typeof motion.span>)} className={["fp-rolnum", className].filter(Boolean).join(" ")}>
{tekst}
</motion.span>
);
}
/**
* Prijzen — importeren in de app-root (registry-site: app/globals.css).
* Token-contract 2. Knoppen komen uit primitives.css (.fp-btn); hier alleen
* de vlakken, de schakelaars en het sterrenveld.
*/
.fp-rolnum { font-variant-numeric: tabular-nums; }
/* ── starfield-pricing ───────────────────────────────────────────────────── */
.fp-starprice { position: relative; width: 100%; padding: var(--section-padding-block) 0; background: var(--background); color: var(--foreground); overflow: hidden; }
.fp-starprice__veld { position: absolute; inset: 0; overflow: hidden; pointer-events: none; }
.fp-starprice__ster { position: absolute; display: block; border-radius: 50%; background: var(--foreground); opacity: 0; animation: fp-starprice-twinkel calc(var(--motion-smooth-duration) * 6) linear infinite; will-change: transform, opacity; }
@keyframes fp-starprice-twinkel { 0% { opacity: 0; } 50% { opacity: 1; } 100% { opacity: 0; } }
@media (prefers-reduced-motion: reduce) { .fp-starprice__ster { animation: none; opacity: 0.5; } }
.fp-starprice__inhoud { position: relative; z-index: 1; max-width: 72rem; margin: 0 auto; padding: 0 var(--space-md); box-sizing: border-box; }
.fp-starprice__kop { max-width: 48rem; margin: 0 auto var(--space-xl); text-align: center; }
.fp-starprice__titel { margin: 0 0 var(--space-md); font: inherit; font-size: var(--text-h2); line-height: 1.1; font-weight: 700; letter-spacing: -0.03em; }
.fp-starprice__toelichting { margin: 0; font-size: var(--text-body); color: var(--muted-foreground); white-space: pre-line; }
.fp-starprice__schakelaar { position: relative; display: flex; width: fit-content; margin: 0 auto; padding: 0.25rem; border-radius: 999px; background: var(--muted); }
.fp-starprice__tab { position: relative; padding: var(--space-xs) var(--space-lg); border: 0; border-radius: 999px; background: transparent; cursor: pointer; font: inherit; font-size: var(--text-body-sm); font-weight: 500; color: var(--muted-foreground); transition: color var(--motion-instant-duration) var(--motion-instant); }
.fp-starprice__tab:hover { color: var(--foreground); }
.fp-starprice__tab[aria-selected="true"] { color: var(--primary-foreground); }
.fp-starprice__tab:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }
.fp-starprice__pil { position: absolute; inset: 0; z-index: 0; border-radius: 999px; background: var(--primary); }
.fp-starprice__tab-tekst { position: relative; z-index: 1; }
.fp-starprice__note { opacity: 0.8; }
.fp-starprice__kaarten { display: grid; grid-template-columns: 1fr; align-items: start; gap: var(--space-lg); margin-top: var(--space-xl); }
@media (min-width: 64rem) { .fp-starprice__kaarten { grid-template-columns: repeat(3, minmax(0, 1fr)); } .fp-starprice__kaart--populair { margin-top: calc(-1 * var(--space-lg)); } }
.fp-starprice__kaart {
position: relative; display: flex; flex-direction: column; text-align: center;
padding: var(--space-xl); border-radius: var(--radius-lg);
border: var(--border-width) solid var(--border); background: color-mix(in srgb, var(--background) 70%, transparent); backdrop-filter: blur(4px);
}
.fp-starprice__kaart--populair { border: calc(var(--border-width) * 2) solid var(--primary); box-shadow: var(--shadow-overlay); }
.fp-starprice__label { position: absolute; top: 0; left: 50%; display: inline-flex; align-items: center; gap: var(--space-2xs); padding: var(--space-2xs) var(--space-md); border-radius: 999px; background: var(--primary); color: var(--primary-foreground); font-size: var(--text-label); font-weight: 600; white-space: nowrap; transform: translate(-50%, -50%); }
.fp-starprice__label > svg { width: 1em; height: 1em; }
.fp-starprice__naam { margin: 0; font: inherit; font-size: var(--text-h3); font-weight: 600; }
.fp-starprice__omschrijving { margin: var(--space-xs) 0 0; font-size: var(--text-body-sm); color: var(--muted-foreground); }
.fp-starprice__prijs { display: flex; align-items: baseline; justify-content: center; gap: var(--space-2xs); margin: var(--space-lg) 0 0; }
.fp-starprice__bedrag { font-size: var(--text-display); line-height: 1; font-weight: 700; letter-spacing: -0.03em; }
.fp-starprice__suffix { font-size: var(--text-body-sm); font-weight: 600; letter-spacing: 0.02em; color: var(--muted-foreground); white-space: nowrap; }
.fp-starprice__billing { margin: var(--space-xs) 0 0; font-size: var(--text-label); color: var(--muted-foreground); }
.fp-starprice__lijst { list-style: none; margin: var(--space-xl) 0 0; padding: 0; display: grid; gap: var(--space-sm); text-align: left; font-size: var(--text-body-sm); line-height: 1.5; color: var(--muted-foreground); }
.fp-starprice__lijst li { display: flex; gap: var(--space-sm); }
.fp-starprice__lijst svg { flex: 0 0 auto; width: 1.25rem; height: 1.25rem; color: var(--primary); }
.fp-starprice__cta { display: inline-flex; align-items: center; justify-content: center; width: 100%; margin-top: auto; padding-top: var(--space-sm); padding-bottom: var(--space-sm); box-sizing: border-box; text-decoration: none; }
.fp-starprice__kaart .fp-starprice__cta { margin-top: var(--space-xl); }
/* ── tiers-pricing ───────────────────────────────────────────────────────── */
.fp-tiers { display: flex; flex-direction: column; align-items: center; gap: var(--space-xl); width: 100%; padding: var(--space-xl) 0; color: var(--foreground); }
.fp-tiers__kop { display: grid; gap: var(--space-lg); justify-items: center; text-align: center; }
.fp-tiers__titel { margin: 0; font: inherit; font-size: var(--text-h2); line-height: 1.1; font-weight: 500; letter-spacing: -0.02em; }
.fp-tiers__ondertitel { margin: 0; font-size: var(--text-body); color: var(--muted-foreground); }
.fp-tiers__schakelaar { display: flex; width: fit-content; padding: 0.25rem; border-radius: 999px; background: var(--muted); }
.fp-tiers__tab { position: relative; display: inline-flex; align-items: center; gap: var(--space-xs); padding: var(--space-xs) var(--space-md); border: 0; border-radius: 999px; background: transparent; cursor: pointer; font: inherit; font-size: var(--text-body-sm); font-weight: 600; color: var(--foreground); text-transform: capitalize; }
.fp-tiers__tab:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }
.fp-tiers__pil { position: absolute; inset: 0; z-index: 0; border-radius: 999px; background: var(--background); box-shadow: 0 1px 2px color-mix(in srgb, var(--foreground) 8%, transparent); }
.fp-tiers__tab-tekst { position: relative; z-index: 1; }
.fp-tiers__korting { position: relative; z-index: 1; padding: 0.125rem var(--space-xs); border-radius: 999px; background: var(--muted); font-size: var(--text-label); font-weight: 600; white-space: nowrap; }
.fp-tiers__tab[aria-selected="true"] .fp-tiers__korting { background: var(--muted); }
.fp-tiers__raster { display: grid; gap: var(--space-lg); width: 100%; max-width: 72rem; }
@media (min-width: 40rem) { .fp-tiers__raster { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (min-width: 80rem) { .fp-tiers__raster { grid-template-columns: repeat(4, minmax(0, 1fr)); } }
.fp-tiers__kaart {
position: relative; display: flex; flex-direction: column; gap: var(--space-xl); overflow: hidden;
padding: var(--space-lg); border-radius: var(--radius-lg); border: var(--border-width) solid var(--border);
background: var(--background); color: var(--foreground); box-shadow: 0 1px 2px color-mix(in srgb, var(--foreground) 6%, transparent);
}
.fp-tiers__kaart--omgekeerd { background: var(--foreground); color: var(--background); border-color: var(--foreground); }
.fp-tiers__kaart--populair { box-shadow: 0 0 0 2px var(--primary); }
/* Het raster van de omgekeerde kaart: lijnen in de achtergrondkleur op lage dekking, vervagend naar onderen. */
.fp-tiers__raster-achtergrond {
position: absolute; inset: 0; pointer-events: none;
background-image: linear-gradient(to right, color-mix(in srgb, var(--background) 18%, transparent) 1px, transparent 1px), linear-gradient(to bottom, color-mix(in srgb, var(--background) 18%, transparent) 1px, transparent 1px);
background-size: 45px 45px;
-webkit-mask-image: radial-gradient(ellipse 80% 50% at 50% 0%, black 70%, transparent 110%); mask-image: radial-gradient(ellipse 80% 50% at 50% 0%, black 70%, transparent 110%);
}
.fp-tiers__gloed { position: absolute; inset: 0; pointer-events: none; background: radial-gradient(ellipse 80% 80% at 50% -20%, color-mix(in srgb, var(--primary) 12%, transparent), transparent); }
.fp-tiers__naam { position: relative; display: flex; align-items: center; gap: var(--space-sm); margin: 0; font: inherit; font-size: var(--text-h3); font-weight: 500; text-transform: capitalize; }
.fp-tiers__badge { padding: 0.125rem var(--space-xs); border-radius: 999px; background: var(--muted); color: var(--foreground); font-size: var(--text-label); font-weight: 600; white-space: nowrap; }
.fp-tiers__prijsvak { position: relative; min-height: 3rem; }
.fp-tiers__prijs { margin: 0; font-size: var(--text-data); line-height: 1.1; font-weight: 500; }
.fp-tiers__suffix { margin: 0; font-size: var(--text-label); color: var(--muted-foreground); }
.fp-tiers__kaart--omgekeerd .fp-tiers__suffix { color: color-mix(in srgb, var(--background) 70%, transparent); }
.fp-tiers__inhoud { position: relative; flex: 1; display: grid; gap: var(--space-xs); align-content: start; }
.fp-tiers__omschrijving { margin: 0; font-size: var(--text-body-sm); font-weight: 500; }
.fp-tiers__lijst { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--space-xs); font-size: var(--text-body-sm); font-weight: 500; color: var(--muted-foreground); }
.fp-tiers__kaart--omgekeerd .fp-tiers__lijst { color: var(--background); }
.fp-tiers__lijst li { display: flex; align-items: center; gap: var(--space-xs); }
.fp-tiers__lijst svg { flex: 0 0 auto; width: 1rem; height: 1rem; }
.fp-tiers__cta { position: relative; display: inline-flex; align-items: center; justify-content: center; gap: var(--space-xs); width: 100%; padding-left: var(--space-sm); padding-right: var(--space-sm); box-sizing: border-box; text-decoration: none; white-space: nowrap; }
.fp-tiers__cta > svg { width: 1rem; height: 1rem; }
.fp-tiers__kaart--omgekeerd .fp-tiers__cta { background: var(--background); color: var(--foreground); border-color: transparent; }