Jot
Hand-Drawn Annotation Arrow for React
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.
<Jot />replays the demo when you let go.
Wren Forms
One question at a time, saved as they type, easy to answer on a phone.
1,284
Finish rate71%
counted as they land
"use client";
import * as React from "react";
import { Jot } from "@/components/ui/jot";
// The last twelve hours of replies, oldest first; the newest bar grows as replies come in.
const HOURS = [18, 26, 22, 35, 41, 38, 52, 61, 48, 57, 66, 44];
export function JotDemo() {
const ctaRef = React.useRef<HTMLAnchorElement>(null);
const statsRef = React.useRef<HTMLDivElement>(null);
const [replies, setReplies] = React.useState(1284);
// A reply or two every couple of seconds, so "live" is true.
React.useEffect(() => {
const timer = window.setInterval(() => setReplies((n) => n + 1 + Math.floor(Math.random() * 2)), 2200);
return () => window.clearInterval(timer);
}, []);
const latest = HOURS[HOURS.length - 1] + (replies - 1284) * 2;
const top = Math.max(...HOURS, latest);
return (
<div data-jot-scope className="@container/jot relative w-full max-w-[56rem] px-2 pt-24 pb-32 @lg/jot:px-6 @3xl/jot:pt-28 @3xl/jot:pb-40">
<div className="mx-auto grid max-w-[38rem] gap-8 rounded-[1.75rem] border border-zinc-950/10 bg-white p-6 shadow-[0_1px_2px_rgb(0_0_0/0.04),0_12px_32px_-12px_rgb(0_0_0/0.12)] @lg/jot:grid-cols-[1fr_12rem] @lg/jot:p-8 dark:border-white/10 dark:bg-zinc-900 dark:shadow-none">
<div className="flex flex-col">
<p className="flex items-center gap-2 text-[13px] font-medium text-zinc-500 dark:text-zinc-400">
<svg viewBox="0 0 20 20" aria-hidden className="size-5">
<rect width="20" height="20" rx="6" className="fill-zinc-950 dark:fill-white" />
<path
d="M5.5 7.5 7.6 13l2.4-5 2.4 5 2.1-5.5"
className="fill-none stroke-white dark:stroke-zinc-950"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
Wren Forms
</p>
<h2 className="mt-5 text-[2rem]/[1.05] font-semibold tracking-[-0.03em] text-balance text-zinc-950 dark:text-white">
Forms people actually finish.
</h2>
<p className="mt-3 max-w-[30ch] text-[15px]/6 text-pretty text-zinc-600 dark:text-zinc-400">
One question at a time, saved as they type, easy to answer on a phone.
</p>
<div className="mt-7 flex flex-wrap items-center gap-x-5 gap-y-3">
<a
ref={ctaRef}
href="#build"
className="inline-flex h-10 items-center gap-1.5 rounded-full bg-zinc-950 px-5 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-900"
>
Build a form
<svg viewBox="0 0 16 16" aria-hidden className="size-4">
<path d="M6 4l4 4-4 4" className="fill-none stroke-current" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</a>
<a
href="#templates"
className="rounded-sm text-sm font-medium text-zinc-600 underline decoration-zinc-300 underline-offset-4 outline-none hover:text-zinc-950 hover:decoration-zinc-950 focus-visible:ring-2 focus-visible:ring-zinc-950 dark:text-zinc-400 dark:decoration-zinc-700 dark:hover:text-white dark:hover:decoration-white dark:focus-visible:ring-white"
>
See templates
</a>
</div>
</div>
<div
ref={statsRef}
className="order-first self-end rounded-2xl @lg/jot:order-none border border-zinc-950/[0.07] bg-zinc-50 p-4 dark:border-white/[0.07] dark:bg-zinc-950/60"
>
<div className="flex items-center justify-between text-xs text-zinc-500 dark:text-zinc-400">
<span>Replies today</span>
<span className="flex items-center gap-1.5">
<span className="size-1.5 rounded-full bg-emerald-500" />
Live
</span>
</div>
<p className="mt-1.5 text-[1.75rem]/none font-semibold tracking-tight text-zinc-950 tabular-nums dark:text-white">
{replies.toLocaleString("en-US")}
</p>
<div className="mt-4 flex h-12 items-end gap-[3px]" aria-hidden>
{[...HOURS.slice(0, -1), latest].map((h, i) => (
<span
key={i}
className={
i === HOURS.length - 1
? "flex-1 rounded-[2px] bg-zinc-950 transition-[height] duration-500 ease-out dark:bg-white"
: "flex-1 rounded-[2px] bg-zinc-300 dark:bg-zinc-700"
}
style={{ height: `${(h / top) * 100}%` }}
/>
))}
</div>
<p className="mt-3 flex justify-between text-xs text-zinc-500 tabular-nums dark:text-zinc-400">
<span>Finish rate</span>
<span className="font-medium text-zinc-950 dark:text-white">71%</span>
</p>
</div>
</div>
<Jot
to={ctaRef}
loop
bend={-0.35}
tilt={-3}
className="absolute bottom-5 left-4 max-w-44 text-xl/snug text-blue-600 @lg/jot:left-6 @3xl/jot:bottom-7 @3xl/jot:left-4 dark:text-blue-400"
>
free for your first 1,000 replies
</Jot>
<Jot
to={statsRef}
bend={0.3}
tilt={2}
delay={0.7}
className="absolute top-4 right-4 max-w-none text-xl/snug text-blue-600 @lg/jot:right-6 @3xl/jot:top-6 @3xl/jot:right-4 dark:text-blue-400"
>
real replies,
<br />
counted as they land
</Jot>
</div>
);
}
Add this moment from KeyframeUI to our project and adapt it to our content.
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.
This demo, Annotated Hero: A form builder's hero. Two notes write themselves in, one curling its arrow up to the call to action, the other pointing at the live reply count.
1. Install it:
npx shadcn@latest add @keyframeui/jot-demo
(If the CLI can't find @keyframeui, use https://keyframeui.com/r/jot-demo.json instead.)
It adds the demo, components/jot-demo.tsx, and the component, components/ui/jot.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 <Jot> on its own.
3. These props set its feel. Change them before you change the file:
- from (default "auto"): The side of the note the arrow leaves from. `"auto"` picks the side facing the target.
- at (default "auto"): The side of the target the arrow lands on. `"auto"` aims at its center and stops at its edge.
- bend (default 0.35): How far the arrow bows, from -1 to 1. 0 is nearly straight; flip the sign to bow the other way.
- loop (default false): Curl the arrow once midway, like a quick flourish.
- gap (default 6): Pixels left between the arrowhead and the target.
- tilt (default 0): Degrees to turn the words, for a looser hand. The arrow isn't turned.
- delay (default 0): Seconds to wait before the first write, to write several notes one after another.
- speed (default 1): How fast the pen writes, draws the arrow and rubs out, as a multiple of the default pace. `2` is twice as fast.
4. Don't change, in components/ui/jot.tsx:
- The pen lives outside React: it reads both boxes in motion's `frame.read` and paints in `frame.render`, only while something could move a box, and never off screen.
- The words are real text shown by a `clip-path` wipe, line by line; the strokes draw by `strokeDasharray` on `pathLength={1}` paths, and an undrawn one is hidden outright, since a zero-length dash still paints a round cap.
- The pen writes the words, sets off on the shaft before the last word is done, then draws the head's two strokes; rubbing out runs backward: head, shaft, words.
- The tremor is seeded from the words and fades out at both ends, so the line never shimmers and still starts and lands exactly.
- While shown, the target lists the note in `aria-describedby`; reduced motion drops the pen and fades the note and arrow in and out by opacity.
Its timing is all in the TIMING block at the top of that file.
Docs: https://keyframeui.com/components/jot.mdInstallation
Add the component on its own with the CLI, or copy the file in by hand.
The CLI writes components/ui/jot.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/jotInstall 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/jot.tsx"use client"; import * as React from "react"; import { animate, cancelFrame, cubicBezier, frame, useReducedMotion, type AnimationPlaybackControls, } from "motion/react"; import { cn } from "@/lib/utils"; /** * Keep: * - The pen lives outside React: it reads both boxes in motion's `frame.read` and paints in * `frame.render`, only while something could move a box, and never off screen. * - The words are real text shown by a `clip-path` wipe, line by line; the strokes draw by * `strokeDasharray` on `pathLength={1}` paths, and an undrawn one is hidden outright, since a * zero-length dash still paints a round cap. * - The pen writes the words, sets off on the shaft before the last word is done, then draws the * head's two strokes; rubbing out runs backward: head, shaft, words. * - The tremor is seeded from the words and fades out at both ends, so the line never shimmers * and still starts and lands exactly. * - While shown, the target lists the note in `aria-describedby`; reduced motion drops the pen and * fades the note and arrow in and out by opacity. */ const TIMING = { /** Before the first write, in seconds (the `delay` prop). */ delay: 0, /** How fast the pen writes, draws and rubs out, as a multiple of the pace below (`speed`). */ speed: 1, // At the default pace; `speed` divides these. /** Writing the words, in seconds per character. */ letter: 0.028, /** The shortest and longest the words take, in seconds. */ write: [0.35, 0.7], /** Drawing the shaft, in px/s. */ pen: 650, /** The shortest and longest the shaft takes, in seconds. */ draw: [0.25, 0.5], /** How long before the last word is done the shaft sets off, in seconds. */ lead: 0.1, /** Each of the head's two strokes, in seconds. */ stroke: 0.1, /** Between the head's two strokes, in seconds. */ stagger: 0.04, /** Every stroke eases in and out, like a hand, as a cubic bezier. */ hand: [0.65, 0, 0.35, 1], /** Old words wiping out before the new ones write in, in seconds. */ unwrite: 0.15, /** Rubbing out, head first, in seconds each. */ rub: { head: 0.06, shaft: 0.2, words: 0.16 }, /** How long before the part ahead of it is gone each part starts rubbing out, in seconds. */ rubOverlap: { shaft: 0.02, words: 0.04 }, /** The arrow swinging over to a new target, as a spring (not paced by `speed`). */ swing: { type: "spring", visualDuration: 0.45, bounce: 0.12 }, /** Reduced motion: the note and its arrow fading in, in seconds. */ fadeIn: 0.2, /** Reduced motion: fading out, in seconds. */ fadeOut: 0.15, /** Reading boxes every frame after anything that could move one, in ms. */ watch: 700, /** Reading on after a transition or scroll ends, in ms. */ tail: 250, /** After this, a transition that never reported its end is forgotten, in ms. */ stale: 4000, } as const; /* ───────────────────────────────────────────────────────── * JOT STORYBOARD * * rest a few handwritten words in the margin and nothing drawn * yet. The words are real text in your text color, so * screen readers have them from the start * write once half the note is on screen (or at mount), the pen * writes the words left to right, line by line, at about * 28ms a letter (0.35–0.7s in all) * draw 0.1s before the last word is done the pen sets off from * the side of the note facing the target and draws the * arrow to it at ~650px/s (0.25–0.5s), easing in and out * like a hand. It bows by `bend`, curls once midway with * `loop`, strays ±0.8px either side of true (seeded by the * words, so it never shimmers) and stops `gap` px short of * the target's edge * head the head goes on last: two short strokes, 11px and 9px, * 0.1s each and 0.04s apart * attach whenever something could move a box (a resize, a scroll, * a transition, a re-render) both boxes are read every frame * until things settle, so the arrow stays attached through * reflows, hover lifts and layout animations. When nothing * moves it stops reading, and off screen it sleeps * swing point `to` somewhere new and the arrow swings over on a * spring (0.45s, a small 0.12 settle) while the words stay. * New words wipe the old ones out (0.15s) and write in * rub `show={false}` rubs it out in reverse: the head, then the * shaft back into the note, then the words, ~0.35s in all. * `show` again writes it back from wherever it got to * describe while it shows, the target takes the words as its * description (aria-describedby), so a screen reader on the * button hears the note too * reduced no pen: the note and its arrow fade in (0.2s) and out * (0.15s), and a new target fades the arrow out and back in * ───────────────────────────────────────────────────────── */ const JOT = { wobble: 0.8, // px the line strays either side of true barbs: [11, 9], // px; the head's two strokes spread: [27, 31], // degrees each stroke leans off the shaft inset: 4, // px between the words and where the arrow leaves them shortest: 14, // px; closer than this and no arrow is drawn view: 0.5, // share of the note on screen before it writes } as const; const handEase = cubicBezier(...TIMING.hand); type Side = "auto" | "top" | "right" | "bottom" | "left"; type Point = { x: number; y: number }; type Box = { x: number; y: number; w: number; h: number }; /** Start, bend control and end of the arrow, in the note's own px. */ type Route = { a: Point; c: Point; b: Point }; type Line = { l: number; r: number; t: number; b: number }; type Lines = { list: Line[]; pad: number }; const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v)); const lerp = (a: number, b: number, t: number) => a + (b - a) * t; const lerpPoint = (p: Point, q: Point, t: number) => ({ x: lerp(p.x, q.x, t), y: lerp(p.y, q.y, t) }); const lerpRoute = (p: Route, q: Route, t: number): Route => ({ a: lerpPoint(p.a, q.a, t), c: lerpPoint(p.c, q.c, t), b: lerpPoint(p.b, q.b, t), }); const sameRoute = (p: Route | null, q: Route | null) => !!p && !!q && Math.abs(p.a.x - q.a.x) + Math.abs(p.a.y - q.a.y) + Math.abs(p.b.x - q.b.x) + Math.abs(p.b.y - q.b.y) < 0.05 && Math.abs(p.c.x - q.c.x) + Math.abs(p.c.y - q.c.y) < 0.05; const HIDDEN = "inset(0 100% 0 0)"; /** The plain text inside a note, for its pace, its wobble and spotting new words. */ function textOf(node: React.ReactNode): string { if (node == null || typeof node === "boolean") return ""; if (typeof node === "string" || typeof node === "number") return String(node); if (Array.isArray(node)) return node.map(textOf).join(""); if (React.isValidElement<{ children?: React.ReactNode }>(node)) return textOf(node.props.children); return ""; } /** A hand's tremor: two slow waves with phases picked from the words, so it's the same every render. */ function tremor(text: string) { let h = 2166136261; for (let i = 0; i < text.length; i++) h = Math.imul(h ^ text.charCodeAt(i), 16777619); const next = () => { h = Math.imul(h ^ (h >>> 15), 2246822507); h = Math.imul(h ^ (h >>> 13), 3266489909); return ((h ^= h >>> 16) >>> 0) / 4294967296; }; const f1 = 1.2 + next() * 0.7; const f2 = 2.8 + next() * 1.1; const p1 = next(); const p2 = next(); return (t: number) => JOT.wobble * (0.65 * Math.sin(2 * Math.PI * (f1 * t + p1)) + 0.35 * Math.sin(2 * Math.PI * (f2 * t + p2))); } /** The side of a box that faces a point: where a ray from its center to the point leaves it. */ function facing(box: Box, p: Point): Exclude<Side, "auto"> { const dx = p.x - (box.x + box.w / 2); const dy = p.y - (box.y + box.h / 2); if (Math.abs(dx) / Math.max(box.w / 2, 1) >= Math.abs(dy) / Math.max(box.h / 2, 1)) { return dx >= 0 ? "right" : "left"; } return dy >= 0 ? "bottom" : "top"; } /** A point just outside one side of a box, slid along it toward `toward` but kept off the corners. */ function onSide(box: Box, side: Exclude<Side, "auto">, toward: Point, out: number): Point { if (side === "left" || side === "right") { return { x: side === "left" ? box.x - out : box.x + box.w + out, y: clamp(toward.y, box.y + box.h * 0.3, box.y + box.h * 0.7), }; } return { x: clamp(toward.x, box.x + box.w * 0.2, box.x + box.w * 0.8), y: side === "top" ? box.y - out : box.y + box.h + out, }; } /** Where the line from `a` to the box's center enters the box grown by `gap`. */ function entry(a: Point, box: Box, gap: number): Point { const lo = { x: box.x - gap, y: box.y - gap }; const hi = { x: box.x + box.w + gap, y: box.y + box.h + gap }; const d = { x: box.x + box.w / 2 - a.x, y: box.y + box.h / 2 - a.y }; let enter = 0; for (const axis of ["x", "y"] as const) { if (Math.abs(d[axis]) < 1e-6) continue; const t1 = (lo[axis] - a[axis]) / d[axis]; const t2 = (hi[axis] - a[axis]) / d[axis]; enter = Math.max(enter, Math.min(t1, t2)); } enter = clamp(enter, 0, 1); return { x: a.x + d.x * enter, y: a.y + d.y * enter }; } function plan(note: Box, target: Box, o: { from: Side; at: Side; bend: number; gap: number }): Route | null { const center = { x: target.x + target.w / 2, y: target.y + target.h / 2 }; const a = onSide(note, o.from === "auto" ? facing(note, center) : o.from, center, JOT.inset); const b = o.at === "auto" ? entry(a, target, o.gap) : onSide(target, o.at, a, o.gap); const dx = b.x - a.x; const dy = b.y - a.y; const d = Math.hypot(dx, dy); if (d < JOT.shortest) return null; // Bow to the right of travel for a positive bend (clockwise on screen), by a share of the length. const bend = clamp(o.bend, -1, 1); return { a, b, c: { x: (a.x + b.x) / 2 - (dy / d) * bend * d * 0.5, y: (a.y + b.y) / 2 + (dx / d) * bend * d * 0.5 }, }; } /** The shaft as a sampled line with the tremor and the optional curl, plus the tip and its heading. */ function trace(r: Route, loop: boolean, bend: number, shake: (t: number) => number) { const { a, c, b } = r; const chord = Math.hypot(b.x - a.x, b.y - a.y); const length = (Math.hypot(c.x - a.x, c.y - a.y) + Math.hypot(b.x - c.x, b.y - c.y) + chord) / 2; const radius = clamp(chord * 0.08, 8, 14); const curl = loop && length > radius * 7; // The curl takes a stretch of the line about 2.6 radii long, centred halfway along. const span = curl ? clamp((2.6 * radius) / length, 0.08, 0.3) : 0; const t0 = 0.5 - span / 2; const ts: number[] = []; const step = Math.max(3 / Math.max(length, 1), 1 / 160); for (let t = 0; t < 1; t += step) if (!curl || t <= t0 || t >= t0 + span) ts.push(t); if (curl) for (let i = 1; i < 40; i++) ts.push(t0 + (span * i) / 40); ts.push(1); ts.sort((p, q) => p - q); const side = bend < 0 ? -1 : 1; let d = ""; for (const t of ts) { const u = 1 - t; let x = u * u * a.x + 2 * u * t * c.x + t * t * b.x; let y = u * u * a.y + 2 * u * t * c.y + t * t * b.y; let tx = 2 * u * (c.x - a.x) + 2 * t * (b.x - c.x); let ty = 2 * u * (c.y - a.y) + 2 * t * (b.y - c.y); const tl = Math.hypot(tx, ty) || 1; tx /= tl; ty /= tl; // The tremor fades out at both ends, so the line still starts and lands exactly. const wobble = shake(t) * Math.sin(Math.PI * t); x += -ty * wobble; y += tx * wobble; if (curl && t > t0 && t < t0 + span) { // A circle rolled along the line: forward at its foot, back over the top, forward again. const th = ((t - t0) / span) * Math.PI * 2; x += radius * (tx * Math.sin(th) - side * ty * (1 - Math.cos(th))); y += radius * (ty * Math.sin(th) + side * tx * (1 - Math.cos(th))); } d += `${d ? "L" : "M"}${x.toFixed(1)} ${y.toFixed(1)}`; } let hx = b.x - c.x; let hy = b.y - c.y; const hl = Math.hypot(hx, hy); if (hl < 0.01) { hx = b.x - a.x; hy = b.y - a.y; } const n = Math.hypot(hx, hy) || 1; return { d, tip: b, heading: { x: hx / n, y: hy / n } }; } function barb(tip: Point, heading: Point, length: number, degrees: number) { const rad = (degrees * Math.PI) / 180; const bx = -heading.x; const by = -heading.y; const x = tip.x + (bx * Math.cos(rad) - by * Math.sin(rad)) * length; const y = tip.y + (bx * Math.sin(rad) + by * Math.cos(rad)) * length; return `M${tip.x.toFixed(1)} ${tip.y.toFixed(1)}L${x.toFixed(1)} ${y.toFixed(1)}`; } /** The words' line boxes in their own px, measured upright even when the note is tilted. */ function measureLines(words: HTMLElement, turner: HTMLElement | null, scale: number): Lines | null { const turned = turner?.style.rotate ?? ""; if (turner) turner.style.rotate = "0deg"; const box = words.getBoundingClientRect(); const range = document.createRange(); range.selectNodeContents(words); const rects = Array.from(range.getClientRects()); if (turner) turner.style.rotate = turned; if (!box.width || !box.height) return null; const list: Line[] = []; for (const rect of rects) { if (rect.width < 0.5 || rect.height < 0.5) continue; const l = (rect.left - box.left) / scale; const r = (rect.right - box.left) / scale; const t = (rect.top - box.top) / scale; const b = (rect.bottom - box.top) / scale; const mid = (t + b) / 2; const line = list.find((x) => mid > x.t && mid < x.b); if (line) { line.l = Math.min(line.l, l); line.r = Math.max(line.r, r); line.t = Math.min(line.t, t); line.b = Math.max(line.b, b); } else list.push({ l, r, t, b }); } list.sort((p, q) => p.t - q.t); const size = parseFloat(getComputedStyle(words).fontSize) || 16; return { list, pad: size * 0.4 }; } /** A clip that shows the words up to where the pen has got: whole lines above, part of the current one. */ function wipe(lines: Lines | null, progress: number) { if (progress >= 1) return "none"; if (progress <= 0 || !lines || !lines.list.length) return HIDDEN; const { list, pad } = lines; const px = (n: number) => `${n.toFixed(1)}px`; const left = px(Math.min(...list.map((x) => x.l)) - pad); const right = px(Math.max(...list.map((x) => x.r)) + pad); const top = px(list[0].t - pad); // Lines meet halfway between one's bottom and the next one's top; the last runs out to the padding. const below = (i: number) => px(i === list.length - 1 ? list[i].b + pad : (list[i].b + list[i + 1].t) / 2); let ink = progress * list.reduce((sum, x) => sum + (x.r - x.l), 0); for (let i = 0; i < list.length; i++) { const w = list[i].r - list[i].l; if (ink <= w || i === list.length - 1) { // The pen sweeps each line from just before its first letter to just past its last. const pen = px(list[i].l - pad + (Math.min(ink, w) / Math.max(w, 1)) * (w + 2 * pad)); if (i === 0) return `polygon(${left} ${top}, ${pen} ${top}, ${pen} ${below(0)}, ${left} ${below(0)})`; return `polygon(${left} ${top}, ${right} ${top}, ${right} ${below(i - 1)}, ${pen} ${below(i - 1)}, ${pen} ${below(i)}, ${left} ${below(i)})`; } ink -= w; } return "none"; } function addToken(el: Element, attr: string, token: string) { const list = (el.getAttribute(attr) ?? "").split(/\s+/).filter(Boolean); if (!list.includes(token)) el.setAttribute(attr, [...list, token].join(" ")); } function removeToken(el: Element, attr: string, token: string) { const list = (el.getAttribute(attr) ?? "").split(/\s+/).filter((t) => t && t !== token); if (list.length) el.setAttribute(attr, list.join(" ")); else el.removeAttribute(attr); } /* ── Pen ──────────────────────────────────────────────── */ // Everything that moves lives here, outside React. Each note reads both boxes in motion's read // step and paints in its render step, so many notes on one page never force extra layouts. type Els = { root: HTMLElement; turner: HTMLElement; words: HTMLElement; svg: SVGSVGElement; shaft: SVGPathElement; barbs: [SVGPathElement, SVGPathElement]; }; type PenOptions = { to?: React.RefObject<Element | null> | string; from: Side; at: Side; bend: number; loop: boolean; gap: number; trigger: "view" | "mount"; delay: number; speed: number; show: boolean; describe: boolean; reduced: boolean; text: string; }; type Key = "words" | "shaft" | "head" | "swing" | "ghost" | "fadeWords" | "fadeArrow"; type Run = { controls: AnimationPlaybackControls; to: number; end: number }; const HEAD = TIMING.stroke + TIMING.stagger; // s for both strokes of the head function createPen(els: Els, id: string) { let o: PenOptions | null = null; // words / shaft / head: how much the pen has put down, 0–1. swing: from the old route to the // new one (springs a little past 1). ghost: old words still showing. fade*: reduced motion. const v: Record<Key, number> = { words: 0, shaft: 0, head: 0, swing: 1, ghost: 0, fadeWords: 1, fadeArrow: 1 }; const runs: Partial<Record<Key, Run>> = {}; let triggered = false; let wrote = false; let near = false; // on or near the screen let looping = false; // reading and painting every frame let until = 0; // ms; keep reading until then, in case something is still moving let watched: Element | null = null; // the target the resize observer is on // CSS transitions and animations running in the scope: element → property → when it last stirred. const moving = new Map<EventTarget, Map<string, number>>(); let target: Element | null = null; let described: Element | null = null; let live: Route | null = null; let drawn: Route | null = null; let swingFrom: Route | null = null; let lines: Lines | null = null; let linesDirty = true; let ghost: { el: HTMLElement; lines: Lines | null } | null = null; let width = 0; let shake = tremor(""); let painted = ""; const observers: { disconnect(): void }[] = []; const now = () => performance.now() / 1000; // `speed` scales every stroke of the pen, writing and rubbing out alike. const pace = () => 1 / Math.max(0.05, o?.speed ?? TIMING.speed); const writeTime = () => clamp((o?.text.length ?? 0) * TIMING.letter, TIMING.write[0], TIMING.write[1]) * pace(); const drawTime = () => { if (!live) return TIMING.draw[0] * pace(); const { a, c, b } = live; const length = (Math.hypot(c.x - a.x, c.y - a.y) + Math.hypot(b.x - c.x, b.y - c.y) + Math.hypot(b.x - a.x, b.y - a.y)) / 2; return clamp(length / TIMING.pen, TIMING.draw[0], TIMING.draw[1]) * pace(); }; const stop = (key: Key) => { runs[key]?.controls.stop(); delete runs[key]; }; /** Tween one value and return when it lands. A run already heading to the same place is kept. */ const tween = (key: Key, to: number, duration: number, delay: number, ease: "hand" | "linear" = "hand", then?: () => void) => { const run = runs[key]; if (run && run.to === to && !then) return run.end; stop(key); if (Math.abs(v[key] - to) < 1e-4) { v[key] = to; then?.(); return now(); } const wait = Math.max(delay, 0); const controls = animate(v[key], to, { duration: Math.max(duration, 0.001), delay: wait, ease: ease === "hand" ? handEase : (t: number) => t, onUpdate: (value) => { v[key] = value; }, onComplete: () => { v[key] = to; if (runs[key]?.controls === controls) delete runs[key]; then?.(); }, }); const end = now() + wait + duration; runs[key] = { controls, to, end }; poke(0); return end; }; /** Bring every value to where it should be, in pen order: words, shaft, head, or the reverse. */ const sync = () => { if (!o) return; const wantWords = triggered && o.show; const wantArrow = wantWords && !!live; if (o.reduced) { // No pen: what's there is all there, and opacity carries the change. stop("words"); stop("shaft"); stop("head"); if (wantWords) { if (v.words < 1) { v.words = 1; v.fadeWords = 0; } tween("fadeWords", 1, TIMING.fadeIn * (1 - v.fadeWords), 0, "linear"); } else tween("fadeWords", 0, TIMING.fadeOut * v.fadeWords, 0, "linear"); if (wantArrow) { if (v.shaft < 1 || v.head < 1) { v.shaft = 1; v.head = 1; v.fadeArrow = 0; } tween("fadeArrow", 1, TIMING.fadeIn * (1 - v.fadeArrow), 0, "linear"); } else tween("fadeArrow", 0, TIMING.fadeOut * v.fadeArrow, 0, "linear"); return; } stop("fadeWords"); stop("fadeArrow"); v.fadeWords = 1; v.fadeArrow = 1; const t = now(); if (wantWords) { const delay = wrote ? 0 : o.delay; wrote = true; const wordsEnd = tween("words", 1, (1 - v.words) * writeTime(), delay); if (wantArrow) { const start = Math.max(t, wordsEnd - TIMING.lead * pace()); const shaftEnd = tween("shaft", 1, (1 - v.shaft) * drawTime(), start - t); tween("head", 1, (1 - v.head) * HEAD * pace(), shaftEnd - t, "linear"); } else rubArrow(); return; } const shaftEnd = rubArrow(); tween("words", 0, v.words * TIMING.rub.words * pace(), Math.max(0, shaftEnd - t - TIMING.rubOverlap.words * pace())); }; /** Head first, then the shaft back into the note. Returns when the shaft is gone. */ const rubArrow = () => { const t = now(); const headEnd = tween("head", 0, v.head * TIMING.rub.head * pace(), 0, "linear"); return tween("shaft", 0, v.shaft * TIMING.rub.shaft * pace(), Math.max(0, headEnd - t - TIMING.rubOverlap.shaft * pace())); }; /** Swing from whatever is drawn now to the live route, if anything is drawn yet. */ const startSwing = () => { if (!drawn || v.shaft <= 0) return; swingFrom = drawn; stop("swing"); v.swing = 0; if (o?.reduced) { // Fade the arrow out where it is, then back in at its new place. tween("fadeArrow", 0, TIMING.fadeOut * v.fadeArrow, 0, "linear", () => { v.swing = 1; swingFrom = null; tween("fadeArrow", 1, TIMING.fadeIn, 0, "linear"); }); return; } const controls = animate(0, 1, { ...TIMING.swing, onUpdate: (value) => { v.swing = value; }, onComplete: () => { v.swing = 1; swingFrom = null; if (runs.swing?.controls === controls) delete runs.swing; }, }); runs.swing = { controls, to: 1, end: Infinity }; poke(0); }; const redescribe = () => { const want = o && o.describe && o.show ? target : null; if (want === described) return; if (described) removeToken(described, "aria-describedby", id); described = want; if (want) addToken(want, "aria-describedby", id); }; const resolve = (): Element | null => { const to = o?.to; if (!to) return null; if (typeof to !== "string") return to.current; const scope = els.root.closest("[data-jot-scope]") ?? document; try { return scope.querySelector(to); } catch { return null; } }; const read = () => { if (!o) return; const { root, turner, words } = els; const rootRect = root.getBoundingClientRect(); if (!rootRect.width && !rootRect.height) return; // A scaled ancestor shrinks every rect alike; dividing it out keeps the arrow in the note's own px. const scale = root.offsetWidth ? rootRect.width / root.offsetWidth || 1 : 1; if (linesDirty) { lines = measureLines(words, turner, scale); linesDirty = false; } width = words.offsetWidth; const next = resolve(); const hadRoute = !!live; if (next !== target) { if (target && next) startSwing(); target = next; redescribe(); } if (target !== watched) { if (watched) resize.unobserve(watched); watched = target; if (watched) resize.observe(watched); } let route: Route | null = null; const tr = target?.isConnected ? target.getBoundingClientRect() : null; if (tr && (tr.width || tr.height)) { const local = (r: DOMRect): Box => ({ x: (r.left - rootRect.left) / scale, y: (r.top - rootRect.top) / scale, w: r.width / scale, h: r.height / scale, }); route = plan(local(turner.getBoundingClientRect()), local(tr), o); } live = route; // The target turned up, went away or came back: draw the arrow or rub it out. if (hadRoute !== !!route) sync(); }; const paint = (force = false) => { if (!o) return; const { words, svg, shaft, barbs } = els; let route = live ?? drawn; if (live && swingFrom) route = lerpRoute(swingFrom, live, v.swing); const head1 = handEase(clamp((v.head * HEAD) / TIMING.stroke, 0, 1)); const head2 = handEase(clamp((v.head * HEAD - TIMING.stagger) / TIMING.stroke, 0, 1)); // Exact values: a rounded key would hide the last step to 1, and the words would stop a hair short. const key = [v.words, v.shaft, head1, head2, v.ghost, v.fadeWords, v.fadeArrow, o.loop, lines?.list.length].join(); if (!force && key === painted && sameRoute(route, drawn)) return; painted = key; words.style.clipPath = wipe(lines, v.words); words.style.opacity = v.fadeWords < 1 ? String(v.fadeWords) : ""; if (ghost) { ghost.el.style.clipPath = wipe(ghost.lines, v.ghost); ghost.el.style.visibility = v.ghost > 0 ? "visible" : "hidden"; } svg.style.opacity = v.fadeArrow < 1 ? String(v.fadeArrow) : ""; if (!route) { for (const p of [shaft, ...barbs]) p.style.visibility = "hidden"; return; } drawn = route; const line = trace(route, o.loop, o.bend, shake); shaft.setAttribute("d", line.d); barbs[0].setAttribute("d", barb(line.tip, line.heading, JOT.barbs[0], JOT.spread[0])); barbs[1].setAttribute("d", barb(line.tip, line.heading, JOT.barbs[1], -JOT.spread[1])); const dash = (p: SVGPathElement, progress: number) => { // A zero-length dash still paints a round cap, so an undrawn stroke is hidden outright. p.style.visibility = progress > 0.001 ? "visible" : "hidden"; p.style.strokeDasharray = progress >= 1 ? "" : `${progress} 1`; }; dash(shaft, v.shaft); dash(barbs[0], head1); dash(barbs[1], head2); }; const loop = (on: boolean) => { if (on === looping) return; looping = on; if (on) { frame.read(read, true); frame.render(render, true); } else { cancelFrame(read); cancelFrame(render); } }; /** Read and paint every frame for a while, because something may be moving. */ const poke = (ms: number = TIMING.watch) => { until = Math.max(until, performance.now() + ms); if (near) loop(true); }; const render = () => { paint(); const t = performance.now(); moving.forEach((props, el) => { props.forEach((at, name) => { if (t - at > TIMING.stale) props.delete(name); }); if (!props.size) moving.delete(el); }); // Settled: nothing of ours is animating, nothing in the scope is moving, nothing stirred lately. if (!Object.keys(runs).length && !moving.size && t > until) loop(false); }; const arm = () => { if (!o || triggered) return; if (o.trigger === "mount") { triggered = true; sync(); return; } const view = new IntersectionObserver( ([entry]) => { if (!entry?.isIntersecting) return; view.disconnect(); triggered = true; sync(); }, { threshold: JOT.view }, ); view.observe(els.root); observers.push(view); }; // Near the screen it watches for anything that could move a box; off screen it does nothing. const scope = els.root.closest("[data-jot-scope]") ?? document.body; const nearby = new IntersectionObserver( ([entry]) => { near = !!entry?.isIntersecting; if (near) poke(); else loop(false); }, { rootMargin: "200px" }, ); nearby.observe(els.root); const resize = new ResizeObserver((entries) => { if (entries.some((e) => e.target === els.words)) linesDirty = true; poke(); }); resize.observe(els.words); resize.observe(els.root); if (els.root.parentElement) resize.observe(els.root.parentElement); // Our own paint writes styles inside the note; only changes elsewhere count. const mutations = new MutationObserver((records) => { if (records.some((r) => !els.root.contains(r.target))) poke(TIMING.watch / 2); }); // Every attribute, so a selector like `[data-active]` finds its new match the moment it moves. mutations.observe(scope, { subtree: true, childList: true, characterData: true, attributes: true }); observers.push(nearby, resize, mutations); const stir = (e: Event) => { const el = e.target; const relevant = !(el instanceof Node) || scope.contains(el) || el.contains(els.root) || (!!target && el.contains(target)); if (!el || !relevant) return; if (e instanceof TransitionEvent || e instanceof AnimationEvent) { const name = e instanceof TransitionEvent ? e.propertyName : e.animationName; const props = moving.get(el) ?? new Map<string, number>(); if (e.type === "transitionend" || e.type === "transitioncancel" || e.type === "animationend" || e.type === "animationcancel") { props.delete(name); } else props.set(name, performance.now()); if (props.size) moving.set(el, props); else moving.delete(el); } poke(TIMING.tail); }; const events = [ "scroll", "transitionrun", "transitionend", "transitioncancel", "animationstart", "animationiteration", "animationend", "animationcancel", ]; events.forEach((type) => document.addEventListener(type, stir, { capture: true, passive: true })); const onResize = () => poke(); window.addEventListener("resize", onResize, { passive: true }); document.fonts?.ready.then(() => { linesDirty = true; poke(); }); return { set(next: PenOptions) { const prev = o; o = next; poke(); if (!prev || prev.text !== next.text) shake = tremor(next.text); if (!prev || prev.trigger !== next.trigger) arm(); if (prev && (prev.from !== next.from || prev.at !== next.at || prev.bend !== next.bend || prev.gap !== next.gap)) { startSwing(); } if (!prev || prev.show !== next.show || prev.reduced !== next.reduced) sync(); if (!prev || prev.describe !== next.describe || prev.show !== next.show) redescribe(); }, /** New words: the old ones wipe out from a copy, then the new ones write in. */ rewrite(el: HTMLElement) { if (!o || v.words <= 0) return; const { root } = els; const scale = root.offsetWidth ? root.getBoundingClientRect().width / root.offsetWidth || 1 : 1; el.style.width = width ? `${width}px` : "max-content"; ghost = { el, lines: measureLines(el, els.turner, scale) }; linesDirty = true; stop("words"); v.ghost = v.words; v.words = 0; const writing = triggered && o.show; if (o.reduced) { v.ghost = 0; v.words = writing ? 1 : 0; } else { const out = TIMING.unwrite * v.ghost * pace(); tween("ghost", 0, out, 0, "linear"); if (writing) tween("words", 1, writeTime(), out); } startSwing(); paint(true); }, destroy() { loop(false); observers.forEach((ob) => ob.disconnect()); events.forEach((type) => document.removeEventListener(type, stir, { capture: true })); window.removeEventListener("resize", onResize); (Object.keys(runs) as Key[]).forEach(stop); if (described) removeToken(described, "aria-describedby", id); described = null; o = null; }, }; } type Pen = ReturnType<typeof createPen>; /* ── Jot ──────────────────────────────────────────────── */ export interface JotProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> { /** The words. Keep them short: a few words on one or two lines. */ children: React.ReactNode; /** * What the arrow points at: a ref, or a CSS selector looked up inside the nearest * `[data-jot-scope]` ancestor (the whole document without one). Leave it out for a note * with no arrow. */ to?: React.RefObject<Element | null> | string; /** The side of the note the arrow leaves from. `"auto"` picks the side facing the target. */ from?: Side; /** The side of the target the arrow lands on. `"auto"` aims at its center and stops at its edge. */ at?: Side; /** How far the arrow bows, from -1 to 1. 0 is nearly straight; flip the sign to bow the other way. */ bend?: number; /** Curl the arrow once midway, like a quick flourish. */ loop?: boolean; /** Pixels left between the arrowhead and the target. */ gap?: number; /** Degrees to turn the words, for a looser hand. The arrow isn't turned. */ tilt?: number; /** When it writes itself: once half of it is on screen, or as soon as it mounts. */ trigger?: "view" | "mount"; /** Seconds to wait before the first write, to write several notes one after another. */ delay?: number; /** How fast the pen writes, draws and rubs out, as a multiple of the default pace. `2` is twice as fast. */ speed?: number; /** Set to false to rub the note out; true writes it back in. */ show?: boolean; /** Describe the target with the note's words for screen readers, through `aria-describedby`. */ describe?: boolean; } export function Jot({ children, to, from = "auto", at = "auto", bend = 0.35, loop = false, gap = 6, tilt = 0, trigger = "view", delay = TIMING.delay, speed = TIMING.speed, show = true, describe = true, className, ...props }: JotProps) { const reduced = useReducedMotion() ?? false; const id = React.useId(); const rootRef = React.useRef<HTMLDivElement>(null); const turnerRef = React.useRef<HTMLSpanElement>(null); const wordsRef = React.useRef<HTMLSpanElement>(null); const ghostRef = React.useRef<HTMLSpanElement>(null); const svgRef = React.useRef<SVGSVGElement>(null); const shaftRef = React.useRef<SVGPathElement>(null); const barbARef = React.useRef<SVGPathElement>(null); const barbBRef = React.useRef<SVGPathElement>(null); const penRef = React.useRef<Pen | null>(null); // New words keep the old ones on screen as a copy, so they can wipe out before the new ones write. const text = textOf(children); const [kept, setKept] = React.useState({ text, children }); const [ghost, setGhost] = React.useState<{ key: number; children: React.ReactNode } | null>(null); if (kept.text !== text) { setGhost({ key: (ghost?.key ?? 0) + 1, children: kept.children }); setKept({ text, children }); } React.useLayoutEffect(() => { const [root, turner, words, svg, shaft, a, b] = [ rootRef.current, turnerRef.current, wordsRef.current, svgRef.current, shaftRef.current, barbARef.current, barbBRef.current, ]; if (!root || !turner || !words || !svg || !shaft || !a || !b) return; const pen = createPen({ root, turner, words, svg, shaft, barbs: [a, b] }, id); penRef.current = pen; return () => { pen.destroy(); penRef.current = null; }; }, [id]); React.useLayoutEffect(() => { penRef.current?.set({ to, from, at, bend, loop, gap, trigger, delay, speed, show, describe, reduced, text }); }); React.useLayoutEffect(() => { if (ghost && ghostRef.current) penRef.current?.rewrite(ghostRef.current); }, [ghost]); return ( <div ref={rootRef} data-jot="" aria-hidden={show ? undefined : true} className={cn( "pointer-events-none relative z-10 w-max max-w-56 font-['Segoe_Print','Bradley_Hand',Chilanka,casual,cursive] text-lg/snug select-none", className, )} {...props} > <span ref={turnerRef} className="relative block" style={tilt ? { rotate: `${tilt}deg` } : undefined}> <span ref={wordsRef} id={id} className="block" style={{ clipPath: HIDDEN }}> {children} </span> {ghost ? ( <span key={ghost.key} ref={ghostRef} aria-hidden className="absolute top-0 left-0 block" style={{ clipPath: HIDDEN, visibility: "hidden" }} > {ghost.children} </span> ) : null} </span> <svg ref={svgRef} aria-hidden className="pointer-events-none absolute top-0 left-0 size-px overflow-visible" fill="none" stroke="currentColor" strokeWidth={1.75} strokeLinecap="round" strokeLinejoin="round" > <path ref={shaftRef} pathLength={1} style={{ visibility: "hidden" }} /> <path ref={barbARef} pathLength={1} style={{ visibility: "hidden" }} /> <path ref={barbBRef} pathLength={1} style={{ visibility: "hidden" }} /> </svg> </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 Jot. Each one installs whole, with the component included.
Follow the Pick
Three plans as radio cards. The note swings its arrow to whichever plan you pick and rewrites itself for it.
14 days free, then $24 a month
Start with Studio"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>
);
}
Add this moment from KeyframeUI to our project and adapt it to our content.
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.
This demo, Follow the Pick: Three plans as radio cards. The note swings its arrow to whichever plan you pick and rewrites itself for it.
1. Install it:
npx shadcn@latest add @keyframeui/jot-plans-demo
(If the CLI can't find @keyframeui, use https://keyframeui.com/r/jot-plans-demo.json instead.)
It adds the demo, components/jot-plans-demo.tsx, and the component, components/ui/jot.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 <Jot> on its own.
3. These props set its feel. Change them before you change the file:
- from (default "auto"): The side of the note the arrow leaves from. `"auto"` picks the side facing the target.
- at (default "auto"): The side of the target the arrow lands on. `"auto"` aims at its center and stops at its edge.
- bend (default 0.35): How far the arrow bows, from -1 to 1. 0 is nearly straight; flip the sign to bow the other way.
- loop (default false): Curl the arrow once midway, like a quick flourish.
- gap (default 6): Pixels left between the arrowhead and the target.
- tilt (default 0): Degrees to turn the words, for a looser hand. The arrow isn't turned.
- delay (default 0): Seconds to wait before the first write, to write several notes one after another.
- speed (default 1): How fast the pen writes, draws the arrow and rubs out, as a multiple of the default pace. `2` is twice as fast.
4. Don't change, in components/ui/jot.tsx:
- The pen lives outside React: it reads both boxes in motion's `frame.read` and paints in `frame.render`, only while something could move a box, and never off screen.
- The words are real text shown by a `clip-path` wipe, line by line; the strokes draw by `strokeDasharray` on `pathLength={1}` paths, and an undrawn one is hidden outright, since a zero-length dash still paints a round cap.
- The pen writes the words, sets off on the shaft before the last word is done, then draws the head's two strokes; rubbing out runs backward: head, shaft, words.
- The tremor is seeded from the words and fades out at both ends, so the line never shimmers and still starts and lands exactly.
- While shown, the target lists the note in `aria-describedby`; reduced motion drops the pen and fades the note and arrow in and out by opacity.
Its timing is all in the TIMING block at the top of that file.
Docs: https://keyframeui.com/components/jot.mdHints That Retire
A file-sharing card where each hint rubs itself out once you've done what it asks, and the next one writes in.
Send files
anything in
"use client";
import * as React from "react";
import { Jot } from "@/components/ui/jot";
const FILE = { name: "Brand guidelines.pdf", size: "4.2 MB" };
const LINK = "parcel.to/k7f2q";
type Step = "empty" | "uploading" | "ready" | "sent";
export function JotOnboardingDemo() {
const [step, setStep] = React.useState<Step>("empty");
const dropRef = React.useRef<HTMLButtonElement>(null);
const copyRef = React.useRef<HTMLButtonElement>(null);
// A short, pretend upload, so the second hint waits for the file to land.
React.useEffect(() => {
if (step !== "uploading") return;
const timer = window.setTimeout(() => setStep("ready"), 900);
return () => window.clearTimeout(timer);
}, [step]);
const hasFile = step !== "empty";
const sent = step === "sent";
return (
<div
data-jot-scope
className="@container/share relative grid w-full max-w-[52rem] justify-items-center px-2 pt-20 pb-24 @2xl/share:px-0 @2xl/share:py-10"
>
<div className="w-full max-w-[22rem] rounded-[1.5rem] border border-zinc-950/10 bg-white p-5 shadow-[0_1px_2px_rgb(0_0_0/0.04),0_12px_32px_-12px_rgb(0_0_0/0.12)] dark:border-white/10 dark:bg-zinc-900 dark:shadow-none">
<div className="flex items-center justify-between text-xs text-zinc-500 dark:text-zinc-400">
<span className="flex items-center gap-1.5 font-medium text-zinc-950 dark:text-white">
<svg viewBox="0 0 16 16" aria-hidden className="size-4">
<path d="M2.5 5.2 8 2.5l5.5 2.7v5.6L8 13.5l-5.5-2.7z" className="fill-amber-400" />
<path d="M2.5 5.2 8 8l5.5-2.8M8 8v5.5" className="fill-none stroke-amber-700" strokeWidth="1.1" strokeLinejoin="round" />
</svg>
Parcel
</span>
Links last 7 days
</div>
<p className="mt-4 text-lg/tight font-semibold tracking-[-0.02em] text-zinc-950 dark:text-white">Send files</p>
<button
ref={dropRef}
type="button"
disabled={hasFile}
onClick={() => setStep("uploading")}
className="mt-3 grid w-full justify-items-center gap-1 rounded-2xl border border-dashed border-zinc-950/20 bg-zinc-50 px-4 py-6 text-center outline-none transition-colors duration-150 enabled:hover:border-zinc-950/40 enabled:hover:bg-zinc-100/70 focus-visible:ring-2 focus-visible:ring-blue-500 disabled:cursor-default disabled:opacity-50 dark:border-white/15 dark:bg-white/[0.03] dark:enabled:hover:border-white/30 dark:enabled:hover:bg-white/[0.06]"
>
<svg viewBox="0 0 20 20" aria-hidden className="size-5 text-zinc-500 dark:text-zinc-400">
<path d="M10 13V4m0 0L6.5 7.5M10 4l3.5 3.5M4 13.5v1A1.5 1.5 0 0 0 5.5 16h9a1.5 1.5 0 0 0 1.5-1.5v-1" className="fill-none stroke-current" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
<span className="text-sm font-medium text-zinc-950 dark:text-white">Drop files here</span>
<span className="text-xs text-zinc-500 dark:text-zinc-400">or click to add a sample file</span>
</button>
<div className="mt-3 flex h-12 items-center gap-3 rounded-xl px-1" aria-live="polite">
{hasFile ? (
<>
<span className="grid size-9 shrink-0 place-items-center rounded-lg bg-red-50 text-[10px] font-semibold text-red-600 dark:bg-red-500/10 dark:text-red-400">
PDF
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-zinc-950 dark:text-white">{FILE.name}</span>
<span className="mt-1 block h-1 overflow-hidden rounded-full bg-zinc-100 dark:bg-white/10">
<span
className={
step === "uploading"
? "block h-full w-full origin-left rounded-full bg-zinc-950 transition-transform duration-[900ms] ease-out starting:scale-x-0 dark:bg-white"
: "block h-full w-full rounded-full bg-emerald-500"
}
/>
</span>
</span>
<span className="text-xs text-zinc-500 tabular-nums dark:text-zinc-400">
{step === "uploading" ? "Uploading" : FILE.size}
</span>
</>
) : (
<span className="w-full text-center text-xs text-zinc-400 dark:text-zinc-500">No files yet</span>
)}
</div>
<div className="mt-3 flex items-center gap-2 border-t border-zinc-950/[0.06] pt-4 dark:border-white/[0.06]">
<span className="min-w-0 flex-1 truncate rounded-full bg-zinc-100 px-3 py-2 font-mono text-xs text-zinc-500 dark:bg-white/5 dark:text-zinc-400">
{step === "ready" || sent ? LINK : "Your link shows up here"}
</span>
<button
ref={copyRef}
type="button"
disabled={step !== "ready" && !sent}
onClick={() => setStep("sent")}
className="inline-flex h-9 shrink-0 items-center gap-1.5 rounded-full bg-zinc-950 px-4 text-sm font-medium text-white outline-none transition-opacity duration-150 enabled:hover:bg-zinc-800 focus-visible:ring-2 focus-visible:ring-zinc-950 focus-visible:ring-offset-2 disabled:opacity-35 dark:bg-white dark:text-zinc-950 dark:enabled:hover:bg-zinc-200 dark:focus-visible:ring-white dark:focus-visible:ring-offset-zinc-900"
>
{sent ? (
<>
<svg viewBox="0 0 16 16" aria-hidden className="size-4">
<path d="m3.5 8.5 3 3 6-7" className="fill-none stroke-current" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" />
</svg>
Copied
</>
) : (
"Copy link"
)}
</button>
</div>
</div>
<button
type="button"
onClick={() => setStep("empty")}
className={
"mt-4 rounded-sm text-xs font-medium text-zinc-500 underline decoration-zinc-300 underline-offset-4 outline-none transition-opacity duration-200 hover:text-zinc-950 focus-visible:ring-2 focus-visible:ring-zinc-950 dark:text-zinc-400 dark:decoration-zinc-700 dark:hover:text-white " +
(sent ? "opacity-100" : "pointer-events-none opacity-0")
}
tabIndex={sent ? 0 : -1}
aria-hidden={!sent}
>
Start over
</button>
<Jot
to={dropRef}
show={step === "empty"}
bend={0.3}
tilt={-3}
className="absolute top-4 left-4 text-xl/snug text-blue-600 @2xl/share:top-[30%] @2xl/share:left-4 dark:text-blue-400"
>
start here: drop
<br />
anything in
</Jot>
<Jot
to={copyRef}
show={step === "ready"}
bend={0.35}
tilt={2}
className="absolute right-4 bottom-6 text-xl/snug text-blue-600 @2xl/share:top-[62%] @2xl/share:right-4 @2xl/share:bottom-auto dark:text-blue-400"
>
now grab the link
</Jot>
<Jot
show={sent}
tilt={-2}
className="absolute bottom-6 left-4 text-xl/snug text-emerald-600 @2xl/share:top-[64%] @2xl/share:bottom-auto @2xl/share:left-4 dark:text-emerald-400"
>
that's the whole thing
</Jot>
</div>
);
}
Add this moment from KeyframeUI to our project and adapt it to our content.
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.
This demo, Hints That Retire: A file-sharing card where each hint rubs itself out once you've done what it asks, and the next one writes in.
1. Install it:
npx shadcn@latest add @keyframeui/jot-onboarding-demo
(If the CLI can't find @keyframeui, use https://keyframeui.com/r/jot-onboarding-demo.json instead.)
It adds the demo, components/jot-onboarding-demo.tsx, and the component, components/ui/jot.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 <Jot> on its own.
3. These props set its feel. Change them before you change the file:
- from (default "auto"): The side of the note the arrow leaves from. `"auto"` picks the side facing the target.
- at (default "auto"): The side of the target the arrow lands on. `"auto"` aims at its center and stops at its edge.
- bend (default 0.35): How far the arrow bows, from -1 to 1. 0 is nearly straight; flip the sign to bow the other way.
- loop (default false): Curl the arrow once midway, like a quick flourish.
- gap (default 6): Pixels left between the arrowhead and the target.
- tilt (default 0): Degrees to turn the words, for a looser hand. The arrow isn't turned.
- delay (default 0): Seconds to wait before the first write, to write several notes one after another.
- speed (default 1): How fast the pen writes, draws the arrow and rubs out, as a multiple of the default pace. `2` is twice as fast.
4. Don't change, in components/ui/jot.tsx:
- The pen lives outside React: it reads both boxes in motion's `frame.read` and paints in `frame.render`, only while something could move a box, and never off screen.
- The words are real text shown by a `clip-path` wipe, line by line; the strokes draw by `strokeDasharray` on `pathLength={1}` paths, and an undrawn one is hidden outright, since a zero-length dash still paints a round cap.
- The pen writes the words, sets off on the shaft before the last word is done, then draws the head's two strokes; rubbing out runs backward: head, shaft, words.
- The tremor is seeded from the words and fades out at both ends, so the line never shimmers and still starts and lands exactly.
- While shown, the target lists the note in `aria-describedby`; reduced motion drops the pen and fades the note and arrow in and out by opacity.
Its timing is all in the TIMING block at the top of that file.
Docs: https://keyframeui.com/components/jot.mdProps
| Prop | Type | Default | Description |
|---|---|---|---|
children Required | React.ReactNode | None | The words. Keep them short: a few words on one or two lines. |
to | React.RefObject<Element | null> | string | None | What the arrow points at: a ref, or a CSS selector looked up inside the nearest [data-jot-scope] ancestor (the whole document without one). Leave it out for a note with no arrow. |
from | "auto" | "top" | "right" | "bottom" | "left" | "auto" | The side of the note the arrow leaves from. "auto" picks the side facing the target. |
at | "auto" | "top" | "right" | "bottom" | "left" | "auto" | The side of the target the arrow lands on. "auto" aims at its center and stops at its edge. |
bend | number | 0.35 | How far the arrow bows, from -1 to 1. 0 is nearly straight; flip the sign to bow the other way. |
loop | boolean | false | Curl the arrow once midway, like a quick flourish. |
gap | number | 6 | Pixels left between the arrowhead and the target. |
tilt | number | 0 | Degrees to turn the words, for a looser hand. The arrow isn't turned. |
trigger | "view" | "mount" | "view" | When it writes itself: once half of it is on screen, or as soon as it mounts. |
delay | number | 0 | Seconds to wait before the first write, to write several notes one after another. |
speed | number | 1 | How fast the pen writes, draws the arrow and rubs out, as a multiple of the default pace. 2 is twice as fast. |
show | boolean | true | Set to false to rub the note out; true writes it back in. |
describe | boolean | true | Describe the target with the note's words for screen readers, through aria-describedby. |
className | string | None | Extra classes for the note: where it sits, its text color (the arrow takes it too), size and width. The words use a stack of system handwriting fonts; set your own with a font-* class. |
childrenRequiredType
React.ReactNodeThe words. Keep them short: a few words on one or two lines.
toType
React.RefObject<Element | null> | stringWhat the arrow points at: a ref, or a CSS selector looked up inside the nearest
[data-jot-scope]ancestor (the whole document without one). Leave it out for a note with no arrow.fromType
"auto" | "top" | "right" | "bottom" | "left"Default"auto"The side of the note the arrow leaves from.
"auto"picks the side facing the target.atType
"auto" | "top" | "right" | "bottom" | "left"Default"auto"The side of the target the arrow lands on.
"auto"aims at its center and stops at its edge.bendType
numberDefault0.35How far the arrow bows, from -1 to 1. 0 is nearly straight; flip the sign to bow the other way.
loopType
booleanDefaultfalseCurl the arrow once midway, like a quick flourish.
gapType
numberDefault6Pixels left between the arrowhead and the target.
tiltType
numberDefault0Degrees to turn the words, for a looser hand. The arrow isn't turned.
triggerType
"view" | "mount"Default"view"When it writes itself: once half of it is on screen, or as soon as it mounts.
delayType
numberDefault0Seconds to wait before the first write, to write several notes one after another.
speedType
numberDefault1How fast the pen writes, draws the arrow and rubs out, as a multiple of the default pace.
2is twice as fast.showType
booleanDefaulttrueSet to false to rub the note out; true writes it back in.
describeType
booleanDefaulttrueDescribe the target with the note's words for screen readers, through
aria-describedby.classNameType
stringExtra classes for the note: where it sits, its text color (the arrow takes it too), size and width. The words use a stack of system handwriting fonts; set your own with a
font-*class.