Jot Pricing
A pricing section where the handwritten notes do the selling. A note beside the billing switch points at Yearly and rewrites itself to a thank-you when the visitor switches, while every price rolls to its new number. Drag the device slider and the note above the plans swings its arrow to the one that fits.
"use client";
import * as React from "react";
import { Jot } from "@/components/ui/jot";
import { Odometer } from "@/components/ui/odometer";
/* ── Content ─────────────────────────────────────────────────
* Everything you'd change to make the section yours: the copy,
* the plans and their limits, and the handwritten notes. The
* device slider picks the plan that fits, and the note above
* the cards points at it.
* ─────────────────────────────────────────────────────────── */
/** The one accent: the fitting plan's ring, its button and the slider. */
const ACCENT = { light: "#0f766e", dark: "#2dd4bf" };
const EYEBROW = "Pricing";
const HEADLINE = "Pay for the devices you sync.";
const HEADLINE_QUIET = "Not the seats.";
const SUBLINE =
"Every plan syncs offline, merges without conflicts and keeps 30 days of history. Start free and move up when your app takes off.";
const SLIDER_LABEL = "Devices syncing each month";
const DEVICES = { min: 100, max: 250_000, start: 12_000 };
/** The notes in the margin. The billing note rewrites itself when the visitor switches. */
const NOTES = {
monthly: "2 months free if you go yearly",
yearly: "nice, 2 months on us",
fit: { hobby: "free covers this", pro: "this one fits", scale: "you'll want this one" },
};
type PlanId = keyof typeof NOTES.fit;
type Plan = {
id: PlanId;
name: string;
blurb: string;
/** USD per month, billed monthly and billed yearly. */
monthly: number;
yearly: number;
/** The most devices it syncs; the slider picks the first plan that covers the count. */
devices: number;
cta: { label: string; href: string };
features: string[];
};
const PLANS: Plan[] = [
{
id: "hobby",
name: "Hobby",
blurb: "For side projects and prototypes.",
monthly: 0,
yearly: 0,
devices: 1_000,
cta: { label: "Start free", href: "#start" },
features: ["Offline writes and merges", "7 days of history", "Community support"],
},
{
id: "pro",
name: "Pro",
blurb: "For apps with real people in them.",
monthly: 29,
yearly: 24,
devices: 25_000,
cta: { label: "Start a 14-day trial", href: "#trial" },
features: ["Everything in Hobby", "30 days of history", "Presence and live cursors", "Email support, next business day"],
},
{
id: "scale",
name: "Scale",
blurb: "For apps people never close.",
monthly: 199,
yearly: 166,
devices: 250_000,
cta: { label: "Talk to us", href: "#sales" },
features: ["Everything in Pro", "1 year of history", "Your own region", "A shared Slack channel"],
},
];
const usd: Intl.NumberFormatOptions = { style: "currency", currency: "USD", maximumFractionDigits: 0 };
const count = new Intl.NumberFormat("en-US");
// The slider moves on a log scale, so 100 and 250,000 devices are both a short drag away.
const STEPS = 1000;
const span = Math.log10(DEVICES.max / DEVICES.min);
const toDevices = (step: number) => {
const raw = DEVICES.min * 10 ** ((step / STEPS) * span);
const magnitude = 10 ** Math.max(0, Math.floor(Math.log10(raw)) - 1);
return Math.round(raw / magnitude) * magnitude;
};
const toStep = (devices: number) => Math.round((Math.log10(devices / DEVICES.min) / span) * STEPS);
/* ── Section ─────────────────────────────────────────────── */
export function JotPricing() {
const [yearly, setYearly] = React.useState(false);
const [step, setStep] = React.useState(() => toStep(DEVICES.start));
const devices = toDevices(step);
const fit = (PLANS.find((plan) => devices <= plan.devices) ?? PLANS[PLANS.length - 1]).id;
const yearlyRef = React.useRef<HTMLButtonElement>(null);
const cards = {
hobby: React.useRef<HTMLDivElement>(null),
pro: React.useRef<HTMLDivElement>(null),
scale: React.useRef<HTMLDivElement>(null),
};
return (
<section
data-jot-scope
style={
{
"--accent-light": ACCENT.light,
"--accent-dark": ACCENT.dark,
} as React.CSSProperties
}
className="bg-white text-zinc-950 [--accent:var(--accent-light)] dark:bg-zinc-950 dark:text-white dark:[--accent:var(--accent-dark)]"
>
<div className="mx-auto max-w-5xl px-6 py-24 sm:py-32">
<div className="mx-auto max-w-2xl text-center">
<p className="text-sm font-semibold text-(--accent)">{EYEBROW}</p>
<h2 className="mt-3 text-4xl font-semibold tracking-[-0.03em] text-balance sm:text-5xl">
{HEADLINE} <span className="text-zinc-400 dark:text-zinc-500">{HEADLINE_QUIET}</span>
</h2>
<p className="mx-auto mt-5 max-w-xl text-base leading-relaxed text-pretty text-zinc-600 dark:text-zinc-400">
{SUBLINE}
</p>
</div>
{/* Billing, with a note that talks the visitor into yearly and thanks them when they switch. */}
<div className="mt-12 flex justify-center">
<div className="relative">
<div
role="radiogroup"
aria-label="Billing"
className="inline-flex rounded-full bg-zinc-100 p-1 text-sm font-medium dark:bg-white/[0.07]"
>
{[false, true].map((option) => (
<button
key={String(option)}
ref={option ? yearlyRef : undefined}
type="button"
role="radio"
aria-checked={yearly === option}
onClick={() => setYearly(option)}
className={`rounded-full px-4 py-1.5 transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--accent) ${
yearly === option
? "bg-white text-zinc-950 shadow-sm dark:bg-zinc-800 dark:text-white"
: "text-zinc-500 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-white"
}`}
>
{option ? "Yearly" : "Monthly"}
</button>
))}
</div>
<Jot
to={yearlyRef}
from="left"
bend={-0.45}
tilt={-2}
className="absolute top-1/2 left-full mt-3 ml-8 hidden w-max whitespace-nowrap text-zinc-600 sm:block dark:text-zinc-300"
>
{yearly ? NOTES.yearly : NOTES.monthly}
</Jot>
</div>
</div>
{/* How many devices: the plan that covers them gets the ring and the note. */}
<div className="mx-auto mt-10 max-w-md">
<div className="flex items-baseline justify-between text-sm">
<label htmlFor="jot-pricing-devices" className="font-medium">
{SLIDER_LABEL}
</label>
<span className="font-semibold tabular-nums">{count.format(devices)}</span>
</div>
<input
id="jot-pricing-devices"
type="range"
min={0}
max={STEPS}
value={step}
onChange={(event) => setStep(Number(event.target.value))}
aria-valuetext={`${count.format(devices)} devices`}
className="mt-3 w-full cursor-pointer accent-(--accent)"
/>
</div>
<div className="relative mt-24 grid gap-4 md:mt-28 md:grid-cols-3">
<Jot
to={cards[fit]}
at="top"
bend={0.3}
tilt={-1.5}
className="absolute -top-16 left-1/2 hidden w-max -translate-x-1/2 text-zinc-600 md:block dark:text-zinc-300"
>
{NOTES.fit[fit]}
</Jot>
{PLANS.map((plan) => {
const fits = plan.id === fit;
const price = yearly ? plan.yearly : plan.monthly;
return (
<div
key={plan.id}
ref={cards[plan.id]}
className={`relative flex flex-col rounded-2xl p-7 transition-[box-shadow,background-color] duration-300 motion-reduce:transition-none ${
fits
? "bg-(--accent)/[0.04] shadow-[0_0_0_2px_var(--accent),0_12px_32px_-16px_rgb(0_0_0/0.25)]"
: "shadow-[0_0_0_1px_rgb(9_9_11/0.08)] dark:shadow-[0_0_0_1px_rgb(255_255_255/0.1)]"
}`}
>
<h3 className="text-base font-semibold">{plan.name}</h3>
<p className="mt-1 text-sm text-zinc-500 dark:text-zinc-400">{plan.blurb}</p>
<p className="mt-6 flex items-baseline gap-1">
<Odometer value={price} format={usd} className="text-4xl font-semibold tracking-[-0.03em]" />
<span className="text-sm text-zinc-500 dark:text-zinc-400">/mo</span>
</p>
<p className="mt-1 h-5 text-xs text-zinc-500 dark:text-zinc-400">
{price === 0 ? "Free forever" : yearly ? `Billed $${count.format(price * 12)} a year` : "Billed monthly"}
</p>
<a
href={plan.cta.href}
className={`mt-6 rounded-full px-4 py-2.5 text-center text-sm font-semibold transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--accent) ${
fits
? "bg-(--accent) text-white hover:opacity-90 dark:text-zinc-950"
: "bg-zinc-100 text-zinc-900 hover:bg-zinc-200 dark:bg-white/[0.08] dark:text-white dark:hover:bg-white/[0.12]"
}`}
>
{plan.cta.label}
</a>
<p className="mt-6 text-sm font-medium">Up to {count.format(plan.devices)} devices</p>
<ul className="mt-3 space-y-2.5 text-sm text-zinc-600 dark:text-zinc-400">
{plan.features.map((feature) => (
<li key={feature} className="flex gap-2.5">
<CheckIcon className="mt-0.5 size-4 shrink-0 text-(--accent)" />
{feature}
</li>
))}
</ul>
</div>
);
})}
</div>
</div>
</section>
);
}
function CheckIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth={1.8} className={className} aria-hidden>
<path d="m3.5 8.5 3 3 6-7" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
Installation
The CLI writes components/jot-pricing.tsx, the components it's built with (components/ui/jot.tsx and components/ui/odometer.tsx) and lib/utils.ts, 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/jot-pricingThen put it where the section goes. The copy, links and data sit in constants at the top of the file, so making it yours starts there.
import { JotPricing } from "@/components/jot-pricing";
export default function Page() {
return <JotPricing />;
}
Built with
Each of these is a component on its own, with its own examples and props. The block installs them for you.
- Jot A handwritten note that draws its own arrow to the thing it's about. It writes itself in when it scrolls into view, keeps the arrow attached as the layout moves, swings it over when you point it somewhere new, and rubs itself out once it has done its job. @keyframeui/jot
- Odometer A number that rolls each digit on its own wheel, up when the value grows and down when it shrinks, and slides new digits in as the number gets longer. @keyframeui/odometer