← alle entries

tiers-pricing

FC-0147Prijzenpricing-tiersblockv1.0.0

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.

zelfde component — andere tokens

Eenvoudige prijzen

Kies het pakket dat bij je past

Solo

Gratis

Voor je eigen project

  • E-mailmeldingen
  • Controle elke 3 min
  • 10 monitors
  • Tot 3 gebruikers
Aan de slag

TeamMeest gekozen

€90

per maand, per gebruiker

Voor kleine bedrijven

  • Onbeperkt bellen
  • Controle elke 30 s
  • 20 monitors
  • Tot 6 gebruikers
Aan de slag

Organisatie

€120

per maand, per gebruiker

Voor grote bedrijven

  • Onbeperkt bellen
  • Controle elke 15 s
  • 50 monitors
  • Tot 10 gebruikers
Aan de slag

Enterprise

Op maat

Voor meerdere teams

  • Alles uit Organisatie
  • 100 monitors
  • 15 statuspagina's
  • 200+ koppelingen
Neem contact op

Vier treden: 'Team' is populair (ring + badge + gloed), 'Enterprise' is omgekeerd met rasterpatroon. Getalprijzen veren; tekstprijzen staan.

blocks/pricing/tiers-pricing.tsx
/**
 * 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>
  );
}
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; }