Solo
Gratis
Voor je eigen project
- E-mailmeldingen
- Controle elke 3 min
- 10 monitors
- Tot 3 gebruikers
Prijssectie met vier treden: kop, frequentie-schakelaar (glijdende pil met kortingsbadge) en een raster van kaarten. 'popular' krijgt een ring in de merkkleur, een badge en een zachte gloed; 'highlighted' is omgekeerd (tekstkleur als vlak met een vervagend rasterpatroon). Getalprijzen veren naar hun nieuwe waarde; tekstprijzen ('Gratis', 'Op maat') staan gewoon.
Kies het pakket dat bij je past
Gratis
Voor je eigen project
per maand, per gebruiker
Voor kleine bedrijven
per maand, per gebruiker
Voor grote bedrijven
Op maat
Voor meerdere teams
Vier treden: 'Team' is populair (ring + badge + gloed), 'Enterprise' is omgekeerd met rasterpatroon. Getalprijzen veren; tekstprijzen staan.
/**
* TiersPricing — de prijssectie met vier treden: kop, een frequentie-
* schakelaar (pil glijdt via layoutId, met een kortingsbadge) en een raster
* van kaarten. Twee bijzondere staten: `popular` (ring in de merkkleur, een
* badge naast de naam, zachte gloed bovenin) en `highlighted` (omgekeerd:
* tekstkleur als vlak met een rasterpatroon dat naar onderen vervaagt).
* Prijzen veren naar hun nieuwe waarde; een tekstprijs ('Gratis', 'Op maat')
* staat gewoon.
*
* Geoogst uit 21st.dev Pricing Section (aymanch-03, Dub.co): @number-flow →
* rolling-number, shadcn Card/Badge/Button/cva → eigen vlakken + button-
* stylesheet, lucide BadgeCheck/ArrowRight → inline paden, het #4f4f4f2e-
* raster en rgba(120,119,198) → color-mix van --border en --primary, 'Save
* 35%' en 'Per month/user' → `periods[].note`/`suffix`, USD → Intl.
*/
"use client";
import { useState } from "react";
import type * as React from "react";
import { motion } from "framer-motion";
import { useSpringTransition } from "../../primitives/motion";
import { usePress } from "../../primitives/ui/press";
import { RollingNumber } from "./rolling-number";
export interface PricingTier {
id: string;
name: string;
description?: string;
/** Per periode een getal (veert) of tekst ('Gratis', 'Op maat'). */
price: Record<string, number | string>;
features: readonly string[];
cta: string;
href: string;
popular?: boolean;
highlighted?: boolean;
}
export interface TierPeriod {
id: string;
label: string;
/** Kortingsbadge in de tab ('bespaar 35%'). */
note?: string;
/** Regel onder een getalprijs ('per maand, per gebruiker'). */
suffix?: string;
}
export interface TiersPricingProps extends Omit<React.HTMLAttributes<HTMLElement>, "title" | "children"> {
title: React.ReactNode;
subtitle?: React.ReactNode;
tiers: readonly PricingTier[];
periods: readonly TierPeriod[];
defaultPeriod?: string;
onPeriodChange?: (id: string) => void;
popularLabel?: string;
format?: Intl.NumberFormatOptions;
locale?: string;
linkComponent?: React.ElementType;
}
function Tab({ p, actief, onPress, veer }: { p: TierPeriod; actief: boolean; onPress: () => void; veer: ReturnType<typeof useSpringTransition> }) {
const { pressed, pressProps } = usePress({ onPress });
return (
<button {...pressProps} type="button" role="tab" aria-selected={actief} className="fp-tiers__tab" data-pressed={pressed ? "" : undefined}>
{actief && <motion.span layoutId="fp-tiers-pil" className="fp-tiers__pil" transition={veer} />}
<span className="fp-tiers__tab-tekst">{p.label}</span>
{p.note && <span className="fp-tiers__korting">{p.note}</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="M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z" /><path d="m9 12 2 2 4-4" /></svg>;
const Pijl = () => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M5 12h14M12 5l7 7-7 7" /></svg>;
export function TiersPricing({ title, subtitle, tiers, periods, defaultPeriod, onPeriodChange, popularLabel = "Meest gekozen", format, locale, linkComponent: Link = "a", className, ...rest }: TiersPricingProps) {
const [periode, setPeriode] = useState(defaultPeriod ?? periods[0]?.id ?? "");
const actief = periods.find((p) => p.id === periode);
const veer = useSpringTransition("snappy");
const kies = (id: string) => {
if (id === periode) return;
setPeriode(id);
onPeriodChange?.(id);
};
return (
<section {...rest} className={["fp-tiers", className].filter(Boolean).join(" ")}>
<header className="fp-tiers__kop">
<h2 className="fp-tiers__titel">{title}</h2>
{subtitle && <p className="fp-tiers__ondertitel">{subtitle}</p>}
<div className="fp-tiers__schakelaar" role="tablist" aria-label="Periode">
{periods.map((p) => <Tab key={p.id} p={p} actief={p.id === periode} onPress={() => kies(p.id)} veer={veer} />)}
</div>
</header>
<div className="fp-tiers__raster">
{tiers.map((tier) => {
const prijs = tier.price[periode];
return (
<article key={tier.id} className={["fp-tiers__kaart", tier.highlighted ? "fp-tiers__kaart--omgekeerd" : "", tier.popular ? "fp-tiers__kaart--populair" : ""].filter(Boolean).join(" ")}>
{tier.highlighted && <span className="fp-tiers__raster-achtergrond" aria-hidden="true" />}
{tier.popular && <span className="fp-tiers__gloed" aria-hidden="true" />}
<h3 className="fp-tiers__naam">
{tier.name}
{tier.popular && <span className="fp-tiers__badge">{popularLabel}</span>}
</h3>
<div className="fp-tiers__prijsvak">
{typeof prijs === "number" ? (
<>
<RollingNumber value={prijs} format={format} locale={locale} className="fp-tiers__prijs" />
{actief?.suffix && <p className="fp-tiers__suffix">{actief.suffix}</p>}
</>
) : (
<p className="fp-tiers__prijs">{prijs}</p>
)}
</div>
<div className="fp-tiers__inhoud">
{tier.description && <p className="fp-tiers__omschrijving">{tier.description}</p>}
<ul className="fp-tiers__lijst">
{tier.features.map((f) => <li key={f}><Vink />{f}</li>)}
</ul>
</div>
<Link href={tier.href} className={["fp-btn", tier.highlighted ? "fp-btn--ghost" : "fp-btn--primary", "fp-tiers__cta"].join(" ")}>
{tier.cta}<Pijl />
</Link>
</article>
);
})}
</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; }