Overview

A small route planner. Stops sit on a quiet stylised map and the route runs through them as a smooth curve, with dashes drifting along it and a marker travelling the way. Drag a stop to move it (it lifts and shows its name). Click anywhere on the line, or its + handle, to add a stop there, or double-click the map to add one at the end. Hovering a leg shows its distance and time. Beside the map, the itinerary lists every stop with the leg to the next one; you can drag stops into a new order or remove them, and switch between walking, cycling and driving to update the times. Undo and redo cover every change, and the route can reverse or loop back to the start.


Installation

Install dependencies:

$ pnpm add motion lucide-react

Add the utility function for class merging:

lib/utils.ts
tsx
import { ClassValue, clsx } from "clsx";import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {  return twMerge(clsx(inputs));}

Copy the component code into your project:

components/ui/route-editor.tsx
tsx
"use client";
import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";import type { KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent, ReactNode } from "react";import {  AnimatePresence,  LayoutGroup,  Reorder,  motion,  useDragControls,  useReducedMotion,  useSpring,  useTransform,} from "motion/react";import { ArrowLeftRight, Bike, Car, Footprints, GripVertical, Plus, Redo2, Repeat, Undo2, X } from "lucide-react";import { cn } from "@/lib/utils";
export type RoutePoint = { id: string; label: string; x: number; y: number };export type RouteMode = "walk" | "bike" | "drive";
export type RouteEditorProps = {  /** Controlled stops (x and y are 0–100). Pair with `onPointsChange`. */  points?: RoutePoint[];  /** Initial stops when uncontrolled. */  defaultPoints?: RoutePoint[];  onPointsChange?: (points: RoutePoint[]) => void;  title?: string;  /** Real-world width of the map, used for distances. */  mapWidthKm?: number;  defaultMode?: RouteMode;  /** Start as a loop that returns to the first stop. */  defaultLoop?: boolean;  maxPoints?: number;  className?: string;};
type V = { x: number; y: number };type Seg = { from: V; c1: V; c2: V; to: V; d: string; mid: V; length: number };
const SPEED: Record<RouteMode, number> = { walk: 4.8, bike: 15, drive: 32 };const MODES = [  { id: "walk" as const, label: "Walk", Icon: Footprints },  { id: "bike" as const, label: "Bike", Icon: Bike },  { id: "drive" as const, label: "Drive", Icon: Car },];const EASE_OUT = [0.23, 1, 0.32, 1] as const;const LIFT = { type: "spring" as const, stiffness: 500, damping: 30 };
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));const letter = (i: number) => String.fromCharCode(65 + (i % 26));let seq = 0;const newId = () => `stop-${Date.now().toString(36)}-${seq++}`;
function formatKm(km: number) {  return km < 1 ? `${Math.round(km * 100) * 10} m` : `${km.toFixed(1)} km`;}function formatTime(hours: number) {  const m = Math.max(1, Math.round(hours * 60));  if (m < 60) return `${m} min`;  return `${Math.floor(m / 60)} h ${String(m % 60).padStart(2, "0")} min`;}
/** Catmull-Rom through the stops, as one cubic per leg. */function segments(P: V[], closed: boolean): Seg[] {  const n = P.length;  if (n < 2) return [];  const get = (i: number) => (closed ? P[((i % n) + n) % n] : P[clamp(i, 0, n - 1)]);  const count = closed && n > 2 ? n : n - 1;  const out: Seg[] = [];  for (let i = 0; i < count; i++) {    const [p0, p1, p2, p3] = [get(i - 1), get(i), get(i + 1), get(i + 2)];    const c1 = { x: p1.x + (p2.x - p0.x) / 6, y: p1.y + (p2.y - p0.y) / 6 };    const c2 = { x: p2.x - (p3.x - p1.x) / 6, y: p2.y - (p3.y - p1.y) / 6 };    const at = (t: number) => {      const u = 1 - t;      return {        x: u * u * u * p1.x + 3 * u * u * t * c1.x + 3 * u * t * t * c2.x + t * t * t * p2.x,        y: u * u * u * p1.y + 3 * u * u * t * c1.y + 3 * u * t * t * c2.y + t * t * t * p2.y,      };    };    let length = 0;    let prev = p1;    for (let s = 1; s <= 20; s++) {      const q = at(s / 20);      length += Math.hypot(q.x - prev.x, q.y - prev.y);      prev = q;    }    out.push({ from: p1, c1, c2, to: p2, d: `M ${p1.x} ${p1.y} C ${c1.x} ${c1.y} ${c2.x} ${c2.y} ${p2.x} ${p2.y}`, mid: at(0.5), length });  }  return out;}
function Num({ value, format }: { value: number; format: (v: number) => string }) {  const reduce = useReducedMotion();  const mv = useSpring(value, { stiffness: 140, damping: 24 });  useEffect(() => {    if (reduce) mv.jump(value);    else mv.set(value);  }, [value, mv, reduce]);  const text = useTransform(mv, format);  return <motion.span className="tabular-nums">{text}</motion.span>;}
export function RouteEditor({  points,  defaultPoints,  onPointsChange,  title = "New route",  mapWidthKm = 6,  defaultMode = "walk",  defaultLoop = false,  maxPoints = 10,  className,}: RouteEditorProps) {  const reduce = !!useReducedMotion();  const uid = useId();  const controlled = points !== undefined && onPointsChange !== undefined;  const [local, setLocal] = useState<RoutePoint[]>(() => points ?? defaultPoints ?? []);  const value = controlled ? points : local;  const valueRef = useRef(value);  valueRef.current = value;
  const [past, setPast] = useState<RoutePoint[][]>([]);  const [future, setFuture] = useState<RoutePoint[][]>([]);  const [mode, setMode] = useState<RouteMode>(defaultMode);  const [loop, setLoop] = useState(defaultLoop);  const [selected, setSelected] = useState<string | null>(null);  const [hoverId, setHoverId] = useState<string | null>(null);  const [hoverLeg, setHoverLeg] = useState<number | null>(null);  const [draggingId, setDraggingId] = useState<string | null>(null);  const [announce, setAnnounce] = useState("");  const mapRef = useRef<HTMLDivElement>(null);  const pinRefs = useRef(new Map<string, HTMLButtonElement>());  const [size, setSize] = useState({ w: 0, h: 0 });  const nameCounter = useRef(value.length + 1);
  useEffect(() => {    const el = mapRef.current;    if (!el) return;    const ro = new ResizeObserver(([e]) => setSize({ w: e.contentRect.width, h: e.contentRect.height }));    ro.observe(el);    return () => ro.disconnect();  }, []);
  // History: snapshot before a change, then set freely (drags set many times per snapshot).  const set = useCallback(    (next: RoutePoint[]) => {      valueRef.current = next;      if (!controlled) setLocal(next);      onPointsChange?.(next);    },    [controlled, onPointsChange],  );  const snapshot = useCallback(() => {    const cur = valueRef.current;    setPast((p) => [...p.slice(-49), cur]);    setFuture([]);  }, []);  const commit = (next: RoutePoint[], message?: string) => {    snapshot();    set(next);    if (message) setAnnounce(message);  };  const undo = () => {    const prev = past[past.length - 1];    if (!prev) return;    setPast((p) => p.slice(0, -1));    setFuture((f) => [valueRef.current, ...f]);    set(prev);    setAnnounce("Undone");  };  const redo = () => {    const next = future[0];    if (!next) return;    setFuture((f) => f.slice(1));    setPast((p) => [...p, valueRef.current]);    set(next);    setAnnounce("Redone");  };
  // Geometry in pixels; the map keeps a fixed aspect so pixels map evenly to km.  const px = useMemo(() => value.map((p) => ({ x: (p.x / 100) * size.w, y: (p.y / 100) * size.h })), [value, size]);  const segs = useMemo(() => segments(px, loop), [px, loop]);  const kmPerPx = size.w ? mapWidthKm / size.w : 0;  const legs = segs.map((s) => s.length * kmPerPx);  const total = legs.reduce((a, b) => a + b, 0);  const fullD = segs.length ? `M ${segs[0].from.x} ${segs[0].from.y} ${segs.map((s) => `C ${s.c1.x} ${s.c1.y} ${s.c2.x} ${s.c2.y} ${s.to.x} ${s.to.y}`).join(" ")}` : "";  const pathPx = segs.reduce((a, s) => a + s.length, 0);  const full = value.length >= maxPoints;  const canRemove = value.length > 2;
  const toPct = (clientX: number, clientY: number) => {    const r = mapRef.current!.getBoundingClientRect();    return { x: clamp(((clientX - r.left) / r.width) * 100, 3, 97), y: clamp(((clientY - r.top) / r.height) * 100, 6, 94) };  };
  const insert = (index: number, at: { x: number; y: number }) => {    if (full) return;    const stop = { id: newId(), label: `Stop ${nameCounter.current++}`, ...at };    const next = [...value.slice(0, index), stop, ...value.slice(index)];    commit(next, `Added ${stop.label}`);    setSelected(stop.id);  };  const remove = (id: string) => {    if (!canRemove) return;    const i = value.findIndex((p) => p.id === id);    const gone = value[i];    commit(      value.filter((p) => p.id !== id),      `Removed ${gone?.label ?? "stop"}`,    );    if (selected === id) setSelected(null);    const neighbour = value[i + 1] ?? value[i - 1];    if (neighbour) requestAnimationFrame(() => pinRefs.current.get(neighbour.id)?.focus());  };
  // Pin dragging, keeping the grab offset so the pin doesn't jump under the pointer.  const drag = useRef<{ id: string; pointerId: number; sx: number; sy: number; ox: number; oy: number; moved: boolean } | null>(null);  const onPinDown = (e: ReactPointerEvent<HTMLButtonElement>, p: RoutePoint) => {    if (e.pointerType === "mouse" && e.button !== 0) return;    e.stopPropagation();    setSelected(p.id);    const r = e.currentTarget.getBoundingClientRect();    drag.current = { id: p.id, pointerId: e.pointerId, sx: e.clientX, sy: e.clientY, ox: e.clientX - (r.left + r.width / 2), oy: e.clientY - (r.top + r.height / 2), moved: false };    try {      e.currentTarget.setPointerCapture(e.pointerId);    } catch {}  };  const onPinMove = (e: ReactPointerEvent<HTMLButtonElement>) => {    const d = drag.current;    if (!d || d.pointerId !== e.pointerId) return;    if (!d.moved) {      if (Math.hypot(e.clientX - d.sx, e.clientY - d.sy) < 3) return;      d.moved = true;      snapshot();      setDraggingId(d.id);    }    const at = toPct(e.clientX - d.ox, e.clientY - d.oy);    set(valueRef.current.map((q) => (q.id === d.id ? { ...q, ...at } : q)));  };  const onPinUp = (e: ReactPointerEvent<HTMLButtonElement>) => {    const d = drag.current;    if (!d || d.pointerId !== e.pointerId) return;    if (d.moved) setAnnounce(`Moved ${valueRef.current.find((q) => q.id === d.id)?.label ?? "stop"}`);    drag.current = null;    setDraggingId(null);  };
  const lastKey = useRef<{ id: string; t: number } | null>(null);  const onPinKey = (e: ReactKeyboardEvent<HTMLButtonElement>, p: RoutePoint) => {    const step = e.shiftKey ? 5 : 1;    const delta: Record<string, [number, number]> = {      ArrowLeft: [-step, 0],      ArrowRight: [step, 0],      ArrowUp: [0, -step],      ArrowDown: [0, step],    };    if (e.key in delta) {      e.preventDefault();      // Consecutive nudges of one stop are a single undo step.      const now = performance.now();      if (!lastKey.current || lastKey.current.id !== p.id || now - lastKey.current.t > 1000) snapshot();      lastKey.current = { id: p.id, t: now };      const [dx, dy] = delta[e.key];      set(valueRef.current.map((q) => (q.id === p.id ? { ...q, x: clamp(q.x + dx, 3, 97), y: clamp(q.y + dy, 6, 94) } : q)));    } else if (e.key === "Delete" || e.key === "Backspace") {      e.preventDefault();      remove(p.id);    } else if (e.key === "Escape") {      setSelected(null);    }  };
  const onRootKey = (e: ReactKeyboardEvent<HTMLElement>) => {    const mod = e.metaKey || e.ctrlKey;    if (!mod) return;    const k = e.key.toLowerCase();    if (k === "z" && !e.shiftKey) {      e.preventDefault();      undo();    } else if ((k === "z" && e.shiftKey) || k === "y") {      e.preventDefault();      redo();    }  };
  const activeLeg = hoverLeg;  const legLabel = (i: number) => `${formatKm(legs[i] ?? 0)} · ${formatTime((legs[i] ?? 0) / SPEED[mode])}`;
  return (    <section      aria-label={`${title} route editor`}      onKeyDown={onRootKey}      className={cn(        "grid overflow-hidden rounded-[22px] bg-(--color-paper) text-(--color-ink) lg:grid-cols-[minmax(0,1fr)_17rem]",        "shadow-[0_0_0_1px_var(--color-rule),0_1px_2px_rgba(0,0,0,0.04),0_20px_48px_-24px_rgba(0,0,0,0.28)]",        className,      )}    >      {/* Map */}      <div className="relative p-2 pb-0 lg:pr-0 lg:pb-2">        <div          ref={mapRef}          role="group"          aria-label="Route map. Double-click to add a stop."          onPointerDown={(e) => {            if (!(e.target as HTMLElement).closest("[data-route-pin],[data-route-handle],[data-route-ui]")) setSelected(null);          }}          onDoubleClick={(e) => {            if ((e.target as HTMLElement).closest("[data-route-pin],[data-route-handle],[data-route-ui]")) return;            insert(value.length, toPct(e.clientX, e.clientY));          }}          className="group/map relative aspect-[16/10] w-full touch-none overflow-hidden rounded-[16px] bg-(--color-paper-2) shadow-[inset_0_0_0_1px_var(--color-rule)] select-none"        >          <MapBackdrop />
          {/* Route */}          {size.w > 0 ? (            <svg aria-hidden viewBox={`0 0 ${size.w} ${size.h}`} className="absolute inset-0 size-full overflow-visible">              {activeLeg !== null && segs[activeLeg] ? (                <path d={segs[activeLeg].d} fill="none" stroke="var(--color-ink)" strokeOpacity="0.14" strokeWidth="16" strokeLinecap="round" />              ) : null}              <path d={fullD} fill="none" stroke="var(--color-paper)" strokeWidth="9" strokeLinecap="round" strokeLinejoin="round" />              <motion.path                d={fullD}                fill="none"                stroke="var(--color-ink)"                strokeWidth="4.5"                strokeLinecap="round"                strokeLinejoin="round"                initial={reduce ? false : { pathLength: 0 }}                animate={{ pathLength: 1 }}                transition={{ duration: 1.1, ease: [0.65, 0, 0.35, 1] }}              />              {!reduce ? (                <>                  {/* Dashes drift along the route to show its direction. */}                  <motion.path                    d={fullD}                    fill="none"                    stroke="var(--color-paper)"                    strokeOpacity="0.75"                    strokeWidth="1.6"                    strokeLinecap="round"                    strokeDasharray="1 11"                    animate={{ strokeDashoffset: [0, -24] }}                    transition={{ duration: 1.2, repeat: Infinity, ease: "linear" }}                  />                  {fullD && !draggingId ? (                    <circle r="4.5" fill="var(--color-paper)" stroke="var(--color-ink)" strokeWidth="2">                      <animateMotion dur={`${clamp(pathPx / 70, 3, 14)}s`} repeatCount="indefinite" path={fullD} rotate="auto" />                    </circle>                  ) : null}                </>              ) : null}              {/* Wide invisible hit areas per leg: hover to inspect, click to add a stop there. */}              {segs.map((s, i) => (                <path                  key={i}                  d={s.d}                  fill="none"                  stroke="transparent"                  strokeWidth="18"                  strokeLinecap="round"                  className={cn("pointer-events-[stroke]", !full && "cursor-copy")}                  onPointerEnter={() => setHoverLeg(i)}                  onPointerLeave={() => setHoverLeg((h) => (h === i ? null : h))}                  onClick={(e) => insert(i + 1, toPct(e.clientX, e.clientY))}                />              ))}            </svg>          ) : null}
          {/* Leg midpoint handles */}          {size.w > 0 && !draggingId && !full            ? segs.map((s, i) => (                <button                  key={`h-${i}`}                  type="button"                  data-route-handle                  tabIndex={-1}                  aria-hidden                  onClick={() => insert(i + 1, { x: (s.mid.x / size.w) * 100, y: (s.mid.y / size.h) * 100 })}                  onPointerEnter={() => setHoverLeg(i)}                  onPointerLeave={() => setHoverLeg((h) => (h === i ? null : h))}                  style={{ left: s.mid.x, top: s.mid.y }}                  className={cn(                    "absolute z-10 grid size-5 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full bg-(--color-paper) text-(--color-ink) shadow-[0_0_0_1.5px_var(--color-ink),0_2px_6px_-2px_rgba(0,0,0,0.3)]",                    "scale-75 opacity-0 transition-[opacity,transform] duration-200 group-hover/map:opacity-100 hover:scale-110 [@media(hover:none)]:opacity-80",                    hoverLeg === i && "scale-100 opacity-100",                  )}                >                  <Plus className="size-3" strokeWidth={2.5} />                </button>              ))            : null}
          {/* Leg readout */}          <AnimatePresence>            {activeLeg !== null && segs[activeLeg] && !draggingId ? (              <motion.div                key={`leg-${activeLeg}`}                data-route-ui                initial={{ opacity: 0, y: 4, filter: "blur(4px)" }}                animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}                exit={{ opacity: 0, y: 4, filter: "blur(4px)" }}                transition={{ duration: 0.2, ease: EASE_OUT }}                style={{ left: segs[activeLeg].mid.x, top: segs[activeLeg].mid.y - 22 }}                className="pointer-events-none absolute z-20 -translate-x-1/2 -translate-y-full rounded-full bg-(--color-ink) px-2.5 py-1 text-[11.5px] font-medium whitespace-nowrap text-(--color-paper) shadow-[0_6px_16px_-6px_rgba(0,0,0,0.4)]"              >                {letter(activeLeg)} → {letter((activeLeg + 1) % Math.max(1, value.length))} · {legLabel(activeLeg)}              </motion.div>            ) : null}          </AnimatePresence>
          {/* Stops */}          <AnimatePresence initial={false}>            {value.map((p, i) => {              const on = selected === p.id;              const dragging = draggingId === p.id;              const hovered = hoverId === p.id;              const ends = i === 0 || (i === value.length - 1 && !loop);              return (                <motion.button                  key={p.id}                  ref={(el) => {                    if (el) pinRefs.current.set(p.id, el);                    else pinRefs.current.delete(p.id);                  }}                  type="button"                  data-route-pin                  aria-label={`Stop ${letter(i)}, ${p.label}. Arrow keys move it${canRemove ? ", Delete removes it" : ""}.`}                  aria-pressed={on}                  onPointerDown={(e) => onPinDown(e, p)}                  onPointerMove={onPinMove}                  onPointerUp={onPinUp}                  onPointerCancel={onPinUp}                  onPointerEnter={() => setHoverId(p.id)}                  onPointerLeave={() => setHoverId((h) => (h === p.id ? null : h))}                  onFocus={() => setSelected(p.id)}                  onKeyDown={(e) => onPinKey(e, p)}                  initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.3 }}                  animate={{ opacity: 1, scale: 1 }}                  exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.3, filter: "blur(4px)" }}                  transition={{ type: "spring", bounce: 0.45, duration: 0.45 }}                  style={{ left: `${p.x}%`, top: `${p.y}%`, x: "-50%", y: "-50%", zIndex: dragging ? 40 : on ? 30 : 20 }}                  className={cn("absolute rounded-full outline-none focus-visible:ring-2 focus-visible:ring-(--color-focus) focus-visible:ring-offset-2 focus-visible:ring-offset-(--color-paper-2)", dragging ? "cursor-grabbing" : "cursor-grab")}                >                  {on && !reduce && !dragging ? (                    <motion.span                      aria-hidden                      className="absolute inset-0 rounded-full bg-(--color-ink)"                      animate={{ scale: [1, 2], opacity: [0.25, 0] }}                      transition={{ duration: 1.5, repeat: Infinity, ease: "easeOut" }}                    />                  ) : null}                  <motion.span                    animate={{ scale: dragging ? 1.2 : on || hovered ? 1.08 : 1, y: dragging ? -5 : 0 }}                    transition={reduce ? { duration: 0 } : LIFT}                    className={cn(                      "relative grid place-items-center rounded-full text-[11.5px] font-semibold transition-[box-shadow,background-color,color] duration-200",                      ends ? "size-8" : "size-7",                      ends || on ? "bg-(--color-ink) text-(--color-paper)" : "bg-(--color-paper) text-(--color-ink)",                      dragging                        ? "shadow-[0_0_0_2px_var(--color-paper),0_0_0_3.5px_var(--color-ink),0_14px_24px_-8px_rgba(0,0,0,0.45)]"                        : "shadow-[0_0_0_2px_var(--color-paper),0_0_0_3.5px_var(--color-ink),0_3px_8px_-3px_rgba(0,0,0,0.35)]",                    )}                  >                    {letter(i)}                  </motion.span>                  <AnimatePresence>                    {on || hovered || dragging ? (                      <motion.span                        initial={{ opacity: 0, y: 4, filter: "blur(4px)" }}                        animate={{ opacity: 1, y: dragging ? -5 : 0, filter: "blur(0px)" }}                        exit={{ opacity: 0, y: 4, filter: "blur(4px)" }}                        transition={{ duration: 0.18, ease: EASE_OUT }}                        className="pointer-events-none absolute bottom-full left-1/2 mb-2 -translate-x-1/2 rounded-full bg-(--color-paper)/90 px-2 py-0.5 text-[11.5px] font-medium whitespace-nowrap text-(--color-ink) shadow-[0_0_0_1px_var(--color-rule),0_4px_12px_-4px_rgba(0,0,0,0.25)] backdrop-blur-md"                      >                        {p.label}                      </motion.span>                    ) : null}                  </AnimatePresence>                </motion.button>              );            })}          </AnimatePresence>
          {/* Toolbar */}          <div data-route-ui className="absolute top-2.5 left-2.5 z-50 flex items-center gap-0.5 rounded-full bg-(--color-paper)/80 p-1 shadow-[0_0_0_1px_var(--color-rule),0_4px_14px_-6px_rgba(0,0,0,0.25)] backdrop-blur-md">            <ToolButton label="Undo" shortcut="⌘Z" disabled={!past.length} onClick={undo}>              <Undo2 />            </ToolButton>            <ToolButton label="Redo" shortcut="⇧⌘Z" disabled={!future.length} onClick={redo}>              <Redo2 />            </ToolButton>            <span aria-hidden className="mx-0.5 h-4 w-px bg-(--color-rule)" />            <ToolButton label="Reverse route" onClick={() => commit([...value].reverse(), "Route reversed")}>              <ArrowLeftRight />            </ToolButton>            <ToolButton              label={loop ? "Make one-way" : "Return to start"}              pressed={loop}              onClick={() => {                setLoop((l) => !l);                setAnnounce(loop ? "One-way route" : "Route returns to start");              }}            >              <Repeat />            </ToolButton>          </div>
          <div data-route-ui className="pointer-events-none absolute right-2.5 bottom-2.5 z-50 hidden rounded-full bg-(--color-paper)/80 px-2.5 py-1 text-[11px] text-(--color-neutral) shadow-[0_0_0_1px_var(--color-rule)] backdrop-blur-md sm:block">            {full ? `Up to ${maxPoints} stops` : "Double-click or click the line to add a stop"}          </div>
          {/* Scale bar */}          {size.w > 0 ? (            <div aria-hidden className="pointer-events-none absolute bottom-3 left-3 z-50 flex flex-col gap-1 text-[10.5px] font-medium text-(--color-neutral)">              {mapWidthKm >= 2 ? "1 km" : "500 m"}              <span                className="h-1.5 border-x-[1.5px] border-b-[1.5px] border-(--color-neutral)"                style={{ width: (mapWidthKm >= 2 ? 1 : 0.5) / kmPerPx }}              />            </div>          ) : null}        </div>      </div>
      {/* Itinerary */}      <div className="flex min-w-0 flex-col p-4 sm:p-5">        <div className="flex items-start justify-between gap-3">          <div className="min-w-0">            <div className="truncate text-[15px] font-semibold tracking-tight">{title}</div>            <div className="mt-0.5 flex items-baseline gap-1.5 text-[12.5px] text-(--color-neutral)">              <span className="font-(family-name:--font-display) text-[22px] font-semibold tracking-tight text-(--color-ink)">                <Num value={total} format={(v) => v.toFixed(1)} />              </span>              km              <span aria-hidden>·</span>              <AnimatePresence initial={false} mode="popLayout">                <motion.span                  key={`${mode}-${formatTime(total / SPEED[mode])}`}                  initial={{ opacity: 0, y: 6, filter: "blur(3px)" }}                  animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}                  exit={{ opacity: 0, y: -6, filter: "blur(3px)" }}                  transition={{ duration: 0.22, ease: EASE_OUT }}                  className="tabular-nums"                >                  {formatTime(total / SPEED[mode])}                </motion.span>              </AnimatePresence>            </div>          </div>        </div>
        <LayoutGroup id={`${uid}-mode`}>          <div role="radiogroup" aria-label="Travel mode" className="mt-3 grid grid-cols-3 gap-0.5 rounded-full bg-(--color-paper-2) p-1 shadow-[inset_0_0_0_1px_var(--color-rule)]">            {MODES.map(({ id, label, Icon }) => {              const on = mode === id;              return (                <button                  key={id}                  type="button"                  role="radio"                  aria-checked={on}                  onClick={() => setMode(id)}                  className={cn(                    "relative flex h-7 items-center justify-center gap-1.5 rounded-full text-[12px] font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-(--color-focus)",                    on ? "text-(--color-ink)" : "text-(--color-neutral) hover:text-(--color-ink-2)",                  )}                >                  {on ? (                    <motion.span                      layoutId="mode-pill"                      className="absolute inset-0 rounded-full bg-(--color-paper) shadow-[0_0_0_0.5px_var(--color-rule),0_1px_3px_rgba(0,0,0,0.1)]"                      transition={reduce ? { duration: 0 } : { type: "spring", bounce: 0.2, duration: 0.4 }}                    />                  ) : null}                  <Icon className="relative size-3.5" strokeWidth={2} />                  <span className="relative">{label}</span>                </button>              );            })}          </div>        </LayoutGroup>
        <div className="mt-4 mb-1.5 flex items-center justify-between text-[10.5px] font-semibold tracking-[0.12em] text-(--color-neutral) uppercase">          Stops          <span className="font-mono tracking-normal tabular-nums">            {value.length}/{maxPoints}          </span>        </div>
        <Reorder.Group as="div" axis="y" values={value} onReorder={set} className="relative -mx-1.5 flex flex-col" role="list">          <AnimatePresence initial={false}>            {value.map((p, i) => (              <StopRow                key={p.id}                point={p}                index={i}                last={i === value.length - 1}                loop={loop}                leg={i < segs.length ? legLabel(i) : null}                active={selected === p.id || hoverId === p.id}                canRemove={canRemove}                reduce={reduce}                onDragStart={snapshot}                onHover={(on) => {                  setHoverId(on ? p.id : null);                  setHoverLeg(on && i < segs.length ? i : null);                }}                onSelect={() => {                  setSelected(p.id);                  pinRefs.current.get(p.id)?.focus();                }}                onRemove={() => remove(p.id)}              />            ))}          </AnimatePresence>        </Reorder.Group>      </div>
      <div className="sr-only" aria-live="polite">        {announce}      </div>    </section>  );}
