Interactive
PaperPlanePath
A coral paper plane on a ruled notebook page. It flies the pencil route when it comes into view, banks through the bends and rolls over on a loop. Draw any line and it flies that instead; Next route changes course.
Download
- React
- Tailwind CSS v4
- motion
components/ui/paper-plane-path.tsx
"use client";
import { useEffect, useRef, useState } from "react";
import { useInView, useReducedMotion } from "motion/react";
type PaperPlanePathProps = {
/** Routes the plane flies, as SVG path data in a 520 x 320 box. */
routes?: string[];
/** Flying speed in box units per second. */
speed?: number;
/** Called when the plane lands. */
onLand?: () => void;
/** Read out by screen readers. */
label?: string;
className?: string;
};
const W = 520;
const H = 320;
const ROUTES = [
"M92 246C176 268 262 244 304 176C338 118 286 66 240 98C196 130 234 204 318 202C392 200 440 150 462 86",
"M92 90C150 60 176 250 236 232C292 214 268 84 326 78C384 72 372 246 462 226",
"M92 256C230 300 448 260 440 160C434 74 214 44 180 136C156 208 300 232 334 164C352 122 292 112 272 146",
];
const wrap = (a: number) => Math.atan2(Math.sin(a), Math.cos(a));
const ease = (t: number) => t * t * (3 - 2 * t);
/* Midpoint curves turn a shaky hand line into one smooth stroke. */
function smooth(points: { x: number; y: number }[]) {
let d = `M${points[0].x.toFixed(1)} ${points[0].y.toFixed(1)}`;
for (let i = 1; i < points.length - 1; i++) {
const mx = (points[i].x + points[i + 1].x) / 2;
const my = (points[i].y + points[i + 1].y) / 2;
d += `Q${points[i].x.toFixed(1)} ${points[i].y.toFixed(1)} ${mx.toFixed(1)} ${my.toFixed(1)}`;
}
const end = points[points.length - 1];
return `${d}L${end.x.toFixed(1)} ${end.y.toFixed(1)}`;
}
const startOf = (d: string) => (d.match(/-?\d+(\.\d+)?/g) ?? ["0", "0"]).slice(0, 2).map(Number);
/* PaperPlanePath: a coral paper plane on a ruled notebook page. It flies the pencil route, banks through the bends and rolls over on a loop. Draw any line with your finger and it flies that instead. */
export function PaperPlanePath({ routes = ROUTES, speed = 200, onLand, label = "Paper plane flying a pencil route", className = "" }: PaperPlanePathProps) {
const reduce = useReducedMotion();
const svg = useRef<SVGSVGElement>(null);
const guide = useRef<SVGPathElement>(null);
const reveal = useRef<SVGPathElement>(null);
const plane = useRef<SVGGElement>(null);
const shadow = useRef<SVGGElement>(null);
const mark = useRef<SVGGElement>(null);
const hand = useRef<SVGPathElement>(null);
const points = useRef<{ x: number; y: number }[]>([]);
const landed = useRef(onLand);
const [flight, setFlight] = useState({ d: routes[0], next: 1, count: 0 });
const [status, setStatus] = useState("");
const [drawing, setDrawing] = useState(false);
const inView = useInView(svg, { once: true, amount: 0.3 });
const [startX, startY] = startOf(flight.d);
useEffect(() => {
landed.current = onLand;
}, [onLand]);
useEffect(() => {
const path = guide.current!;
const total = path.getTotalLength();
const end = path.getPointAtLength(total);
mark.current?.setAttribute("transform", `translate(${end.x.toFixed(1)} ${end.y.toFixed(1)})`);
const at = (s: number) => {
const p = path.getPointAtLength(s);
const q = path.getPointAtLength(Math.min(total, s + 2));
const o = path.getPointAtLength(Math.max(0, s - 2));
return { x: p.x, y: p.y, turn: Math.atan2(q.y - o.y, q.x - o.x) };
};
let heading = at(0).turn;
let roll = Math.cos(heading) >= 0 ? 1 : -1;
const place = (s: number, lift: number) => {
const p = at(s);
heading += wrap(p.turn - heading) * (s === 0 ? 1 : 0.32);
roll += ((Math.cos(heading) >= 0 ? 1 : -1) - roll) * 0.1;
const flat = Math.abs(roll) < 0.14 ? 0.14 * (roll < 0 ? -1 : 1) : roll;
const pose = `rotate(${((heading * 180) / Math.PI).toFixed(1)}) scale(${(0.92 + lift * 0.14).toFixed(3)} ${(flat * (0.92 + lift * 0.14)).toFixed(3)})`;
plane.current?.setAttribute("transform", `translate(${p.x.toFixed(1)} ${p.y.toFixed(1)}) ${pose}`);
shadow.current?.setAttribute("transform", `translate(${(p.x + 4 + lift * 12).toFixed(1)} ${(p.y + 6 + lift * 20).toFixed(1)}) ${pose}`);
reveal.current?.setAttribute("stroke-dasharray", `${s.toFixed(1)} ${(total + 10).toFixed(1)}`);
};
const land = () => {
place(total, 0);
setStatus(`Landed after ${Math.round(total)} units`);
landed.current?.();
};
place(0, 0);
if (reduce) {
if (flight.count) land();
return;
}
if (!inView) return;
const seconds = Math.max(1.2, total / speed);
const began = performance.now();
let raf = 0;
const tick = (now: number) => {
const t = Math.min(1, (now - began) / (seconds * 1000));
if (t === 1) {
land();
return;
}
place(total * (t * 0.45 + ease(t) * 0.55), Math.sin(Math.PI * t));
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [flight, inView, reduce, speed]);
const local = (e: React.PointerEvent) => {
const m = svg.current!.getScreenCTM()!.inverse();
return { x: Math.max(0, Math.min(W, m.a * e.clientX + m.c * e.clientY + m.e)), y: Math.max(0, Math.min(H, m.b * e.clientX + m.d * e.clientY + m.f)) };
};
const onDown = (e: React.PointerEvent<SVGSVGElement>) => {
e.currentTarget.setPointerCapture(e.pointerId);
points.current = [local(e)];
hand.current?.setAttribute("d", "");
setDrawing(true);
};
const onMove = (e: React.PointerEvent) => {
if (!drawing) return;
const p = local(e);
const last = points.current[points.current.length - 1];
if (Math.hypot(p.x - last.x, p.y - last.y) < 7 || points.current.length > 420) return;
points.current.push(p);
hand.current?.setAttribute("d", smooth(points.current));
};
const onUp = () => {
if (!drawing) return;
setDrawing(false);
const line = points.current;
if (line.length >= 6) setFlight((f) => ({ d: smooth(line), next: f.next, count: f.count + 1 }));
hand.current?.setAttribute("d", "");
};
const nextRoute = () => {
setStatus("");
setFlight((f) => ({ d: routes[f.next % routes.length], next: f.next + 1, count: f.count + 1 }));
};
return (
<div className={`@container relative overflow-hidden rounded-[1.4cqw] ${className}`}>
<svg
ref={svg}
viewBox={`0 0 ${W} ${H}`}
role="img"
aria-label={label}
className="block h-auto w-full cursor-crosshair touch-none select-none"
onPointerDown={onDown}
onPointerMove={onMove}
onPointerUp={onUp}
onPointerCancel={onUp}
>
<defs>
<mask id="plane-flown" maskUnits="userSpaceOnUse" x="0" y="0" width={W} height={H}>
<path ref={reveal} d={flight.d} fill="none" stroke="#fff" strokeWidth="12" strokeDasharray={`0 ${W * 6}`} />
</mask>
</defs>
{/* The notebook page. */}
<rect width={W} height={H} fill="#fbf6e6" />
{Array.from({ length: 12 }, (_, i) => (
<path key={i} d={`M0 ${40 + i * 24}H${W}`} stroke="#a7c4dc" strokeOpacity="0.75" strokeWidth="1" />
))}
<path d={`M58 0V${H}`} stroke="#e08a86" strokeWidth="1.2" />
{[62, 160, 258].map((y) => (
<circle key={y} cx="24" cy={y} r="10" fill="#0f0e0c" fillOpacity="0.82" stroke="#d9cfb4" strokeWidth="1" />
))}
<text x="500" y="30" textAnchor="end" fontSize="17" fill="#6a6256" fontFamily="'Segoe Print','Bradley Hand','Comic Sans MS',cursive">
draw a route, let go
</text>
{/* Pencil route: faint ahead, firm where the plane has been. */}
<path ref={guide} d={flight.d} fill="none" stroke="#7d7566" strokeOpacity="0.55" strokeWidth="1.6" strokeDasharray="3 8" strokeLinecap="round" />
<path d={flight.d} fill="none" stroke="#3d4a63" strokeWidth="2" strokeDasharray="7 7" strokeLinecap="round" mask="url(#plane-flown)" />
<circle cx={startX} cy={startY} r="4" fill="none" stroke="#3d4a63" strokeWidth="1.8" />
<g ref={mark} fill="none" stroke="#c23b2b" strokeWidth="2.6" strokeLinecap="round">
<path d="M-7 -7 7 7M7 -7 -7 7" />
</g>
<path ref={hand} fill="none" stroke="#3d4a63" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />
<g ref={shadow} transform={`translate(${startX + 4} ${startY + 6})`} opacity={drawing ? 0 : 0.2} fill="#2a2418">
<path d="M30 0-20-17-9 0-20 15Z" />
</g>
<g ref={plane} transform={`translate(${startX} ${startY})`} opacity={drawing ? 0 : 1} strokeLinejoin="round">
<path d="M30 0-20-17-9 0Z" fill="#f2694a" stroke="#8d2b15" strokeWidth="0.9" />
<path d="M30 0-9 0-20 15Z" fill="#d94a2b" stroke="#8d2b15" strokeWidth="0.9" />
<path d="M30 0-9 0-13 6Z" fill="#9f2f16" />
<path d="M30 0-20-9" stroke="#ffc2ad" strokeOpacity="0.8" strokeWidth="0.8" fill="none" />
</g>
</svg>
<button
type="button"
onClick={nextRoute}
className="absolute bottom-[3cqw] right-[3cqw] cursor-pointer rounded-[1cqw] border border-[#3d4a63]/50 bg-[#fffdf4] px-[2.2cqw] py-[1cqw] text-[3cqw] font-medium text-[#3d4a63] shadow-sm transition-colors hover:bg-[#3d4a63] hover:text-white"
>
Next route
</button>
<span role="status" className="sr-only">
{status}
</span>
</div>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/paper-plane-path.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/paper-plane-path-demo.tsx
import { PaperPlanePath } from "@/components/ui/paper-plane-path"; export default function PaperPlanePathExample() { return ( <div className="@container-size grid h-full place-items-center overflow-hidden bg-[radial-gradient(circle_at_50%_40%,#4a3a2a,#1a130c_80%)] p-2"> <PaperPlanePath className="w-[min(156cqh,94cqw,640px)]" /> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| routes | string[] | 3 routes | SVG path data in a 520 x 320 box. |
| speed | number | 200 | Box units per second. |
| onLand | () => void | - | Called when the plane lands. |
| label | string | "Paper plane flying a pencil route" | Read out by screen readers. |
| className | string | "w-full" | Set the width here. Everything scales with it. |