← alle entries

starfield-pricing

FC-0146Prijzenpricing-tableblockv1.0.0

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.

zelfde component — andere tokens

Kies je pakket

Alle pakketten bevatten onze kern en ondersteuning.

Start

Voor één website die gewoon moet werken.

€49/ maand

Maandelijks gefactureerd

  • Eén website
  • Basisstatistieken
  • Reactie binnen 48 uur
  • Maandelijkse back-up
Begin gratis
Meest gekozen

Groei

Voor ondernemers die willen doorbouwen.

€99/ maand

Maandelijks gefactureerd

  • Onbeperkt pagina's
  • Uitgebreide statistieken
  • Reactie binnen 24 uur
  • Maandelijks rapport
  • Prioriteit bij wijzigingen
Aan de slag

Studio

Voor organisaties met eigen wensen.

€299/ maand

Maandelijks gefactureerd

  • Alles uit Groei
  • Maatwerk en koppelingen
  • Vaste contactpersoon
  • SLA en beveiliging
Neem contact op

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.

blocks/pricing/starfield-pricing.tsx
/**
 * 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>
  );
}
blocks/pricing/rolling-number.tsx
/**
 * 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>
  );
}
blocks/pricing/pricing.css
/**
 * 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; }