# 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.

A shadcn/ui block from the [@keyframeui](https://keyframeui.com/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/keyframeui/jot-pricing).

- Page, with a live preview: https://shadcn.exchange/r/keyframeui/jot-pricing
- Screenshot: https://shadcn.exchange/shot/keyframeui/jot-pricing/light.png
- Registry item: https://keyframeui.com/r/jot-pricing.json
- Categories: blocks, pricing-sections
- Listed: 2026-11-02, source last changed 2026-11-02

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/keyframeui/jot-pricing.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- Registry items: `https://keyframeui.com/r/jot.json`, `https://keyframeui.com/r/odometer.json`

## Notes from the registry

Live preview and the components it's built with: https://keyframeui.com/blocks/pricing-sections/jot-pricing

## Files

### `registry/blocks/jot-pricing.tsx`

```tsx
"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>
  );
}
```