function ToolButton({  label,  shortcut,  disabled,  pressed,  onClick,  children,}: {  label: string;  shortcut?: string;  disabled?: boolean;  pressed?: boolean;  onClick: () => void;  children: ReactNode;}) {  return (    <button      type="button"      aria-label={label}      aria-pressed={pressed}      title={shortcut ? `${label} (${shortcut})` : label}      disabled={disabled}      onClick={onClick}      className={cn(        "grid size-7 place-items-center rounded-full outline-none transition-[background-color,color,opacity,transform] active:scale-90 focus-visible:ring-2 focus-visible:ring-(--color-focus) disabled:pointer-events-none disabled:opacity-30 [&_svg]:size-3.5",        pressed ? "bg-(--color-ink) text-(--color-paper)" : "text-(--color-ink-2) hover:bg-(--color-paper-2) hover:text-(--color-ink)",      )}    >      {children}    </button>  );}
function StopRow({  point,  index,  last,  loop,  leg,  active,  canRemove,  reduce,  onDragStart,  onHover,  onSelect,  onRemove,}: {  point: RoutePoint;  index: number;  last: boolean;  loop: boolean;  leg: string | null;  active: boolean;  canRemove: boolean;  reduce: boolean;  onDragStart: () => void;  onHover: (on: boolean) => void;  onSelect: () => void;  onRemove: () => void;}) {  const controls = useDragControls();  const ends = index === 0 || (last && !loop);  return (    <Reorder.Item      as="div"      value={point}      role="listitem"      dragListener={false}      dragControls={controls}      onDragStart={onDragStart}      initial={reduce ? { opacity: 0 } : { opacity: 0, height: 0, filter: "blur(4px)" }}      animate={{ opacity: 1, height: "auto", filter: "blur(0px)" }}      exit={reduce ? { opacity: 0 } : { opacity: 0, height: 0, filter: "blur(4px)" }}      transition={{ type: "spring", bounce: 0.15, duration: 0.4 }}      whileDrag={{ scale: 1.02, boxShadow: "0 12px 28px -12px rgba(0,0,0,0.35)" }}      onPointerEnter={() => onHover(true)}      onPointerLeave={() => onHover(false)}      className="relative rounded-[12px] bg-(--color-paper)"    >      <div className={cn("group flex items-center gap-1.5 rounded-[12px] px-1.5 py-1.5 transition-colors", active && "bg-(--color-paper-2)")}>        <button          type="button"          aria-label={`Reorder ${point.label}`}          onPointerDown={(e) => controls.start(e)}          className="grid h-8 w-5 shrink-0 cursor-grab touch-none place-items-center rounded-md text-(--color-neutral) opacity-50 outline-none transition-opacity group-hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-(--color-focus) active:cursor-grabbing"        >          <GripVertical className="size-3.5" />        </button>        <button type="button" onClick={onSelect} className="flex min-w-0 flex-1 items-center gap-2.5 rounded-[8px] text-left outline-none focus-visible:ring-2 focus-visible:ring-(--color-focus)">          <span            className={cn(              "grid size-6 shrink-0 place-items-center rounded-full text-[11px] font-semibold",              ends ? "bg-(--color-ink) text-(--color-paper)" : "bg-(--color-paper) text-(--color-ink) shadow-[inset_0_0_0_1.5px_var(--color-ink)]",            )}          >            {letter(index)}          </span>          <span className="min-w-0">            <span className="block truncate text-[13px] font-medium text-(--color-ink)">{point.label}</span>            <span className="block truncate text-[11.5px] text-(--color-neutral) tabular-nums">              {leg ? (last && loop ? `${leg} back to A` : `${leg} to ${letter(index + 1)}`) : index === 0 ? "Start" : "Finish"}            </span>          </span>        </button>        <button          type="button"          aria-label={`Remove ${point.label}`}          disabled={!canRemove}          onClick={onRemove}          className="grid size-7 shrink-0 place-items-center rounded-full text-(--color-neutral) opacity-0 outline-none transition-[opacity,background-color,color] group-hover:opacity-100 hover:bg-(--color-paper) hover:text-(--color-ink) focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-(--color-focus) disabled:hidden [@media(hover:none)]:opacity-100"        >          <X className="size-3.5" />        </button>      </div>    </Reorder.Item>  );}
/** A quiet, stylised city: blocks, a river, a park and two main roads. */function MapBackdrop() {  const id = useId().replace(/:/g, "");  return (    <svg aria-hidden viewBox="0 0 160 100" preserveAspectRatio="xMidYMid slice" className="pointer-events-none absolute inset-0 size-full">      <defs>        <pattern id={`${id}-grid`} width="8" height="8" patternUnits="userSpaceOnUse">          <path d="M 8 0 L 0 0 0 8" fill="none" stroke="var(--color-rule)" strokeWidth="0.15" />        </pattern>        <pattern id={`${id}-park`} width="2.4" height="2.4" patternUnits="userSpaceOnUse">          <circle cx="1.2" cy="1.2" r="0.35" fill="var(--color-neutral)" fillOpacity="0.35" />        </pattern>      </defs>      <rect width="160" height="100" fill={`url(#${id}-grid)`} />      {[        [8, 8, 22, 14],        [34, 6, 16, 20],        [96, 8, 26, 12],        [128, 18, 22, 16],        [12, 30, 18, 12],        [104, 58, 20, 14],        [130, 62, 24, 18],        [70, 76, 22, 16],        [10, 80, 26, 14],      ].map(([x, y, w, h], i) => (        <rect key={i} x={x} y={y} width={w} height={h} rx="2.2" fill="var(--color-paper)" stroke="var(--color-rule)" strokeWidth="0.3" />      ))}      <ellipse cx="68" cy="30" rx="15" ry="10" fill={`url(#${id}-park)`} stroke="var(--color-rule)" strokeWidth="0.3" />      <path d="M -5 68 C 30 58, 50 78, 82 64 S 130 36, 165 44" fill="none" stroke="var(--color-ink)" strokeOpacity="0.07" strokeWidth="7" strokeLinecap="round" />      <path d="M -5 50 C 40 48, 120 52, 165 49" fill="none" stroke="var(--color-rule)" strokeWidth="2.6" />      <path d="M -5 50 C 40 48, 120 52, 165 49" fill="none" stroke="var(--color-paper)" strokeWidth="1.8" />      <path d="M 88 -5 C 86 30, 92 70, 88 105" fill="none" stroke="var(--color-rule)" strokeWidth="2.6" />      <path d="M 88 -5 C 86 30, 92 70, 88 105" fill="none" stroke="var(--color-paper)" strokeWidth="1.8" />    </svg>  );}

