Plan Fit
Usage-Based Pricing Slider for React
A pricing grid with a usage slider over it, cut into one stretch per plan. Drag it and every card measures your usage against its allowance: past the limit the overage shows as a hatched stretch and that card's price climbs a pack at a time. The highlight glides to whichever plan is cheapest at that usage, so a smaller plan with overage can keep it until the next one costs less.
- Pricing
- Interaction
- Data
Uses motion, clsx, tailwind-merge
<PlanFit />The demo follows as you drag.
60
hours of audio transcribed per month
Best fit: Build
"use client";
import { PlanFit, type PlanFitBilling, type PlanFitPlan } from "@/components/ui/plan-fit";
// A speech-to-text API billed by hours of audio. Build sells extra hours in
// packs, so it stays the cheapest well past its 100 hours, until Scale wins.
const PLANS: PlanFitPlan[] = [
{
id: "free",
name: "Free",
pitch: "Try it on your own recordings.",
price: 0,
included: 5,
caption: "No card needed",
features: ["English and Spanish", "Speaker labels", "Community forum"],
cta: { label: "Start free", href: "#start" },
},
{
id: "build",
name: "Build",
pitch: "For apps shipping their first voice feature.",
price: { monthly: 29, yearly: 23 },
included: 100,
overage: { price: 4, per: 10 },
caption: { monthly: "Billed monthly", yearly: "Billed $276 a year" },
features: ["40 languages", "Word-level timestamps", "Email support"],
cta: { label: "Start building", href: "#build" },
},
{
id: "scale",
name: "Scale",
pitch: "For products people talk to every day.",
price: { monthly: 249, yearly: 199 },
included: 1000,
overage: { price: 30, per: 100 },
limit: 2500,
caption: { monthly: "Billed monthly", yearly: "Billed $2,388 a year" },
features: ["Live streaming", "Custom vocabulary", "99.9% uptime SLA"],
cta: { label: "Choose Scale", href: "#scale" },
},
{
id: "enterprise",
name: "Enterprise",
pitch: "Dedicated capacity in your own region.",
price: "Custom",
caption: "Volume pricing",
features: ["On-prem or your VPC", "A named engineer", "Signed DPA and SOC 2"],
cta: { label: "Talk to sales", href: "#sales" },
},
];
const BILLING: PlanFitBilling[] = [
{ id: "monthly", label: "Monthly" },
{ id: "yearly", label: "Yearly", note: "−20%" },
];
export function PlanFitDemo() {
return (
<div className="w-full max-w-5xl">
<PlanFit
plans={PLANS}
unit="hours"
readoutLabel="hours of audio transcribed per month"
defaultValue={60}
max={5000}
billing={BILLING}
defaultBilling="monthly"
/>
</div>
);
}
Add this moment from KeyframeUI to our project and adapt it to our content. Plan Fit: A pricing grid with a usage slider over it, cut into one stretch per plan. Drag it and every card measures your usage against its allowance: past the limit the overage shows as a hatched stretch and that card's price climbs a pack at a time. The highlight glides to whichever plan is cheapest at that usage, so a smaller plan with overage can keep it until the next one costs less. This demo, Usage-Based API: A speech-to-text API billed by audio hours, with yearly billing. Build sells extra hours in packs, so it stays the best fit well past its 100 hours, until Scale costs less. 1. Install it: npx shadcn@latest add @keyframeui/plan-fit-demo (If the CLI can't find @keyframeui, use https://keyframeui.com/r/plan-fit-demo.json instead.) It adds the demo, components/plan-fit-demo.tsx, and the component, components/ui/plan-fit.tsx. 2. Make it ours: our copy, data, images and colors. Keep the structure the motion depends on. If we only need the effect, use <PlanFit> on its own. 3. These props set its feel. Change them before you change the file: - glideDuration (default 0.42): How long the highlight takes to glide over to a new best plan, in seconds (its spring's visual duration). - glideBounce (default 0.12): How far the highlight overshoots as it lands on a new plan, from 0 (not at all) to 1. - countDuration (default 0.35): How long every price takes to count to its new figure when the billing option changes, in seconds. 4. Don't change, in components/ui/plan-fit.tsx: - Usage, prices, meters and the readout are motion values derived from one `pos`, written into text nodes, `transform`s and the slider's ARIA values, so dragging never re-renders a card. - The highlight is one layer under the cards that glides by `transform` and turns from wherever it is; a card's accents arrive only as it lands and leave first, so two cards are never lit. - Pressing the ball never moves it: the drag keeps the offset it was taken hold at. A finger drags only once it moves sideways; a vertical move scrolls the page. - Reduced motion re-keys the highlight so it fades in at its new card; jumps, prices and the swipe row land at once, and dragging still moves everything 1:1. - The thumb is the `role="slider"` keyboard target, and a new fit is announced through the polite live region. Its timing is all in the TIMING block at the top of that file. Docs: https://keyframeui.com/components/plan-fit.md
Installation
Add the component on its own with the CLI, or copy the file in by hand.
The CLI writes components/ui/plan-fit.tsx and installs motion, clsx and tailwind-merge. It needs the @keyframeui registry in components.json; add it once per project.
npx shadcn@latest add @keyframeui/plan-fitInstall the dependencies
npm i motion clsx tailwind-mergeAdd the cn helper
Skip this if you already use shadcn/ui.
lib/utils.tsimport { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }Copy the source into your project
components/ui/plan-fit.tsx"use client"; import * as React from "react"; import { animate, motion, useMotionValue, useMotionValueEvent, useTransform, type MotionValue } from "motion/react"; import { cn } from "@/lib/utils"; /** * Keep: * - Usage, prices, meters and the readout are motion values derived from one `pos`, written into * text nodes, `transform`s and the slider's ARIA values, so dragging never re-renders a card. * - The highlight is one layer under the cards that glides by `transform` and turns from wherever * it is; a card's accents arrive only as it lands and leave first, so two cards are never lit. * - Pressing the ball never moves it: the drag keeps the offset it was taken hold at. A finger * drags only once it moves sideways; a vertical move scrolls the page. * - Reduced motion re-keys the highlight so it fades in at its new card; jumps, prices and the * swipe row land at once, and dragging still moves everything 1:1. * - The thumb is the `role="slider"` keyboard target, and a new fit is announced through the * polite live region. */ const TIMING = { /** * The highlight, its ring and the tag gliding to a new best plan: a spring, in seconds * (`visualDuration` is the `glideDuration` prop, `bounce` the `glideBounce` prop). */ glide: { type: "spring", visualDuration: 0.42, bounce: 0.12 }, /** The thumb carried to a plan's allowance, in seconds, on a strong ease-out. */ jump: { duration: 0.45, ease: [0.23, 1, 0.32, 1] }, /** The thumb gliding to a click on the rail, in seconds. */ press: { duration: 0.22, ease: [0.23, 1, 0.32, 1] }, /** Prices counting to a new billing option, in seconds (`duration` is the `countDuration` prop). */ count: { duration: 0.35, ease: [0.23, 1, 0.32, 1] }, /** The billing switch's pill: a spring, in seconds. */ slide: { type: "spring", visualDuration: 0.3, bounce: 0 }, /** The highlight appearing in place, under reduced motion, in seconds. */ fade: { duration: 0.18, ease: "easeOut" }, /** A billing option's text color, as a Tailwind class. */ option: "duration-150", /** The ball growing while held, and its focus halo, as Tailwind classes. */ ball: "duration-150 ease-out", /** A focus this long after a press is the keyboard's, and shows the halo, in ms. */ pressFocus: 300, /** A card fading to 45% when its plan is outgrown, as a Tailwind class. */ outgrow: "duration-200", /** * A card's accents, keyed off its `data-fit`, as Tailwind classes. Accents leave a card in 0.1s * and arrive in 0.2s after 0.12s, as the highlight lands, so two cards are never lit at once. */ accent: "duration-100 ease-out group-data-[fit=true]/card:delay-[120ms] group-data-[fit=true]/card:duration-200", /** The curve of the rail names' and the tag's accents, as a Tailwind class. */ accentEase: "ease-out", /** Those accents arriving at the fit, as Tailwind classes. */ accentIn: "delay-[120ms] duration-200", /** And leaving the last fit, as a Tailwind class. */ accentOut: "duration-100", /** The fit's button as a CSS transition: its fill on the accent timing, hover and press at once. */ ctaIn: "[transition:background-color_200ms_ease-out_120ms,color_200ms_ease-out_120ms,scale_150ms_ease-out,filter_150ms_ease-out]", /** Every other plan's button, as a CSS transition class. */ ctaOut: "[transition:background-color_100ms_ease-out,color_100ms_ease-out,scale_150ms_ease-out]", } as const; /* ───────────────────────────────────────────────────────── * PLAN FIT STORYBOARD (driven by the visitor's usage, not by time) * * rest a usage readout over a rail cut into one stretch per * plan, and the plan cards under it, each card under its * own stretch. A tick marks where each plan's allowance * ends. The cheapest plan for the starting usage already * wears the highlight and its "Best fit" tag * drag the thumb follows the pointer 1:1. Pressing the ball * itself never moves it: the drag keeps the spot you took * hold of, so the ball stays under your finger. Usage climbs on a * log scale inside each stretch, rounded to two figures, * and the readout, every meter and every price follow on * the same frame. Nothing re-renders while it moves * meter each card measures the usage against its allowance and * fills toward its limit tick. Past it the bar rescales: * the tick slides left to where the allowance ends inside * the usage, the hatched stretch after it is overage, and * the price climbs a pack at a time * outgrow a plan with no overage stops at its allowance. Past it * the card fades to 45% in 0.2s and its meter says so * glide when another plan becomes the cheapest, the highlight, * its ring and the tag glide over to it (spring 0.42s, * bounce 0.12) and turn from wherever they are if it moves * again. The plan's rail label and button take the accent * in 0.15s. A smaller plan with overage can stay the fit * well past its tick, until the next one is cheaper * jump a plan's name under the rail, Shift+arrows and * PageUp/PageDown carry the thumb to an allowance in 0.45s * on a strong ease-out, and everything sweeps along. * A click on the rail glides there in 0.22s * billing another billing option counts every price to its new * figure in 0.35s; the fit may move with it * narrow below the width where the cards fit in a row they become * a swipe row, and each new fit scrolls to the middle * smooth the thumb, the fill and the meters move by transform, * the highlight is one layer gliding on the compositor, * and numbers are written straight into their text nodes * touch a horizontal drag moves the thumb, a vertical one scrolls * the page, a tap glides to the spot * keys the thumb is a slider: arrows step 1% of the rail, * Shift+arrows and PageUp/PageDown go plan to plan, Home * and End go to the ends. A new fit is announced politely * reduced nothing glides, sweeps or counts: the highlight fades in * at its new card in 0.18s, jumps and prices land at once * and the swipe row snaps. Dragging still moves it all 1:1 * ───────────────────────────────────────────────────────── */ const FIT = { step: 0.01, // share of the rail an arrow key moves the thumb grab: 16, // px from the ball's center that count as taking hold of it, not pressing the rail slop: 6, // px a finger travels before it counts as a sideways drag rather than a scroll figures: 2, // significant figures usage is rounded to start: 0.6, // how far along the second plan's stretch the thumb starts } as const; // From these container widths the cards sit in one row, each under its own // stretch of the rail, and the plan names under the rail give way to the cards // themselves. Narrower, the cards become a swipe row of fixed-width cards. const ROW: Record<number, { root: string; track: string; names: string }> = { 1: { root: "[--plan-fit-card:100%]", track: "w-full", names: "hidden" }, 2: { root: "@[30rem]/plan-fit:[--plan-fit-card:50%]", track: "@[30rem]/plan-fit:w-full", names: "@[30rem]/plan-fit:hidden", }, 3: { root: "@[40rem]/plan-fit:[--plan-fit-card:33.3333%]", track: "@[40rem]/plan-fit:w-full", names: "@[40rem]/plan-fit:hidden", }, 4: { root: "@[48rem]/plan-fit:[--plan-fit-card:25%]", track: "@[48rem]/plan-fit:w-full", names: "@[48rem]/plan-fit:hidden", }, 5: { root: "@[60rem]/plan-fit:[--plan-fit-card:20%]", track: "@[60rem]/plan-fit:w-full", names: "@[60rem]/plan-fit:hidden", }, }; const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)); const formats = new Map<string, Intl.NumberFormat>(); function format(locale: string, options: Intl.NumberFormatOptions) { const key = locale + JSON.stringify(options); let formatter = formats.get(key); if (!formatter) { formatter = new Intl.NumberFormat(locale, options); formats.set(key, formatter); } return formatter; } /* ── types ──────────────────────────────────────────────── */ /** Extra usage past a plan's allowance, sold in packs. */ export interface PlanFitOverage { /** What one pack costs, in `currency`. */ price: number; /** How much usage one pack adds. */ per: number; } /** What a plan's button does: a link with `href`, or a button with `onClick`. */ export interface PlanFitCta { label: string; href?: string; onClick?: () => void; } export type PlanFitPrice = number | string; export interface PlanFitPlan { id: string; name: string; /** One or two lines under the name. */ pitch?: React.ReactNode; /** * The monthly price. Give one per billing option as `{ monthly: 29, yearly: 23 }`. * Text ("Custom") makes it a quoted plan: it only becomes the fit when no priced plan can take the usage. */ price: PlanFitPrice | Record<string, PlanFitPrice>; /** The usage the price covers each month. Leave it out on a quoted plan that takes any amount. */ included?: number; /** Extra usage past `included`, sold in packs: `{ price: 10, per: 1000 }`. Without it the plan stops at `included`. */ overage?: PlanFitOverage; /** * The most usage the plan takes, overage included; past it the plan is outgrown. Defaults to `included`, or with * `overage` to the next plan's `included` (no cap on the last plan). */ limit?: number; /** Small print under the price, one for all billing options or one per option. Overage replaces it while there is some. */ caption?: React.ReactNode | Record<string, React.ReactNode>; /** What the plan includes, under the button. */ features?: React.ReactNode[]; cta?: PlanFitCta; } export interface PlanFitBilling { /** Keys each plan's per-option `price` and `caption`. */ id: string; label: string; /** A short accent note after the label, e.g. "−20%". */ note?: React.ReactNode; } export interface PlanFitProps extends Omit< React.HTMLAttributes<HTMLDivElement>, "defaultValue" | "onChange" | "children" > { /** The plans, smallest allowance first. Each gets a card and a stretch of the rail; up to five fit in a row. */ plans: PlanFitPlan[]; /** What usage is counted in, plural: "credits", "minutes". Used in the readout, the captions and the slider's name. */ unit?: string; /** The line under the big number. Defaults to `<unit> per month`. */ readoutLabel?: React.ReactNode; /** The usage the thumb starts at. Defaults to a point 60% along the second plan's stretch. */ defaultValue?: number; /** The usage at the rail's left end. */ min?: number; /** The usage at the rail's right end. Defaults to the last plan's allowance, or four times the largest one when the last plan is quoted. */ max?: number; /** Options for a billing switch beside the readout, as `{ id, label, note? }`. Leave it out for one price per plan. */ billing?: PlanFitBilling[]; /** The billing option selected at first. Defaults to the first. */ defaultBilling?: string; /** An ISO currency code for every price. */ currency?: string; /** The locale numbers and prices are formatted in. */ locale?: string; /** Shown after each price. */ period?: string; /** The tag on the highlighted plan, also read out to screen readers. */ fitLabel?: string; /** Called with the usage whenever it changes. */ onValueChange?: (usage: number) => void; /** Called with the best plan's id once on mount, then whenever a different plan becomes the best fit. */ onFitChange?: (planId: string) => void; /** Seconds the highlight takes to glide over to a new best plan (the spring's visual duration). */ glideDuration?: number; /** How much the highlight overshoots as it lands on a new plan, from 0 (none) to 1. */ glideBounce?: number; /** Seconds every price takes to count to its figure for another billing option. */ countDuration?: number; } /* ── the scale: rail position ⇄ usage ───────────────────── */ // The rail is cut into one equal stretch per plan, from the previous plan's // allowance to this one's. Inside a stretch usage moves on a log scale (linear // when it starts at 0), so 10 and 10,000 are both a short drag away. function createScale(plans: PlanFitPlan[], min: number, max: number) { const n = plans.length; const bounds = [min]; for (let i = 0; i < n - 1; i++) bounds.push(Math.max(bounds[i], plans[i].included ?? bounds[i])); bounds.push(Math.max(bounds[n - 1], max)); const along = (lo: number, hi: number, f: number) => (lo > 0 && hi > lo ? lo * (hi / lo) ** f : lo + (hi - lo) * f); const back = (lo: number, hi: number, usage: number) => hi <= lo ? 0 : lo > 0 ? Math.log(usage / lo) / Math.log(hi / lo) : (usage - lo) / (hi - lo); // Two significant figures reads 27,000, not 27,384. Allowances snap exactly, // so a thumb on a tick is on the allowance and never a step over it. const round = (raw: number) => { const size = Math.max(1, 10 ** (Math.floor(Math.log10(Math.max(raw, 1))) - FIT.figures + 1)); const exact = bounds.find((bound) => Math.abs(bound - raw) <= size / 2); return exact ?? Math.round(raw / size) * size; }; return { bounds, toUsage(p: number) { const s = clamp(p, 0, 1) * n; const i = Math.min(n - 1, Math.floor(s)); return clamp(round(along(bounds[i], bounds[i + 1], s - i)), min, bounds[n]); }, toPos(usage: number) { const u = clamp(usage, min, bounds[n]); const i = Math.max(0, bounds.findIndex((bound, k) => k > 0 && u <= bound) - 1); return (i + clamp(back(bounds[i], bounds[i + 1], u), 0, 1)) / n; }, }; } /* ── pricing ────────────────────────────────────────────── */ const isPerOption = <T,>(value: unknown): value is Record<string, T> => !!value && typeof value === "object" && !Array.isArray(value) && !React.isValidElement(value); function priceFor(plan: PlanFitPlan, billing?: string): PlanFitPrice { if (!isPerOption<PlanFitPrice>(plan.price)) return plan.price; return (billing !== undefined ? plan.price[billing] : undefined) ?? Object.values(plan.price)[0]; } /** The price between two billing options while the switch counts from one to the other. */ function priceAt(plan: PlanFitPlan, billing: PlanFitBilling[] | undefined, at: number): PlanFitPrice { if (!billing?.length) return priceFor(plan); const a = clamp(Math.floor(at), 0, billing.length - 1); const b = Math.min(billing.length - 1, a + 1); const from = priceFor(plan, billing[a].id); const to = priceFor(plan, billing[b].id); if (typeof from !== "number" || typeof to !== "number") return at - a < 0.5 ? from : to; return from + (to - from) * (at - a); } type Reading = | { kind: "quote" } | { kind: "outgrown"; limit: number } | { kind: "within"; usage: number; included: number; total: number } | { kind: "over"; usage: number; included: number; over: number; extra: number; total: number; }; /** The most a plan takes: its own `limit`, else its allowance, else (with overage) up to the next plan's allowance. */ function limitOf(plans: PlanFitPlan[], i: number) { const plan = plans[i]; if (plan.limit !== undefined) return plan.limit; if (plan.included === undefined) return Infinity; if (!plan.overage) return plan.included; return Math.max(plan.included, plans[i + 1]?.included ?? Infinity); } /** How a plan copes with a usage, at a base price. */ function read(plan: PlanFitPlan, base: PlanFitPrice, usage: number, limit: number): Reading { if (typeof base !== "number" || plan.included === undefined) return { kind: "quote" }; const included = plan.included; if (usage > limit || (usage > included && !plan.overage)) return { kind: "outgrown", limit }; if (usage <= included || !plan.overage) return { kind: "within", usage, included, total: base }; const packs = Math.ceil((usage - included) / plan.overage.per); const extra = packs * plan.overage.price; return { kind: "over", usage, included, over: packs * plan.overage.per, extra, total: base + extra, }; } /** The cheapest plan that takes the usage; on a tie, the bigger one. Quoted plans step in when no priced plan can. */ function fitFor(plans: PlanFitPlan[], usage: number, billing?: string) { let best = -1; let cheapest = Infinity; plans.forEach((plan, i) => { const reading = read(plan, priceFor(plan, billing), usage, limitOf(plans, i)); if ((reading.kind === "within" || reading.kind === "over") && reading.total <= cheapest) { best = i; cheapest = reading.total; } }); if (best >= 0) return best; const quoted = plans.findIndex( (plan, i) => typeof priceFor(plan, billing) !== "number" && usage <= limitOf(plans, i), ); return quoted >= 0 ? quoted : plans.length - 1; } const hasCents = (plan: PlanFitPlan) => [ ...(isPerOption<PlanFitPrice>(plan.price) ? Object.values(plan.price) : [plan.price]), plan.overage?.price ?? 0, ].some((price) => typeof price === "number" && !Number.isInteger(price)); /* ── reduced motion ─────────────────────────────────────── */ // Read straight from the media query rather than through motion's hook, so the // server and the first client render agree and dev builds don't log a warning. const REDUCED = "(prefers-reduced-motion: reduce)"; const subscribeReduced = (onChange: () => void) => { const query = window.matchMedia(REDUCED); query.addEventListener("change", onChange); return () => query.removeEventListener("change", onChange); }; function useReduced() { return React.useSyncExternalStore( subscribeReduced, () => window.matchMedia(REDUCED).matches, () => false, ); } /* ── BillingSwitch ──────────────────────────────────────── */ // A two-or-more way switch; the pill glides under the chosen option. function BillingSwitch({ options, index, reduced, onSelect, }: { options: PlanFitBilling[]; index: number; reduced: boolean; onSelect: (index: number) => void; }) { const buttons = React.useRef<(HTMLButtonElement | null)[]>([]); const onKeyDown = (event: React.KeyboardEvent) => { const step = event.key === "ArrowRight" || event.key === "ArrowDown" ? 1 : event.key === "ArrowLeft" || event.key === "ArrowUp" ? -1 : 0; if (!step) return; event.preventDefault(); const next = (index + step + options.length) % options.length; onSelect(next); buttons.current[next]?.focus(); }; return ( <div role="radiogroup" aria-label="Billing" onKeyDown={onKeyDown} className="relative grid shrink-0 auto-cols-fr grid-flow-col rounded-full bg-zinc-950/[0.05] p-1 text-[13px] font-medium dark:bg-white/[0.07]" > <motion.span aria-hidden initial={false} animate={{ transform: `translateX(${index * 100}%)` }} transition={reduced ? { duration: 0 } : TIMING.slide} style={{ width: `calc((100% - 0.5rem) / ${options.length})` }} className="absolute inset-y-1 left-1 rounded-full bg-white shadow-[0_1px_2px_rgb(0_0_0/0.08),0_0_0_1px_rgb(0_0_0/0.04)] dark:bg-zinc-700 dark:shadow-none" /> {options.map((option, i) => ( <button key={option.id} ref={(node) => { buttons.current[i] = node; }} type="button" role="radio" aria-checked={i === index} tabIndex={i === index ? 0 : -1} onClick={() => onSelect(i)} className={cn( `relative flex items-center justify-center gap-1.5 rounded-full px-3.5 py-1.5 whitespace-nowrap transition-colors ${TIMING.option} outline-none focus-visible:ring-2 focus-visible:ring-(--plan-fit-accent)`, i === index ? "text-zinc-950 dark:text-white" : "text-zinc-500 hover:text-zinc-800 dark:text-zinc-400 dark:hover:text-zinc-200", )} > {option.label} {option.note && <span className="text-xs font-semibold text-(--plan-fit-accent-text)">{option.note}</span>} </button> ))} </div> ); } /* ── Rail ───────────────────────────────────────────────── */ // The slider. The thumb is the keyboard target; the whole 44px strip takes // the pointer. Position is a motion value from 0 to 1, usage is derived from it. function Rail({ plans, scale, pos, usage, fit, unit, locale, reduced, fitLabel, billingId, namesClassName, }: { plans: PlanFitPlan[]; scale: ReturnType<typeof createScale>; pos: MotionValue<number>; usage: MotionValue<number>; fit: number; unit: string; locale: string; reduced: boolean; fitLabel: string; billingId?: string; namesClassName?: string; }) { const n = plans.length; const railRef = React.useRef<HTMLDivElement>(null); const thumbRef = React.useRef<HTMLDivElement>(null); const drag = React.useRef<{ id: number; touch: boolean; moved: boolean; /** px between the pointer and the ball's center when the ball was taken hold of. */ offset: number; x: number; y: number; /** A finger only drags once it's clearly moving sideways; until then it may be a scroll. */ engaged: boolean; } | null>(null); const [dragging, setDragging] = React.useState(false); // The focus halo is for keyboard use only. A press focuses the thumb from code (so arrows work after a click), // which browsers can count as focus-visible, so the halo is tracked here instead. const [ring, setRing] = React.useState(false); const pressedAt = React.useRef(-Infinity); const fill = useTransform(pos, (p) => `scaleX(${p})`); // In container units of the rail, so the small thumb moves and no full-width layer is pushed past the edge. const thumbX = useTransform(pos, (p) => `translateX(calc(${p} * 100cqw))`); const full = format(locale, {}); const compact = format(locale, { notation: "compact", maximumFractionDigits: 1, }); const valueText = (u: number) => `${full.format(u)} ${unit}, ${fitLabel}: ${plans[fitFor(plans, u, billingId)].name}`; // Kept out of React: the slider's numbers change every frame of a drag. useMotionValueEvent(usage, "change", (u) => { thumbRef.current?.setAttribute("aria-valuenow", String(u)); thumbRef.current?.setAttribute("aria-valuetext", valueText(u)); }); // Where a glide in flight is headed, so a second PageUp goes on from there. const heading = React.useRef<number | null>(null); const setTo = (p: number) => { heading.current = null; pos.stop(); pos.set(clamp(p, 0, 1)); }; const glideTo = (p: number, transition: typeof TIMING.jump | typeof TIMING.press) => { if (reduced) return setTo(p); const target = clamp(p, 0, 1); heading.current = target; animate(pos, target, transition).then(() => { if (heading.current === target) heading.current = null; }); }; const onBallAt = (clientX: number) => { const rect = railRef.current?.getBoundingClientRect(); return !!rect && Math.abs(clientX - (rect.left + pos.get() * rect.width)) <= FIT.grab; }; const at = (clientX: number) => { const rect = railRef.current?.getBoundingClientRect(); return rect ? clamp((clientX - rect.left) / rect.width, 0, 1) : pos.get(); }; const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => { if (event.button !== 0) return; const touch = event.pointerType === "touch"; const rect = railRef.current?.getBoundingClientRect(); const center = rect ? rect.left + pos.get() * rect.width : event.clientX; const onBall = Math.abs(event.clientX - center) <= FIT.grab; drag.current = { id: event.pointerId, touch, moved: false, offset: onBall ? event.clientX - center : 0, x: event.clientX, y: event.clientY, engaged: !touch, }; event.currentTarget.setPointerCapture(event.pointerId); pressedAt.current = event.timeStamp; setRing(false); thumbRef.current?.focus({ preventScroll: true }); setDragging(true); if (onBall) { // Taking hold of the ball: it stays where it is until the pointer moves. heading.current = null; pos.stop(); } else if (!touch) { // A finger might be starting a scroll, so touch waits to see which way it goes. glideTo(at(event.clientX), TIMING.press); } }; const onPointerMove = (event: React.PointerEvent<HTMLDivElement>) => { const current = drag.current; if (!current || current.id !== event.pointerId) return; if (!current.engaged) { const dx = Math.abs(event.clientX - current.x); const dy = Math.abs(event.clientY - current.y); if (dx < FIT.slop && dy < FIT.slop) return; if (dy >= dx) { // A scroll: let the page have it. drag.current = null; setDragging(false); return; } current.engaged = true; } current.moved = true; setTo(at(event.clientX - current.offset)); }; const onPointerEnd = (event: React.PointerEvent<HTMLDivElement>) => { const current = drag.current; if (!current || current.id !== event.pointerId) return; if ( event.type === "pointerup" && current.touch && !current.moved && current.offset === 0 && !onBallAt(event.clientX) ) glideTo(at(event.clientX), TIMING.press); drag.current = null; setDragging(false); }; // The allowance ticks, as rail positions. const edges = scale.bounds.slice(1, n).map((_, k) => (k + 1) / n); const nextEdge = (p: number, dir: 1 | -1) => { const ends = [0, ...edges, 1]; return dir > 0 ? (ends.find((e) => e > p + 1e-4) ?? 1) : ([...ends].reverse().find((e) => e < p - 1e-4) ?? 0); }; const onKeyDown = (event: React.KeyboardEvent) => { setRing(true); const p = heading.current ?? pos.get(); const up = event.key === "ArrowRight" || event.key === "ArrowUp"; const down = event.key === "ArrowLeft" || event.key === "ArrowDown"; if ((up || down) && !event.shiftKey) { event.preventDefault(); // Step until the rounded usage actually changes, so no key press is lost. const from = scale.toUsage(p); let q = p; do q = clamp(q + (up ? FIT.step : -FIT.step), 0, 1); while (scale.toUsage(q) === from && q > 0 && q < 1); setTo(q); return; } const target = event.key === "Home" ? 0 : event.key === "End" ? 1 : event.key === "PageUp" || (up && event.shiftKey) ? nextEdge(p, 1) : event.key === "PageDown" || (down && event.shiftKey) ? nextEdge(p, -1) : null; if (target === null) return; event.preventDefault(); glideTo(target, TIMING.jump); }; // A plan's name jumps to its allowance; a quoted plan's to the middle of its stretch. const jumpTo = (i: number) => { const included = plans[i].included; const quoted = typeof priceFor(plans[i]) !== "number" || included === undefined; glideTo(quoted ? (i + 0.5) / n : scale.toPos(included), TIMING.jump); }; const now = usage.get(); return ( <div> <div ref={railRef} onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerEnd} onPointerCancel={onPointerEnd} className="@container relative h-11 cursor-pointer touch-pan-y select-none" > <div className="absolute inset-x-0 top-1/2 h-1.5 -translate-y-1/2 overflow-hidden rounded-full bg-zinc-950/[0.08] dark:bg-white/10"> <motion.div style={{ transform: fill }} className="absolute inset-0 origin-left bg-(--plan-fit-accent)" /> </div> {edges.map((edge) => ( <span key={edge} aria-hidden style={{ left: `${edge * 100}%` }} className="absolute top-1/2 h-3.5 w-px -translate-x-1/2 -translate-y-1/2 bg-zinc-950/25 dark:bg-white/25" /> ))} <motion.div ref={thumbRef} role="slider" tabIndex={0} aria-label={`${unit[0]?.toUpperCase() ?? ""}${unit.slice(1)} per month`} aria-valuemin={scale.bounds[0]} aria-valuemax={scale.bounds[n]} aria-valuenow={now} aria-valuetext={valueText(now)} aria-orientation="horizontal" onKeyDown={onKeyDown} onFocus={(event) => setRing(event.timeStamp - pressedAt.current > TIMING.pressFocus)} onBlur={() => setRing(false)} data-dragging={dragging || undefined} data-ring={ring || undefined} style={{ transform: thumbX }} className="group/thumb absolute top-1/2 left-0 size-[22px] -translate-1/2 rounded-full outline-none" > {/* The ball grows while held. It's a child so the growth can't scale the slide along the rail. */} <span aria-hidden className={`block size-full rounded-full bg-(--plan-fit-accent) shadow-[0_0_0_3px_var(--plan-fit-surface),0_2px_6px_rgb(0_0_0/0.22)] transition-[scale,box-shadow] ${TIMING.ball} group-data-[ring]/thumb:shadow-[0_0_0_3px_var(--plan-fit-surface),0_0_0_7px_color-mix(in_oklab,var(--plan-fit-accent)_35%,transparent)] group-data-[dragging]/thumb:scale-[1.15] dark:shadow-[0_0_0_3px_var(--plan-fit-surface),0_2px_6px_rgb(0_0_0/0.5)] dark:group-data-[ring]/thumb:shadow-[0_0_0_3px_var(--plan-fit-surface),0_0_0_7px_color-mix(in_oklab,var(--plan-fit-accent)_45%,transparent)]`} /> </motion.div> </div> {/* Allowances on their ticks, plan names under their stretches. */} <div aria-hidden className="relative h-4 text-[11px] font-medium text-zinc-400 tabular-nums dark:text-zinc-500"> {edges.map((edge, k) => ( <span key={edge} style={{ left: `${edge * 100}%` }} className="absolute top-0 -translate-x-1/2"> {compact.format(scale.bounds[k + 1])} </span> ))} </div> <div className={cn("mt-1 grid", namesClassName)} style={{ gridTemplateColumns: `repeat(${n}, minmax(0, 1fr))` }}> {plans.map((plan, i) => ( <button key={plan.id} type="button" tabIndex={-1} onClick={() => jumpTo(i)} className={cn( `mx-auto max-w-full truncate rounded-md px-1.5 py-0.5 text-[13px] transition-colors ${TIMING.accentEase}`, i === fit ? `font-semibold text-(--plan-fit-accent-text) ${TIMING.accentIn}` : `text-zinc-500 ${TIMING.accentOut} hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-white`, )} > {plan.name} </button> ))} </div> </div> ); } /* ── PlanCard ───────────────────────────────────────────── */ // One plan. Its price, caption and meter are motion values computed from the // usage and the billing switch, so dragging never re-renders it. const PlanCard = React.memo(function PlanCard({ plan, fit, usage, billingAt, billing, billingId, unit, currency, locale, period, fitLabel, limit, }: { plan: PlanFitPlan; fit: boolean; usage: MotionValue<number>; billingAt: MotionValue<number>; billing?: PlanFitBilling[]; billingId?: string; unit: string; currency: string; locale: string; period: string; fitLabel: string; limit: number; }) { const cents = hasCents(plan); const money = format(locale, { style: "currency", currency, minimumFractionDigits: cents ? 2 : 0, maximumFractionDigits: cents ? 2 : 0, }); const full = format(locale, {}); const compact = format(locale, { notation: "compact", maximumFractionDigits: 1, }); const reading = (u: number, b: number) => read(plan, priceAt(plan, billing, b), u, limit); const inputs = [usage, billingAt]; const price = useTransform(inputs, ([u, b]: number[]) => { const base = priceAt(plan, billing, b); if (typeof base !== "number") return base; const r = read(plan, base, u, limit); return money.format(r.kind === "over" ? r.total : base); }); const overage = useTransform(inputs, ([u, b]: number[]) => { const r = reading(u, b); return r.kind === "over" ? `+${money.format(r.extra)} for ${compact.format(r.over)} more ${unit}` : ""; }); const showOverage = useTransform(overage, (text) => (text ? 1 : 0)); const showCaption = useTransform(overage, (text) => (text ? 0 : 1)); const dim = useTransform(inputs, ([u, b]: number[]) => (reading(u, b).kind === "outgrown" ? 0.45 : 1)); // The meter's full width is the larger of usage and allowance. const meter = useTransform(inputs, ([u, b]: number[]) => { const r = reading(u, b); if (r.kind === "within") return `${compact.format(r.usage)} of ${compact.format(r.included)} ${unit}`; if (r.kind === "over") return `${compact.format(r.usage - r.included)} ${unit} over`; if (r.kind === "outgrown") return `Tops out at ${compact.format(r.limit)}`; return "Any volume"; }); const fill = useTransform(inputs, ([u, b]: number[]) => { const r = reading(u, b); const share = r.kind === "within" ? r.usage / r.included : r.kind === "over" ? r.included / r.usage : 1; return `scaleX(${clamp(share, 0, 1)})`; }); const tick = useTransform(inputs, ([u, b]: number[]) => { const r = reading(u, b); return `translateX(calc(${r.kind === "over" ? r.included / r.usage : 1} * 100cqw))`; }); const hatch = useTransform(inputs, ([u, b]: number[]) => (reading(u, b).kind === "over" ? 1 : 0)); const quoted = typeof priceFor(plan, billingId) !== "number" || plan.included === undefined; const caption = isPerOption<React.ReactNode>(plan.caption) ? billingId !== undefined ? plan.caption[billingId] : Object.values(plan.caption)[0] : plan.caption; const Cta = plan.cta?.href ? "a" : "button"; return ( <motion.div data-plan-fit-card="" data-fit={fit} style={{ opacity: dim }} className={`group/card relative flex w-(--plan-fit-card) shrink-0 snap-center flex-col p-5 transition-opacity ${TIMING.outgrow} @[64rem]/plan-fit:p-6`} > <h3 className="truncate pr-16 text-[15px] font-semibold tracking-[-0.01em]"> {plan.name} {fit && <span className="sr-only"> ({fitLabel})</span>} </h3> <span aria-hidden className={cn( `pointer-events-none absolute top-5 right-5 rounded-full bg-(--plan-fit-accent) px-2 py-0.5 text-[11px] leading-4 font-semibold text-(--plan-fit-accent-ink) transition-[opacity,scale] ${TIMING.accentEase} motion-reduce:scale-100 @[64rem]/plan-fit:top-6 @[64rem]/plan-fit:right-6`, fit ? `scale-100 opacity-100 ${TIMING.accentIn}` : `scale-90 opacity-0 ${TIMING.accentOut}`, )} > {fitLabel} </span> {plan.pitch && ( <p className="mt-1.5 line-clamp-2 min-h-10 text-[13px] leading-5 text-pretty text-zinc-500 dark:text-zinc-400"> {plan.pitch} </p> )} <p className="mt-5 flex items-baseline gap-1 whitespace-nowrap"> <motion.span className="text-[1.875rem] leading-none font-semibold tracking-[-0.035em] tabular-nums @[64rem]/plan-fit:text-[2.125rem]"> {price} </motion.span> {!quoted && <span className="text-[13px] text-zinc-500 dark:text-zinc-400">{period}</span>} </p> <p className="relative mt-2 grid h-4 text-xs leading-4"> <motion.span style={{ opacity: showCaption }} className="col-start-1 row-start-1 truncate text-zinc-500 dark:text-zinc-400" > {caption} </motion.span> <motion.span style={{ opacity: showOverage }} className={`col-start-1 row-start-1 truncate font-medium text-zinc-700 transition-colors group-data-[fit=true]/card:text-(--plan-fit-accent-text) dark:text-zinc-300 ${TIMING.accent}`} > {overage} </motion.span> </p> {/* The meter: usage against this plan's allowance. */} <div className="mt-5"> <motion.p className="text-xs font-medium text-zinc-600 tabular-nums dark:text-zinc-300">{meter}</motion.p> <div aria-hidden className={cn("@container relative mt-2 h-1.5", quoted && "invisible")}> <div className="absolute inset-0 overflow-hidden rounded-full bg-zinc-950/[0.07] dark:bg-white/[0.08]"> <motion.div style={{ opacity: hatch }} className={cn( "absolute inset-0 bg-[repeating-linear-gradient(-45deg,currentColor_0_2px,transparent_2px_5px)] text-zinc-400 transition-colors group-data-[fit=true]/card:text-(--plan-fit-accent) dark:text-zinc-500", TIMING.accent, )} /> <motion.div style={{ transform: fill }} className={cn( "absolute inset-0 origin-left bg-zinc-800 transition-colors group-data-[fit=true]/card:bg-(--plan-fit-accent) dark:bg-zinc-300", TIMING.accent, quoted && "opacity-0", )} /> </div> {!quoted && ( <motion.span style={{ transform: tick }} className="absolute -inset-y-[3px] left-0 w-0.5 -translate-x-1/2 rounded-full bg-zinc-950/75 dark:bg-white/80" /> )} </div> </div> {plan.cta && ( <Cta {...(plan.cta.href ? { href: plan.cta.href } : { type: "button" as const })} onClick={plan.cta.onClick} className={cn( "mt-6 flex h-9 items-center justify-center rounded-full px-4 text-[13px] font-semibold outline-none focus-visible:ring-2 focus-visible:ring-(--plan-fit-accent) focus-visible:ring-offset-2 focus-visible:ring-offset-(--plan-fit-surface) active:scale-[0.97]", // The fill follows the accent timing; hover and press answer at once. fit ? `bg-(--plan-fit-accent) text-(--plan-fit-accent-ink) ${TIMING.ctaIn} hover:brightness-[0.94]` : `bg-zinc-950/[0.05] text-zinc-900 ${TIMING.ctaOut} hover:bg-zinc-950/[0.09] dark:bg-white/[0.07] dark:text-white dark:hover:bg-white/[0.12]`, )} > {plan.cta.label} </Cta> )} <div className="mt-6 border-t border-zinc-950/[0.07] pt-5 dark:border-white/[0.08]"> <p className="text-[13px] font-medium"> {plan.included !== undefined ? `${full.format(plan.included)} ${unit}` : `Any number of ${unit}`} </p> {plan.features && plan.features.length > 0 && ( <ul className="mt-3 space-y-2 text-[13px] leading-5 text-zinc-600 dark:text-zinc-400"> {plan.features.map((feature, i) => ( <li key={i} className="flex gap-2"> <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth={1.8} aria-hidden className={cn( "mt-0.5 size-4 shrink-0 text-zinc-400 transition-colors group-data-[fit=true]/card:text-(--plan-fit-accent) dark:text-zinc-500", TIMING.accent, )} > <path d="m3.5 8.5 3 3 6-7" strokeLinecap="round" strokeLinejoin="round" /> </svg> <span className="min-w-0">{feature}</span> </li> ))} </ul> )} </div> </motion.div> ); }); /* ── PlanFit ────────────────────────────────────────────── */ export function PlanFit({ plans, unit = "credits", readoutLabel, defaultValue, min = 0, max, billing, defaultBilling, currency = "USD", locale = "en-US", period = "/mo", fitLabel = "Best fit", onValueChange, onFitChange, glideDuration = TIMING.glide.visualDuration, glideBounce = TIMING.glide.bounce, countDuration = TIMING.count.duration, className, ...props }: PlanFitProps) { const n = plans.length; const largest = Math.max(0, ...plans.map((plan) => plan.included ?? 0)); const top = max ?? plans[n - 1]?.included ?? Math.max(1, largest * 4); const scale = React.useMemo(() => createScale(plans, min, top), [plans, min, top]); const reduced = useReduced(); const pos = useMotionValue( defaultValue !== undefined ? scale.toPos(defaultValue) : (Math.min(1, n - 1) + FIT.start) / Math.max(1, n), ); const usage = useMotionValue(scale.toUsage(pos.get())); const [billingIndex, setBillingIndex] = React.useState(() => Math.max(0, billing?.findIndex((option) => option.id === defaultBilling) ?? 0), ); const billingId = billing?.[billingIndex]?.id; const billingAt = useMotionValue(billingIndex); const [fit, setFit] = React.useState(() => fitFor(plans, usage.get(), billingId)); const [moved, setMoved] = React.useState(false); const fitRef = React.useRef(fit); const settle = (next: number) => { if (next === fitRef.current) return; fitRef.current = next; setFit(next); setMoved(true); onFitChange?.(plans[next].id); }; useMotionValueEvent(pos, "change", (p) => usage.set(scale.toUsage(p))); useMotionValueEvent(usage, "change", (u) => { onValueChange?.(u); settle(fitFor(plans, u, billingId)); }); const selectBilling = (i: number) => { if (!billing || i === billingIndex) return; setBillingIndex(i); if (reduced) { billingAt.stop(); billingAt.set(i); } else { animate(billingAt, i, { ...TIMING.count, duration: Math.max(0, countDuration) }); } settle(fitFor(plans, usage.get(), billing[i].id)); }; // Tell the parent which plan fits at first, so a button beside it can name it. const reportFit = React.useRef(onFitChange); React.useEffect(() => { reportFit.current = onFitChange; }); const firstFit = React.useRef(plans[fit]?.id); React.useEffect(() => { if (firstFit.current) reportFit.current?.(firstFit.current); }, []); // In the swipe row, bring each new fit to the middle. The first time, without a scroll. const scrollerRef = React.useRef<HTMLDivElement>(null); const scrolled = React.useRef(false); React.useEffect(() => { const scroller = scrollerRef.current; if (!scroller) return; const instant = !scrolled.current || reduced; scrolled.current = true; if (scroller.scrollWidth <= scroller.clientWidth + 1) return; const card = scroller.querySelectorAll<HTMLElement>("[data-plan-fit-card]")[fit]; if (!card) return; scroller.scrollTo({ left: card.offsetLeft - (scroller.clientWidth - card.offsetWidth) / 2, behavior: instant ? "auto" : "smooth", }); }, [fit, reduced]); const row = ROW[n] ?? { root: "", track: "", names: "" }; const readout = useTransform(usage, (u) => format(locale, {}).format(u)); if (n === 0) return null; return ( <div className={cn( "@container/plan-fit relative w-full text-zinc-950 [--plan-fit-accent-ink:#0a0a0a] [--plan-fit-accent-text:color-mix(in_oklab,var(--plan-fit-accent)_82%,#000)] [--plan-fit-accent:#ff4f12] [--plan-fit-card:min(17rem,80cqi)] [--plan-fit-surface:#ffffff] dark:text-white dark:[--plan-fit-accent-text:var(--plan-fit-accent)] dark:[--plan-fit-surface:#09090b]", className, )} {...props} > {/* A container query can't style its own container, so the row width is set one level down. */} <div className={row.root}> <div className="flex flex-wrap items-end justify-between gap-x-6 gap-y-4"> <div className="min-w-0"> <motion.p className="text-[2.75rem] leading-none font-semibold tracking-[-0.045em] tabular-nums @[40rem]/plan-fit:text-[3.5rem]"> {readout} </motion.p> <p className="mt-2.5 text-sm text-zinc-500 dark:text-zinc-400">{readoutLabel ?? `${unit} per month`}</p> </div> {billing && billing.length > 1 && ( <BillingSwitch options={billing} index={billingIndex} reduced={reduced} onSelect={selectBilling} /> )} </div> <div className="mt-6"> <Rail plans={plans} scale={scale} pos={pos} usage={usage} fit={fit} unit={unit} locale={locale} reduced={reduced} fitLabel={fitLabel} billingId={billingId} namesClassName={row.names} /> </div> <div ref={scrollerRef} className="mt-8 snap-x snap-mandatory overflow-x-auto overscroll-x-contain rounded-2xl bg-(--plan-fit-surface) shadow-[0_0_0_1px_rgb(9_9_11/0.09),0_1px_2px_rgb(9_9_11/0.04)] [scrollbar-width:none] dark:shadow-[0_0_0_1px_rgb(255_255_255/0.1)] [&::-webkit-scrollbar]:hidden" > <div className={cn("relative flex w-max", row.track)}> {/* Dividers under the highlight, so its ring is never crossed by one. */} <div aria-hidden className="pointer-events-none absolute inset-0 flex"> {plans.map((plan, i) => ( <div key={plan.id} className={cn( "w-(--plan-fit-card) shrink-0", i > 0 && "border-l border-zinc-950/[0.08] dark:border-white/[0.09]", )} /> ))} </div> <motion.div key={reduced ? `fit-${fit}` : "fit"} aria-hidden initial={reduced && moved ? { opacity: 0, transform: `translateX(${fit * 100}%)` } : false} animate={{ opacity: 1, transform: `translateX(${fit * 100}%)` }} transition={ reduced ? TIMING.fade : { ...TIMING.glide, visualDuration: Math.max(0.01, glideDuration), bounce: clamp(glideBounce, 0, 1) } } className="pointer-events-none absolute inset-y-0 left-0 w-(--plan-fit-card) rounded-2xl bg-[linear-gradient(to_bottom,color-mix(in_oklab,var(--plan-fit-accent)_9%,var(--plan-fit-surface)),color-mix(in_oklab,var(--plan-fit-accent)_3%,var(--plan-fit-surface))_70%)] shadow-[inset_0_0_0_1.5px_var(--plan-fit-accent),0_18px_40px_-24px_color-mix(in_oklab,var(--plan-fit-accent)_70%,transparent)] will-change-transform dark:bg-[linear-gradient(to_bottom,color-mix(in_oklab,var(--plan-fit-accent)_15%,var(--plan-fit-surface)),color-mix(in_oklab,var(--plan-fit-accent)_5%,var(--plan-fit-surface))_70%)]" ></motion.div> {plans.map((plan, i) => ( <PlanCard key={plan.id} plan={plan} fit={i === fit} usage={usage} billingAt={billingAt} billing={billing} billingId={billingId} unit={unit} currency={currency} locale={locale} period={period} fitLabel={fitLabel} limit={limitOf(plans, i)} /> ))} </div> </div> </div> <p aria-live="polite" className="sr-only"> {fitLabel}: {plans[fit]?.name} </p> </div> ); }Update the import paths
The file imports
cnfrom@/lib/utils. Change the path if your project uses a different alias.
Examples
Finished sections built with Plan Fit. Each one installs whole, with the component included.
Capped Tiers
A newsletter tool priced by list size, where each plan stops at its cap and the next one takes over.
2,400
readers on your list
Your plan: Creator
"use client";
import { PlanFit, type PlanFitPlan } from "@/components/ui/plan-fit";
// A newsletter tool priced by list size. No overage: each plan stops at its
// cap, so a plan the list has outgrown fades and the next one takes over.
const PLANS: PlanFitPlan[] = [
{
id: "starter",
name: "Starter",
pitch: "Your first issue, and the hundred after it.",
price: 0,
included: 1000,
caption: "Free forever",
features: ["Unlimited sends", "Signup forms", "Tinta badge in the footer"],
cta: { label: "Start writing", href: "#start" },
},
{
id: "creator",
name: "Creator",
pitch: "For writers turning readers into a business.",
price: 15,
included: 10000,
caption: "Billed monthly",
features: ["Paid subscriptions", "Your own domain", "Referral rewards"],
cta: { label: "Go Creator", href: "#creator" },
},
{
id: "publisher",
name: "Publisher",
pitch: "For a masthead with editors and sponsors.",
price: 59,
included: 50000,
caption: "Billed monthly",
features: ["Five editor seats", "Sponsor slots", "Priority sending"],
cta: { label: "Go Publisher", href: "#publisher" },
},
];
export function PlanFitAudienceDemo() {
return (
<div className="w-full max-w-4xl">
<PlanFit
plans={PLANS}
unit="subscribers"
readoutLabel="readers on your list"
defaultValue={2400}
period="/mo"
fitLabel="Your plan"
className="[--plan-fit-accent-ink:#ffffff] [--plan-fit-accent:#2f5bff] dark:[--plan-fit-accent-ink:#ffffff] dark:[--plan-fit-accent:#5b7cff]"
/>
</div>
);
}
Add this moment from KeyframeUI to our project and adapt it to our content. Plan Fit: A pricing grid with a usage slider over it, cut into one stretch per plan. Drag it and every card measures your usage against its allowance: past the limit the overage shows as a hatched stretch and that card's price climbs a pack at a time. The highlight glides to whichever plan is cheapest at that usage, so a smaller plan with overage can keep it until the next one costs less. This demo, Capped Tiers: A newsletter tool priced by list size, where each plan stops at its cap and the next one takes over. 1. Install it: npx shadcn@latest add @keyframeui/plan-fit-audience-demo (If the CLI can't find @keyframeui, use https://keyframeui.com/r/plan-fit-audience-demo.json instead.) It adds the demo, components/plan-fit-audience-demo.tsx, and the component, components/ui/plan-fit.tsx. 2. Make it ours: our copy, data, images and colors. Keep the structure the motion depends on. If we only need the effect, use <PlanFit> on its own. 3. These props set its feel. Change them before you change the file: - glideDuration (default 0.42): How long the highlight takes to glide over to a new best plan, in seconds (its spring's visual duration). - glideBounce (default 0.12): How far the highlight overshoots as it lands on a new plan, from 0 (not at all) to 1. - countDuration (default 0.35): How long every price takes to count to its new figure when the billing option changes, in seconds. 4. Don't change, in components/ui/plan-fit.tsx: - Usage, prices, meters and the readout are motion values derived from one `pos`, written into text nodes, `transform`s and the slider's ARIA values, so dragging never re-renders a card. - The highlight is one layer under the cards that glides by `transform` and turns from wherever it is; a card's accents arrive only as it lands and leave first, so two cards are never lit. - Pressing the ball never moves it: the drag keeps the offset it was taken hold at. A finger drags only once it moves sideways; a vertical move scrolls the page. - Reduced motion re-keys the highlight so it fades in at its new card; jumps, prices and the swipe row land at once, and dragging still moves everything 1:1. - The thumb is the `role="slider"` keyboard target, and a new fit is announced through the polite live region. Its timing is all in the TIMING block at the top of that file. Docs: https://keyframeui.com/components/plan-fit.md
Signup Step
The plan step of a signup flow in a narrow column: the cards become a swipe row and the button names the plan that fits.
Step 2 of 3
900
images generated per month
Best fit: Studio
"use client";
import * as React from "react";
import { PlanFit, type PlanFitPlan } from "@/components/ui/plan-fit";
// The plan step of a signup flow, in a narrow column: the cards become a
// swipe row, and the button under it always names the plan that fits.
const PLANS: PlanFitPlan[] = [
{
id: "hobby",
name: "Hobby",
pitch: "Side projects and weekend experiments.",
price: 0,
included: 200,
caption: "Watermarked",
features: ["Standard queue", "1024px renders"],
},
{
id: "studio",
name: "Studio",
pitch: "For a designer generating every day.",
price: 18,
included: 2000,
overage: { price: 5, per: 500 },
caption: "Billed monthly",
features: ["Fast queue", "4K upscales", "Commercial license"],
},
{
id: "team",
name: "Team",
pitch: "Shared styles for a whole studio.",
price: 90,
included: 15000,
overage: { price: 20, per: 2500 },
caption: "Billed monthly",
features: ["Shared style library", "Ten seats", "Usage by member"],
},
];
export function PlanFitSignupDemo() {
const [plan, setPlan] = React.useState("studio");
const name = PLANS.find((p) => p.id === plan)?.name;
return (
<div className="w-full max-w-sm rounded-[1.75rem] bg-white p-6 shadow-[0_0_0_1px_rgb(9_9_11/0.08),0_24px_48px_-24px_rgb(9_9_11/0.25)] dark:bg-zinc-900 dark:shadow-[0_0_0_1px_rgb(255_255_255/0.08)]">
<p className="text-xs font-medium text-zinc-500 dark:text-zinc-400">Step 2 of 3</p>
<h3 className="mt-1 text-lg font-semibold tracking-[-0.02em] text-zinc-950 dark:text-white">
How many images a month?
</h3>
<PlanFit
plans={PLANS}
unit="images"
readoutLabel="images generated per month"
defaultValue={900}
max={40000}
onFitChange={setPlan}
className="mt-6 [--plan-fit-accent-ink:#0a0a0a] [--plan-fit-accent:#ff2e88] [--plan-fit-surface:#ffffff] dark:[--plan-fit-surface:#18181b]"
/>
<button
type="button"
className="mt-6 h-11 w-full rounded-full bg-zinc-950 text-sm font-semibold text-white transition-[background-color,scale] duration-150 active:scale-[0.98] hover:bg-zinc-800 dark:bg-white dark:text-zinc-950 dark:hover:bg-zinc-200"
>
Continue with {name}
</button>
</div>
);
}
Add this moment from KeyframeUI to our project and adapt it to our content. Plan Fit: A pricing grid with a usage slider over it, cut into one stretch per plan. Drag it and every card measures your usage against its allowance: past the limit the overage shows as a hatched stretch and that card's price climbs a pack at a time. The highlight glides to whichever plan is cheapest at that usage, so a smaller plan with overage can keep it until the next one costs less. This demo, Signup Step: The plan step of a signup flow in a narrow column: the cards become a swipe row and the button names the plan that fits. 1. Install it: npx shadcn@latest add @keyframeui/plan-fit-signup-demo (If the CLI can't find @keyframeui, use https://keyframeui.com/r/plan-fit-signup-demo.json instead.) It adds the demo, components/plan-fit-signup-demo.tsx, and the component, components/ui/plan-fit.tsx. 2. Make it ours: our copy, data, images and colors. Keep the structure the motion depends on. If we only need the effect, use <PlanFit> on its own. 3. These props set its feel. Change them before you change the file: - glideDuration (default 0.42): How long the highlight takes to glide over to a new best plan, in seconds (its spring's visual duration). - glideBounce (default 0.12): How far the highlight overshoots as it lands on a new plan, from 0 (not at all) to 1. - countDuration (default 0.35): How long every price takes to count to its new figure when the billing option changes, in seconds. 4. Don't change, in components/ui/plan-fit.tsx: - Usage, prices, meters and the readout are motion values derived from one `pos`, written into text nodes, `transform`s and the slider's ARIA values, so dragging never re-renders a card. - The highlight is one layer under the cards that glides by `transform` and turns from wherever it is; a card's accents arrive only as it lands and leave first, so two cards are never lit. - Pressing the ball never moves it: the drag keeps the offset it was taken hold at. A finger drags only once it moves sideways; a vertical move scrolls the page. - Reduced motion re-keys the highlight so it fades in at its new card; jumps, prices and the swipe row land at once, and dragging still moves everything 1:1. - The thumb is the `role="slider"` keyboard target, and a new fit is announced through the polite live region. Its timing is all in the TIMING block at the top of that file. Docs: https://keyframeui.com/components/plan-fit.md
Props
| Prop | Type | Default | Description |
|---|---|---|---|
plans Required | PlanFitPlan[] | None | The plans, smallest allowance first, as { id, name, price, included?, overage?, limit?, pitch?, caption?, features?, cta? }. price is a number, one per billing option ({ monthly: 29, yearly: 23 }), or text like "Custom" for a quoted plan. overage: { price, per } sells extra usage in packs; without it a plan stops at included. Each plan gets a card and a stretch of the rail; up to five fit in a row. |
unit | string | "credits" | What usage is counted in, plural: "credits", "minutes". Used in the readout, the captions and the slider's name. |
readoutLabel | ReactNode | None | The line under the big number. Defaults to <unit> per month. |
defaultValue | number | None | The usage the thumb starts at. Defaults to a point 60% along the second plan's stretch. |
min | number | 0 | The usage at the rail's left end. |
max | number | None | The usage at the rail's right end. Defaults to the last plan's allowance, or four times the largest one when the last plan is quoted. |
billing | PlanFitBilling[] | None | Options for a billing switch beside the readout, as { id, label, note? }, e.g. { id: "yearly", label: "Yearly", note: "−20%" }. Switching counts every price to its new figure. Leave it out for one price per plan. |
defaultBilling | string | None | The billing option selected at first. Defaults to the first. |
currency | string | "USD" | An ISO currency code for every price. |
locale | string | "en-US" | The locale numbers and prices are formatted in. |
period | string | "/mo" | Shown after each price. |
fitLabel | string | "Best fit" | The tag on the highlighted plan, also read out to screen readers. |
onValueChange | (usage: number) => void | None | Called with the usage whenever it changes. |
onFitChange | (planId: string) => void | None | Called with the best plan's id once on mount, then whenever a different plan becomes the best fit. |
glideDuration | number | 0.42 | How long the highlight takes to glide over to a new best plan, in seconds (its spring's visual duration). |
glideBounce | number | 0.12 | How far the highlight overshoots as it lands on a new plan, from 0 (not at all) to 1. |
countDuration | number | 0.35 | How long every price takes to count to its new figure when the billing option changes, in seconds. |
className | string | None | Extra classes for the root. Theme it with CSS variables: --plan-fit-accent (default #ff4f12), --plan-fit-accent-ink for text on the accent, --plan-fit-accent-text for accent-colored words, and --plan-fit-surface for the cards' fill. |
plansRequiredType
PlanFitPlan[]The plans, smallest allowance first, as
{ id, name, price, included?, overage?, limit?, pitch?, caption?, features?, cta? }.priceis a number, one per billing option ({ monthly: 29, yearly: 23 }), or text like "Custom" for a quoted plan.overage: { price, per }sells extra usage in packs; without it a plan stops atincluded. Each plan gets a card and a stretch of the rail; up to five fit in a row.unitType
stringDefault"credits"What usage is counted in, plural: "credits", "minutes". Used in the readout, the captions and the slider's name.
readoutLabelType
ReactNodeThe line under the big number. Defaults to
<unit> per month.defaultValueType
numberThe usage the thumb starts at. Defaults to a point 60% along the second plan's stretch.
minType
numberDefault0The usage at the rail's left end.
maxType
numberThe usage at the rail's right end. Defaults to the last plan's allowance, or four times the largest one when the last plan is quoted.
billingType
PlanFitBilling[]Options for a billing switch beside the readout, as
{ id, label, note? }, e.g.{ id: "yearly", label: "Yearly", note: "−20%" }. Switching counts every price to its new figure. Leave it out for one price per plan.defaultBillingType
stringThe billing option selected at first. Defaults to the first.
currencyType
stringDefault"USD"An ISO currency code for every price.
localeType
stringDefault"en-US"The locale numbers and prices are formatted in.
periodType
stringDefault"/mo"Shown after each price.
fitLabelType
stringDefault"Best fit"The tag on the highlighted plan, also read out to screen readers.
onValueChangeType
(usage: number) => voidCalled with the usage whenever it changes.
onFitChangeType
(planId: string) => voidCalled with the best plan's id once on mount, then whenever a different plan becomes the best fit.
glideDurationType
numberDefault0.42How long the highlight takes to glide over to a new best plan, in seconds (its spring's visual duration).
glideBounceType
numberDefault0.12How far the highlight overshoots as it lands on a new plan, from 0 (not at all) to 1.
countDurationType
numberDefault0.35How long every price takes to count to its new figure when the billing option changes, in seconds.
classNameType
stringExtra classes for the root. Theme it with CSS variables:
--plan-fit-accent(default#ff4f12),--plan-fit-accent-inkfor text on the accent,--plan-fit-accent-textfor accent-colored words, and--plan-fit-surfacefor the cards' fill.