# Jot: Follow the Pick

> Three plans as radio cards. The note swings its arrow to whichever plan you pick and rewrites itself for it.

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-plans-demo).

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

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/keyframeui/jot-plans-demo.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`

## Notes from the registry

Live preview, examples and props: https://keyframeui.com/components/jot#jot-plans-demo

## Files

### `registry/demos/jot-plans-demo.tsx`

```tsx
"use client";

import * as React from "react";

import { Jot } from "@/components/ui/jot";

const PLANS = [
  {
    id: "solo",
    name: "Solo",
    price: 0,
    blurb: "One client, every file",
    terms: "Free for good, no card needed",
    note: "free forever, no card needed",
  },
  {
    id: "studio",
    name: "Studio",
    price: 24,
    blurb: "Ten clients, your logo",
    terms: "14 days free, then $24 a month",
    note: "most studios start here",
  },
  {
    id: "agency",
    name: "Agency",
    price: 79,
    blurb: "Every client, with SSO",
    terms: "14 days free, then $79 a month",
    note: "for when you outgrow ten",
  },
] as const;

type PlanId = (typeof PLANS)[number]["id"];

export function JotPlansDemo() {
  const name = React.useId();
  const [picked, setPicked] = React.useState<PlanId>("studio");
  const plan = PLANS.find((p) => p.id === picked) ?? PLANS[1];
  const index = PLANS.indexOf(plan);

  return (
    <div data-jot-scope className="@container/plans relative w-full max-w-[38rem] px-2 pt-24 pb-4">
      <fieldset>
        <legend className="text-[13px] font-medium text-zinc-500 dark:text-zinc-400">Lantern plans</legend>
        <p className="mt-1 max-w-[16ch] text-xl/tight font-semibold tracking-[-0.02em] text-balance text-zinc-950 dark:text-white">
          Client portals for small studios
        </p>

        <div className="mt-6 grid grid-cols-3 gap-2 @md/plans:gap-3">
          {PLANS.map((p) => (
            <label
              key={p.id}
              data-plan={p.id}
              className="group relative flex cursor-pointer flex-col rounded-2xl border border-zinc-950/10 bg-white p-3 transition-[border-color,box-shadow] duration-200 ease-out hover:border-zinc-950/25 has-checked:border-zinc-950 has-checked:shadow-[0_0_0_1px_var(--color-zinc-950)] has-focus-visible:ring-2 has-focus-visible:ring-blue-500 has-focus-visible:ring-offset-2 @md/plans:p-4 dark:border-white/10 dark:bg-zinc-900 dark:hover:border-white/25 dark:has-checked:border-white dark:has-checked:shadow-[0_0_0_1px_var(--color-white)] dark:has-focus-visible:ring-offset-zinc-950"
            >
              <input
                type="radio"
                name={name}
                value={p.id}
                checked={picked === p.id}
                onChange={() => setPicked(p.id)}
                className="sr-only"
              />
              <span className="flex items-center justify-between gap-2">
                <span className="text-sm font-medium text-zinc-950 dark:text-white">{p.name}</span>
                <span
                  aria-hidden
                  className="grid size-4 place-items-center rounded-full border border-zinc-950/20 transition-colors duration-150 group-has-checked:border-zinc-950 dark:border-white/25 dark:group-has-checked:border-white"
                >
                  <span className="size-2 scale-50 rounded-full bg-zinc-950 opacity-0 transition-[scale,opacity] duration-150 ease-out group-has-checked:scale-100 group-has-checked:opacity-100 dark:bg-white" />
                </span>
              </span>
              <span className="mt-4 flex items-baseline gap-0.5 text-zinc-950 dark:text-white">
                <span className="text-2xl/none font-semibold tracking-tight tabular-nums @md/plans:text-[1.75rem]/none">
                  ${p.price}
                </span>
                <span className="text-xs text-zinc-500 dark:text-zinc-400">/mo</span>
              </span>
              <span className="mt-2 text-xs/4 text-pretty text-zinc-500 dark:text-zinc-400">{p.blurb}</span>
            </label>
          ))}
        </div>
      </fieldset>

      <div className="mt-3 flex flex-wrap items-center justify-between gap-x-4 gap-y-3 rounded-2xl bg-zinc-100/80 py-2.5 pr-2.5 pl-4 dark:bg-white/5">
        <p className="text-sm text-zinc-600 dark:text-zinc-400" aria-live="polite">
          {plan.terms}
        </p>
        <a
          href="#start"
          className="inline-flex h-9 items-center rounded-full bg-zinc-950 px-4 text-sm font-medium text-white outline-none hover:bg-zinc-800 focus-visible:ring-2 focus-visible:ring-zinc-950 focus-visible:ring-offset-2 dark:bg-white dark:text-zinc-950 dark:hover:bg-zinc-200 dark:focus-visible:ring-white dark:focus-visible:ring-offset-zinc-950"
        >
          Start with {plan.name}
        </a>
      </div>

      <Jot
        to={`[data-plan="${picked}"]`}
        at="top"
        // Leave sideways and land heading down: bow up toward whichever side the plan is on.
        bend={index === 2 ? -0.45 : 0.45}
        tilt={-2}
        trigger="mount"
        delay={0.3}
        className="absolute top-4 right-[8%] text-xl/snug text-blue-600 @md/plans:right-[14%] dark:text-blue-400"
      >
        {plan.note}
      </Jot>
    </div>
  );
}
```
