Skip to content

Handwritten Notes

Testimonial notes scattered on a cutting mat: drag cards with spring physics and click to bring one forward.

  • Testimonials
  • Education
  • React only · 0 deps
  • 1 file
Full screen

Browser support · All evergreen browsers (Pointer Events). On narrow screens notes stack and drag sideways so the page still scrolls.

Install. One command, or copy the file.

$npx shadcn@latest add https://freedesignmd.com/r/handwritten-notes.json
  • Byte-identical to the preview
  • No Tailwind or config needed
  • SSR-safe · keyboard accessible
  • Respects reduced motion & transparency
components/testimonials/handwritten-notes.tsx
1/**
2 * HandwrittenNotes, student testimonials as paper notes scattered on a
3 * cutting mat: index cards, sticky notes, a kraft card, a page of graph paper
4 * and a postcard, some with a taped photo. Drag any note to rearrange it
5 * (pointer events + a small spring integrator), click or focus brings it to
6 * the front, "Put them back" springs everything home.
7 *
8 * Props: notes (Note[]), eyebrow, title, stats ([value, label][]), className.
9 * Note: { id, kind, text, name, meta, photo?, x, y, rot, w }
10 * kind: "index" | "sticky" | "pink" | "kraft" | "graph" | "postcard"
11 * x (% of desk width), y (px from desk top), rot (deg), w (px). Desktop
12 * only; under 760px notes stack in a loose column and drag sideways
13 * (vertical swipes keep scrolling the page).
14 *
15 * Handwriting: a cursive system stack ("Bradley Hand", "Segoe Print",
16 * "Marker Felt") is used only for short sticky notes and signatures; longer
17 * notes are set in a readable serif.
18 * Browser support: all evergreen browsers (Pointer Events).
19 * Accessibility: each note is a focusable <figure> with an explicit label;
20 * arrow keys move the focused note (Shift = bigger steps), Enter brings it to
21 * the front. Reduced motion: no spring or tilt, notes move 1:1.
22 */
23import { useEffect, useRef, useState, type KeyboardEvent, type PointerEvent } from "react";
24
25export type NoteKind = "index" | "sticky" | "pink" | "kraft" | "graph" | "postcard";
26export interface Note { id: string; kind: NoteKind; text: string; name: string; meta: string; photo?: string; x: number; y: number; rot: number; w: number }
27
28export interface HandwrittenNotesProps {
29 notes?: Note[];
30 eyebrow?: string;
31 title?: string;
32 stats?: [string, string][];
33 className?: string;
34}
35
36const P = "/img/people/";
37
38export const DEFAULT_NOTES: Note[] = [
39 { id: "marta", kind: "index", x: 1.5, y: 36, rot: -3, w: 280, name: "Marta", meta: "Lisbon · cohort 14",
40 text: "I hadn’t drawn since I was eleven. On day one my mug looked like a bucket. On day thirty I drew my dad’s hands and he put it on the fridge." },
41 { id: "ben", kind: "sticky", x: 27, y: 4, rot: 4, w: 196, name: "Ben", meta: "cohort 12",
42 text: "Ten minutes a day. That’s it. That’s the whole trick." },
43 { id: "hyunwoo", kind: "kraft", x: 46, y: 58, rot: -2, w: 276, photo: P + "p8.webp", name: "Hyun-woo", meta: "Seoul · cohort 15",
44 text: "Perspective week nearly broke me. Tomoko’s “draw the box, then lie about it” video fixed it in six minutes." },
45 { id: "priya", kind: "graph", x: 74, y: 16, rot: 3, w: 262, photo: P + "p6.webp", name: "Priya", meta: "Leeds · cohort 13",
46 text: "My sketchbook from March has 61 pages. My sketchbooks from the five years before that have 9 pages, combined." },
47 { id: "kofi", kind: "pink", x: 4, y: 400, rot: 5, w: 236, photo: P + "p4.webp", name: "Kofi", meta: "cohort 14",
48 text: "Drew on the 7:42 train every day. A stranger asked to buy one." },
49 { id: "grace", kind: "postcard", x: 29, y: 372, rot: -3.5, w: 360, photo: P + "p9.webp", name: "Grace", meta: "Cape Town · cohort 11",
50 text: "Dear Tomoko, I was the one who kept apologising in the feedback threads. I’ve stopped. Thank you for being so kind about my terrible ellipses." },
51 { id: "lukas", kind: "index", x: 67, y: 420, rot: 2, w: 290, name: "Lukas", meta: "Graz · cohort 15",
52 text: "Worth it for watercolour week alone. I finally understand why every green I mixed for ten years turned into mud." },
53];
54
55type Body = { x: number; y: number; vx: number; vy: number; tx: number; ty: number; r: number; vr: number; s: number; vs: number; drag: boolean };
56
57const K = 380, C = 30;
58
59export function HandwrittenNotes({
60 notes = DEFAULT_NOTES,
61 eyebrow = "Line & Wash · a 30-day sketching course",
62 title = "Day thirty. Here’s what they left on the desk.",
63 stats = [["2,140", "students since 2023"], ["86%", "finish all 30 days"], ["10 min", "a day, honestly"]],
64 className,
65}: HandwrittenNotesProps) {
66 const desk = useRef<HTMLDivElement>(null);
67 const els = useRef(new Map<string, HTMLElement>());
68 const bodies = useRef(new Map<string, Body>());
69 const raf = useRef(0);
70 const reduced = useRef(false);
71 const [order, setOrder] = useState(() => notes.map((n) => n.id));
72 const [moved, setMoved] = useState(false);
73 const drag = useRef<{ id: string; px: number; py: number; ox: number; oy: number; lx: number; lt: number; vx: number } | null>(null);
74
75 const body = (id: string) => {
76 let b = bodies.current.get(id);
77 if (!b) { b = { x: 0, y: 0, vx: 0, vy: 0, tx: 0, ty: 0, r: 0, vr: 0, s: 1, vs: 0, drag: false }; bodies.current.set(id, b); }
78 return b;
79 };
80 const rest = (id: string) => notes.find((n) => n.id === id)?.rot ?? 0;
81
82 const paint = (id: string) => {
83 const el = els.current.get(id);
84 const b = bodies.current.get(id);
85 if (el && b) el.style.transform = `translate3d(${b.x.toFixed(2)}px,${b.y.toFixed(2)}px,0) rotate(${(rest(id) + b.r).toFixed(2)}deg) scale(${b.s.toFixed(4)})`;
86 };
87
88 const kick = () => {
89 if (raf.current) return;
90 let last = performance.now();
91 const step = (t: number) => {
92 const dt = Math.min(1 / 30, (t - last) / 1000);
93 last = t;
94 let busy = false;
95 bodies.current.forEach((b, id) => {
96 const tr = b.drag ? Math.max(-14, Math.min(14, (drag.current?.vx ?? 0) * 0.018)) : 0;
97 const ts = b.drag ? 1.045 : 1;
98 if (reduced.current) { b.x = b.tx; b.y = b.ty; b.r = 0; b.s = 1; b.vx = b.vy = b.vr = b.vs = 0; paint(id); return; }
99 b.vx += (-K * (b.x - b.tx) - C * b.vx) * dt; b.x += b.vx * dt;
100 b.vy += (-K * (b.y - b.ty) - C * b.vy) * dt; b.y += b.vy * dt;
101 b.vr += (-K * 0.6 * (b.r - tr) - C * 0.7 * b.vr) * dt; b.r += b.vr * dt;
102 b.vs += (-K * (b.s - ts) - C * b.vs) * dt; b.s += b.vs * dt;
103 const still = Math.abs(b.x - b.tx) < 0.1 && Math.abs(b.y - b.ty) < 0.1 && Math.abs(b.vx) + Math.abs(b.vy) < 0.5 && Math.abs(b.r - tr) < 0.05 && Math.abs(b.s - ts) < 0.0005 && Math.abs(b.vr) < 0.1;
104 if (still && !b.drag) { b.x = b.tx; b.y = b.ty; b.r = 0; b.s = 1; }
105 else busy = true;
106 paint(id);
107 });
108 raf.current = busy ? requestAnimationFrame(step) : 0;
109 };
110 raf.current = requestAnimationFrame(step);
111 };
112
113 useEffect(() => {
114 reduced.current = matchMedia("(prefers-reduced-motion: reduce)").matches;
115 return () => cancelAnimationFrame(raf.current);
116 }, []);
117
118 const front = (id: string) => setOrder((o) => (o[o.length - 1] === id ? o : [...o.filter((x) => x !== id), id]));
119
120 const clampTarget = (id: string, b: Body) => {
121 const d = desk.current, el = els.current.get(id);
122 if (!d || !el) return;
123 const dr = d.getBoundingClientRect();
124 const left = el.offsetLeft, top = el.offsetTop, w = el.offsetWidth, h = el.offsetHeight;
125 const m = 40;
126 b.tx = Math.max(-left - w + m + 60, Math.min(dr.width - left - m - 20, b.tx));
127 b.ty = Math.max(-top - 20, Math.min(dr.height - top - m, b.ty));
128 };
129
130 const onDown = (id: string) => (e: PointerEvent<HTMLElement>) => {
131 if (e.button !== 0) return;
132 front(id);
133 const b = body(id);
134 b.drag = true;
135 drag.current = { id, px: e.clientX, py: e.clientY, ox: b.tx, oy: b.ty, lx: e.clientX, lt: performance.now(), vx: 0 };
136 e.currentTarget.setPointerCapture(e.pointerId);
137 kick();
138 };
139 const onMove = (id: string) => (e: PointerEvent<HTMLElement>) => {
140 const d = drag.current;
141 if (!d || d.id !== id) return;
142 const b = body(id);
143 b.tx = d.ox + e.clientX - d.px;
144 b.ty = d.oy + e.clientY - d.py;
145 clampTarget(id, b);
146 const now = performance.now();
147 const inst = ((e.clientX - d.lx) / Math.max(1, now - d.lt)) * 1000;
148 d.vx = d.vx * 0.7 + inst * 0.3;
149 d.lx = e.clientX; d.lt = now;
150 if (Math.abs(b.tx) + Math.abs(b.ty) > 4) setMoved(true);
151 kick();
152 };
153 const onUp = (id: string) => () => {
154 const b = bodies.current.get(id);
155 if (b) b.drag = false;
156 drag.current = null;
157 kick();
158 };
159 const onKey = (id: string) => (e: KeyboardEvent<HTMLElement>) => {
160 const step = e.shiftKey ? 64 : 16;
161 const b = body(id);
162 if (e.key === "ArrowLeft") b.tx -= step;
163 else if (e.key === "ArrowRight") b.tx += step;
164 else if (e.key === "ArrowUp") b.ty -= step;
165 else if (e.key === "ArrowDown") b.ty += step;
166 else if (e.key === "Enter" || e.key === " ") { front(id); e.preventDefault(); return; }
167 else return;
168 e.preventDefault();
169 clampTarget(id, b);
170 front(id);
171 setMoved(true);
172 kick();
173 };
174 const putBack = () => {
175 bodies.current.forEach((b) => { b.tx = 0; b.ty = 0; });
176 setOrder(notes.map((n) => n.id));
177 setMoved(false);
178 kick();
179 };
180
181 return (
182 <section className={`fd-hwn${className ? " " + className : ""}`} aria-label="Student notes">
183 <style>{css}</style>
184 <div className="fd-hwn-head">
185 <div>
186 <p className="fd-hwn-eyebrow">{eyebrow}</p>
187 <h2 className="fd-hwn-title">{title}</h2>
188 </div>
189 <dl className="fd-hwn-stats">
190 {stats.map(([v, l]) => <div key={l}><dt>{l}</dt><dd>{v}</dd></div>)}
191 </dl>
192 </div>
193 <div className="fd-hwn-desk" ref={desk}>
194 <div className="fd-hwn-ruler" aria-hidden="true" />
195 <p className="fd-hwn-tip" aria-hidden="true">
196 <svg width="34" height="22" viewBox="0 0 34 22" aria-hidden="true"><path d="M2 4c8 0 14 2 20 8 2.4 2.4 4 5 5 8M27 20l-1-6M27 20l-6-2" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" /></svg>
197 drag them around
198 </p>
199 {notes.map((n) => {
200 const z = order.indexOf(n.id) + 1;
201 const long = n.text.length > 70;
202 return (
203 <figure
204 key={n.id}
205 ref={(el) => { if (el) els.current.set(n.id, el); else els.current.delete(n.id); }}
206 className="fd-hwn-note"
207 data-kind={n.kind}
208 data-hand={!long || undefined}
209 data-top={z === order.length || undefined}
210 tabIndex={0}
211 aria-roledescription="movable note"
212 aria-label={`Note from ${n.name}, ${n.meta}: ${n.text}`}
213 style={{ left: `${n.x}%`, top: n.y, width: n.w, zIndex: z, transform: `rotate(${n.rot}deg)`, ["--rot" as string]: `${n.rot}deg` }}
214 onPointerDown={onDown(n.id)}
215 onPointerMove={onMove(n.id)}
216 onPointerUp={onUp(n.id)}
217 onPointerCancel={onUp(n.id)}
218 onFocus={() => front(n.id)}
219 onKeyDown={onKey(n.id)}
220 >
221 {n.kind === "postcard" && <span className="fd-hwn-stamp" aria-hidden="true"><span>30</span><i>DAYS</i></span>}
222 {n.photo && (
223 <span className="fd-hwn-photo" aria-hidden="true">
224 <img src={n.photo} alt="" width={84} height={96} loading="lazy" decoding="async" draggable={false} />
225 <i className="fd-hwn-tape" />
226 </span>
227 )}
228 {(n.kind === "index" || n.kind === "kraft" || n.kind === "graph") && !n.photo && <i className="fd-hwn-tape fd-hwn-tape-top" aria-hidden="true" />}
229 <blockquote className="fd-hwn-text" aria-hidden="true"><p>{n.text}</p></blockquote>
230 <figcaption className="fd-hwn-sig" aria-hidden="true">
231 <span className="fd-hwn-name">{n.name}</span>
232 <span className="fd-hwn-meta">{n.meta}</span>
233 </figcaption>
234 </figure>
235 );
236 })}
237 <button type="button" className="fd-hwn-reset" onClick={putBack} data-show={moved || undefined} aria-hidden={!moved || undefined} tabIndex={moved ? 0 : -1}>
238 <svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M2.5 7a4.5 4.5 0 107.7-3.2M10.5 1.5v2.5H8" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /></svg>
239 Put them back
240 </button>
241 </div>
242 </section>
243 );
244}
245
246const SERIF = `"Iowan Old Style","Palatino Linotype",Palatino,"Book Antiqua",Georgia,serif`;
247const SANS = `ui-sans-serif,-apple-system,BlinkMacSystemFont,"SF Pro Text","Inter","Segoe UI",Roboto,sans-serif`;
248const MONO = `ui-monospace,"SF Mono","JetBrains Mono",Menlo,monospace`;
249const HAND = `"Bradley Hand","Segoe Print","Marker Felt","Chalkboard SE","Comic Neue",cursive`;
250
251const css = `
252.fd-hwn,.fd-hwn *,.fd-hwn *::before,.fd-hwn *::after{box-sizing:border-box}
253.fd-hwn{--fd-ease-out:cubic-bezier(.22,1,.36,1);--fd-spring:cubic-bezier(.34,1.56,.64,1);--fd-dur-1:120ms;--fd-dur-2:240ms;--fd-dur-3:420ms;
254 --fd-hwn-mat:#1E3B30;--fd-hwn-grid:rgba(214,232,214,.09);--fd-hwn-grid2:rgba(214,232,214,.18);--fd-hwn-cream:#EFE8D6;--fd-hwn-mut:#9DB5A6;--fd-hwn-ink:#2A2620;--fd-hwn-marker:#F2C14E;
255 display:block;max-width:1240px;margin:0 auto;padding:72px 32px 80px;background:#15291F;color:var(--fd-hwn-cream);
256 font:400 15px/1.5 ${SANS};letter-spacing:0;-webkit-font-smoothing:antialiased}
257.fd-hwn-head{display:flex;align-items:flex-end;justify-content:space-between;gap:40px;margin:0 0 32px;padding:0}
258.fd-hwn-eyebrow{margin:0 0 14px;padding:0;font:500 12px/1.2 ${MONO};letter-spacing:.1em;text-transform:uppercase;color:var(--fd-hwn-marker)}
259.fd-hwn-title{margin:0;padding:0;max-width:18ch;font:400 clamp(2.25rem,4.6vw,3.75rem)/1.02 ${SERIF};letter-spacing:-.03em;color:var(--fd-hwn-cream);text-wrap:balance}
260.fd-hwn-stats{display:flex;gap:32px;margin:0;padding:0 0 6px}
261.fd-hwn-stats div{display:flex;flex-direction:column-reverse;gap:6px;margin:0;padding:0 0 0 16px;border-left:1px solid rgba(239,232,214,.2)}
262.fd-hwn-stats dt{margin:0;padding:0;font:400 12px/1.3 ${SANS};color:var(--fd-hwn-mut)}
263.fd-hwn-stats dd{margin:0;padding:0;font:400 1.75rem/1 ${SERIF};letter-spacing:-.02em;color:var(--fd-hwn-cream)}
264.fd-hwn-desk{position:relative;height:720px;margin:0;padding:0;border-radius:18px;overflow:hidden;touch-action:pan-y;
265 background-color:var(--fd-hwn-mat);
266 background-image:linear-gradient(var(--fd-hwn-grid2) 1px,transparent 1px),linear-gradient(90deg,var(--fd-hwn-grid2) 1px,transparent 1px),linear-gradient(var(--fd-hwn-grid) 1px,transparent 1px),linear-gradient(90deg,var(--fd-hwn-grid) 1px,transparent 1px);
267 background-size:120px 120px,120px 120px,24px 24px,24px 24px;background-position:-1px -1px;box-shadow:inset 0 0 0 1px rgba(214,232,214,.12),inset 0 0 120px rgba(0,0,0,.25)}
268.fd-hwn-ruler{position:absolute;left:0;right:0;bottom:0;height:22px;pointer-events:none;
269 background-image:linear-gradient(90deg,rgba(214,232,214,.55) 1px,transparent 1px),linear-gradient(90deg,rgba(214,232,214,.3) 1px,transparent 1px);background-size:120px 22px,24px 10px;background-repeat:repeat-x;background-position:-1px 0,-1px 12px}
270.fd-hwn-tip{position:absolute;right:28px;bottom:36px;display:flex;align-items:flex-end;gap:6px;margin:0;padding:0;transform:rotate(-4deg);font:400 1.125rem/1 ${HAND};color:var(--fd-hwn-marker);opacity:.9;pointer-events:none}
271.fd-hwn-tip svg{display:block;transform:scaleX(-1)}
272.fd-hwn-note{position:absolute;display:block;margin:0;padding:22px 22px 18px;border-radius:2px;color:var(--fd-hwn-ink);background:#FBFAF6;cursor:grab;user-select:none;-webkit-user-select:none;touch-action:none;outline:none;will-change:transform;
273 box-shadow:0 1px 1px rgba(0,0,0,.18),0 10px 24px -8px rgba(0,0,0,.45);transition:box-shadow var(--fd-dur-2) var(--fd-ease-out)}
274.fd-hwn-note:active{cursor:grabbing;box-shadow:0 2px 2px rgba(0,0,0,.15),0 28px 50px -12px rgba(0,0,0,.55)}
275.fd-hwn-note:focus-visible{box-shadow:0 0 0 2px var(--fd-hwn-mat),0 0 0 4px rgba(242,193,78,.75),0 10px 24px -8px rgba(0,0,0,.45)}
276.fd-hwn-note[data-kind="index"]{background-color:#FBFAF6;background-image:linear-gradient(transparent 39px,rgba(209,74,64,.55) 39px,rgba(209,74,64,.55) 40px,transparent 40px),repeating-linear-gradient(transparent 0 23px,rgba(80,120,170,.28) 23px 24px);background-position:0 0,0 40px;padding-top:12px}
277.fd-hwn-note[data-kind="index"] .fd-hwn-text{margin-top:36px}
278.fd-hwn-note[data-kind="sticky"]{background:#F6DF74;border-radius:1px 1px 18px 1px/1px 1px 6px 1px;padding:24px 22px 20px}
279.fd-hwn-note[data-kind="pink"]{background:#F3B7A9;border-radius:1px 1px 1px 16px/1px 1px 1px 6px;padding:20px 20px 18px}
280.fd-hwn-note[data-kind="kraft"]{background:#C9A57A;color:#2B2116;background-image:radial-gradient(rgba(90,60,30,.12) 1px,transparent 1.5px);background-size:7px 7px}
281.fd-hwn-note[data-kind="graph"]{background-color:#F7F7F1;background-image:linear-gradient(rgba(60,140,140,.2) 1px,transparent 1px),linear-gradient(90deg,rgba(60,140,140,.2) 1px,transparent 1px);background-size:14px 14px;
282 -webkit-mask:radial-gradient(5px at 50% 0,transparent 98%,#000) 0 0/14px 100% repeat-x;mask:radial-gradient(5px at 50% 0,transparent 98%,#000) 0 0/14px 100% repeat-x;padding-top:26px}
283.fd-hwn-note[data-kind="postcard"]{background:#F2EBDC;padding:22px 26px 20px;background-image:linear-gradient(90deg,transparent calc(62% - 1px),rgba(42,38,32,.22) calc(62% - 1px),rgba(42,38,32,.22) 62%,transparent 62%)}
284.fd-hwn-note[data-kind="postcard"] .fd-hwn-text{width:58%}
285.fd-hwn-note[data-kind="postcard"] .fd-hwn-sig{width:58%}
286.fd-hwn-note[data-kind="postcard"] .fd-hwn-photo{position:absolute;right:22px;top:auto;bottom:20px;transform:rotate(3deg)}
287.fd-hwn-stamp{position:absolute;right:20px;top:18px;display:flex;flex-direction:column;align-items:center;justify-content:center;width:52px;height:60px;border:2px dashed rgba(192,72,52,.75);color:#B6452F;transform:rotate(4deg)}
288.fd-hwn-stamp span{display:block;font:700 1.25rem/1 ${SERIF}}
289.fd-hwn-stamp i{display:block;font:600 8px/1 ${MONO};font-style:normal;letter-spacing:.14em}
290.fd-hwn-photo{position:relative;float:right;display:block;width:76px;margin:-4px -4px 8px 12px;padding:5px 5px 14px;background:#fff;box-shadow:0 1px 3px rgba(0,0,0,.25);transform:rotate(4deg)}
291.fd-hwn-photo img{display:block;width:66px;height:72px;object-fit:cover;object-position:50% 25%;pointer-events:none}
292.fd-hwn-tape{position:absolute;left:50%;top:-10px;display:block;width:56px;height:18px;margin-left:-28px;background:rgba(240,236,214,.62);box-shadow:0 1px 1px rgba(0,0,0,.08);transform:rotate(-6deg)}
293.fd-hwn-photo .fd-hwn-tape{top:-9px;width:44px;margin-left:-22px;transform:rotate(8deg)}
294.fd-hwn-text{margin:0;padding:0}
295.fd-hwn-text p{margin:0;padding:0;font:400 1.0625rem/1.42 ${SERIF};letter-spacing:-.005em;color:inherit;text-wrap:pretty}
296.fd-hwn-note[data-kind="index"] .fd-hwn-text p{line-height:24px}
297.fd-hwn-note[data-hand] .fd-hwn-text p{font:400 1.375rem/1.25 ${HAND};letter-spacing:0}
298.fd-hwn-sig{display:flex;align-items:baseline;flex-wrap:wrap;gap:4px 10px;clear:both;margin:14px 0 0;padding:0}
299.fd-hwn-name{font:400 1.25rem/1 ${HAND};color:#1F3F8F}
300.fd-hwn-note[data-kind="kraft"] .fd-hwn-name,.fd-hwn-note[data-kind="sticky"] .fd-hwn-name,.fd-hwn-note[data-kind="pink"] .fd-hwn-name{color:#2B2116}
301.fd-hwn-meta{font:500 10px/1.2 ${MONO};letter-spacing:.08em;text-transform:uppercase;color:rgba(42,38,32,.6)}
302.fd-hwn-reset{appearance:none;-webkit-appearance:none;position:absolute;left:24px;bottom:36px;z-index:100;display:inline-flex;align-items:center;gap:8px;height:36px;margin:0;padding:0 14px;border:0;border-radius:9999px;background:var(--fd-hwn-cream);color:#15291F;cursor:pointer;
303 font:600 13px/1 ${SANS};opacity:0;transform:translateY(8px);pointer-events:none;transition:opacity var(--fd-dur-2),transform var(--fd-dur-3) var(--fd-spring)}
304.fd-hwn-reset svg{display:block}
305.fd-hwn-reset[data-show]{opacity:1;transform:none;pointer-events:auto}
306.fd-hwn-reset:active{transform:scale(.96)}
307.fd-hwn-reset:focus-visible{outline:2px solid rgba(242,193,78,.75);outline-offset:2px}
308@media (max-width:1100px) and (min-width:761px){.fd-hwn-desk{height:900px}}
309@media (max-width:760px){
310 .fd-hwn{padding:48px 16px 56px}
311 .fd-hwn-head{flex-direction:column;align-items:flex-start;gap:24px;margin-bottom:24px}
312 .fd-hwn-stats{gap:16px;flex-wrap:wrap}
313 .fd-hwn-stats dd{font-size:1.375rem}
314 .fd-hwn-desk{height:auto;display:flex;flex-direction:column;align-items:center;padding:40px 12px 96px;overflow:hidden}
315 .fd-hwn-note{position:relative;left:auto!important;top:auto!important;max-width:88%;margin:0 0 -6px;touch-action:pan-y}
316 .fd-hwn-note:nth-of-type(odd){align-self:flex-start}
317 .fd-hwn-note:nth-of-type(even){align-self:flex-end}
318 .fd-hwn-note[data-kind="postcard"]{max-width:100%}
319 .fd-hwn-tip{display:none}
320 .fd-hwn-reset{bottom:36px}
321}
322@media (prefers-reduced-motion:reduce){.fd-hwn *{transition-duration:1ms!important}}
323@media (prefers-contrast:more){.fd-hwn-note{box-shadow:0 0 0 1px #000}}
324`;
325
326export function Demo() {
327 return (
328 <div className="fd-hwnd">
329 <style>{demoCss}</style>
330 <header className="fd-hwnd-bar">
331 <span className="fd-hwnd-logo">Line <i>&amp;</i> Wash</span>
332 <span className="fd-hwnd-next">Next cohort starts 1 December</span>
333 <a className="fd-hwnd-cta" href="#enrol">Save a seat, €89</a>
334 </header>
335 <HandwrittenNotes />
336 </div>
337 );
338}
339
340const demoCss = `
341.fd-hwnd,.fd-hwnd *{box-sizing:border-box}
342.fd-hwnd{min-height:100vh;margin:0;padding:0;background:#15291F;font:400 14px/1.4 ${SANS}}
343.fd-hwnd-bar{display:flex;align-items:center;gap:24px;max-width:1240px;height:64px;margin:0 auto;padding:0 32px;border-bottom:1px solid rgba(239,232,214,.1)}
344.fd-hwnd-logo{font:400 1.375rem/1 ${SERIF};letter-spacing:-.02em;color:#EFE8D6}
345.fd-hwnd-logo i{font-style:italic;color:#F2C14E}
346.fd-hwnd-next{margin-left:auto;font:400 13px/1 ${SANS};color:#9DB5A6}
347.fd-hwnd-cta{display:inline-flex;align-items:center;height:36px;padding:0 16px;border-radius:9999px;background:#F2C14E;color:#15291F;font:600 13px/1 ${SANS};text-decoration:none}
348.fd-hwnd-cta:hover{background:#EFE8D6}
349.fd-hwnd-cta:focus-visible{outline:2px solid rgba(242,193,78,.75);outline-offset:2px}
350@media (max-width:760px){.fd-hwnd-bar{padding:0 16px}.fd-hwnd-next{display:none}.fd-hwnd-cta{margin-left:auto}}
351`;