Adjust import paths according to your folder structure.


Basic Usage

tsx
import { RouteEditor, type RoutePoint } from "@/components/ui/route-editor";
const [stops, setStops] = useState<RoutePoint[]>([  { id: "a", label: "Old Town", x: 12, y: 28 },  { id: "b", label: "Market Hall", x: 38, y: 40 },  { id: "c", label: "Harbour", x: 86, y: 30 },]);
<RouteEditor  title="Saturday walk"  points={stops}  onPointsChange={setStops}  mapWidthKm={5}/>

Props

PropTypeRequiredDefaultDescription
points / onPointsChangeRoutePoint[] / (points) => voidNo—Controlled stops. x and y are percentages of the map (0–100).
defaultPointsRoutePoint[]No—Initial stops when uncontrolled.
titlestringNo"New route"Name shown above the totals.
mapWidthKmnumberNo6Real width of the map, used for distances and the scale bar.
defaultMode"walk" | "bike" | "drive"No"walk"Travel mode for time estimates.
defaultLoopbooleanNofalseStart with the route returning to the first stop.
maxPointsnumberNo10Most stops allowed.
classNamestringNo—Classes for the card.

Best Practices

  1. The map is a fixed 16:10, so distances stay consistent at any size. Swap MapBackdrop for your own map art or tiles that use the same aspect.
  2. Distances are measured along the drawn curve. Treat them as estimates, not routing.
  3. Keyboard: Tab moves through stops, arrow keys nudge (Shift for larger steps), Delete removes, and ⌘Z / ⇧⌘Z undo and redo. Each change is announced to screen readers.
  4. Keep at least two stops; the remove button hides at two.