More Testimonials. Same file contract.

Quote Wall Marquee, testimonials preview: Testimonial wall marquee: rows of developer posts with before and after numbers drifting in opposite…

Quote Wall Marquee

Testimonial wall marquee: rows of developer posts with before and after numbers drifting in opposite directions.

Portrait Quote Carousel, testimonials preview: Testimonial carousel: one large serif guest quote at a time over an arched room photo, with a portrait…

Portrait Quote Carousel

Testimonial carousel: one large serif guest quote at a time over an arched room photo, with a portrait filmstrip.

Case Study Cards, testimonials preview: Case study cards: customer stories with a counting headline metric, before and after bars and a portrait quote

Case Study Cards

Case study cards: customer stories with a counting headline metric, before and after bars and a portrait quote.

Questions about Handwritten Notes

Run npx shadcn@latest add https://freedesignmd.com/r/handwritten-notes.json, or open the page and copy the file into your project. It has zero dependencies besides React. What you copy is the exact file that renders the live demo.

Testimonial notes scattered on a cutting mat: drag cards with spring physics and click to bring one forward. It is a free, MIT-licensed React testimonials component from the freedesignmd library.

Yes. It is free and MIT-licensed, use, modify and ship it in personal and commercial projects without attribution.

All evergreen browsers (Pointer Events). On narrow screens notes stack and drag sideways so the page still scrolls.

Yes. It is a plain React component with its own styles, so it runs in Next.js, Vite, Remix and Lovable projects, and it needs no Tailwind setup. It has zero dependencies besides React.