Skip to main content

ns-ui

Strip Station

Turn-by-turn directions unbent into one vertical straight-line diagram: row spacing is proportional to real ground distance, not step count, with a 44px floor that visibly breaks (equation-station zigzag, skipped chainage) rather than silently lying about distance.

Use when a turn-by-turn instruction list where a 200m downtown hop and a 40km motorway leg should NOT read as the same row height — vertical space is literal ground distance, with a 44px floor that breaks visibly (zigzag + skipped chainage number) instead of quietly clamping, plus a gutter compass rose paying back the bearing the straightened road gave up. Pick stepper-needle instead for a single bounded numeric value with an adjustment history, or timeline-reasoning-rail / timeline-changelog-wave when steps should space by sequence or time rather than by distance.

Install

npx shadcn add https://design.helpmarq.com/r/strip-station.json

Ask AI

Point an assistant at this component's docs (llms-full.txt) with one click.

Claude, ChatGPT, Grok, and Perplexity open with the prompt already in. Gemini copies it to your clipboard first — paste it in once the chat opens.

Source
registry/core/strip-station/component.tsx
"use client";

import { useEffect, useMemo, useRef, useState } from "react";
import type { KeyboardEvent } from "react";

// ---------------------------------------------------------------------------
// StripStation — a turn-by-turn directions panel drawn as a straight-line
// diagram (a DOT "SLD"): the route is unbent into one vertical strip where
// vertical space between maneuvers is proportional to real ground distance,
// not to row count or elapsed time. A 200m hop and a 40km motorway leg get
// visibly different heights instead of identical row heights.
//
// Geometry contract: row pitch = max(44px, distanceM * scalePxPerM). When
// the true proportional pitch would fall under the 44px floor, the strip
// does NOT silently clamp — it flags the row with the standard equation-
// station break glyph (a zigzag) and the chainage ticks skip a number,
// so the compression is visible rather than a quiet lie about distance.
// ---------------------------------------------------------------------------

export type StripStationManeuver = {
  /** stable id */
  id: string;
  /** e.g. "Turn left onto Mill Rd" */
  instruction: string;
  /** optional secondary road label appended after the instruction */
  road?: string;
  /** length, in meters, of the leg that STARTS at this maneuver (0 for arrival) */
  distanceM: number;
  /** signed exit angle in degrees: 0 = straight ahead, negative = left, positive = right, ±180 = U-turn */
  turnAngleDeg: number;
  /** absolute compass bearing (0-360) of the leg that follows this maneuver */
  headingDeg: number;
};

export interface StripStationProps {
  /** the route, depart-to-arrive, oldest first */
  steps: StripStationManeuver[];
  /** route progress in meters from the start; drives the marker + auto-follow scroll */
  positionM?: number;
  /** pixels per meter of ground distance before the 44px floor applies */
  scalePxPerM?: number;
  /** accessible label for the directions region */
  label?: string;
  /** extra classes merged onto the rendered root element */
  className?: string;
}

const ROW_H = 44; // also the falsifiable minimum pitch, in px
const GLYPH = 28;

function clamp(v: number, min: number, max: number) {
  return Math.min(max, Math.max(min, v));
}

function formatDistance(m: number) {
  if (m >= 1000) return `${(m / 1000).toFixed(m >= 10000 ? 0 : 1)} km`;
  return `${Math.round(Math.max(0, m))} m`;
}

// DOT-style chainage: whole hundreds "+" remainder, e.g. 150m -> "1+50"
function formatChainage(m: number) {
  const safe = Math.max(0, m);
  let whole = Math.floor(safe / 100);
  let rem = Math.round(safe % 100);
  if (rem === 100) {
    rem = 0;
    whole += 1;
  }
  return `${whole}+${String(rem).padStart(2, "0")}`;
}

// smallest step (in meters) on a 1-2-5 ladder whose pixel pitch lands in a
// legible band, so the tick density adapts to the chosen scale instead of
// being hardcoded.
function niceStepMeters(scale: number) {
  if (scale <= 0) return 100;
  const [lo, hi] = [24, 56];
  let mag = 1;
  for (let guard = 0; guard < 60; guard++) {
    for (const base of [1, 2, 5]) {
      const step = base * mag;
      const px = step * scale;
      if (px >= lo && px <= hi) return step;
    }
    const midPx = 2 * mag * scale;
    if (midPx < lo) mag *= 10;
    else mag /= 10;
  }
  return 100;
}

function ticksInRange(fromM: number, toM: number, step: number) {
  if (step <= 0) return [] as number[];
  const start = Math.ceil((fromM + 1e-6) / step) * step;
  const out: number[] = [];
  for (let m = start; m < toM - 1e-6; m += step) out.push(m);
  return out;
}

type Rows = {
  cumD: number[]; // meters at each maneuver, index-aligned to steps
  y: number[]; // px at top of each row, index-aligned to steps
  broke: boolean[]; // true when the segment INTO row i was floored
  totalY: number;
  totalD: number;
};

function buildRows(steps: StripStationManeuver[], scale: number): Rows {
  const n = steps.length;
  const cumD = new Array<number>(n).fill(0);
  const y = new Array<number>(n).fill(0);
  const broke = new Array<boolean>(n).fill(false);
  for (let i = 1; i < n; i++) {
    const legM = Math.max(0, steps[i - 1]?.distanceM ?? 0);
    cumD[i] = (cumD[i - 1] ?? 0) + legM;
    const natural = legM * scale;
    broke[i] = natural < ROW_H - 0.01;
    const pitch = Math.max(ROW_H, natural);
    y[i] = (y[i - 1] ?? 0) + pitch;
  }
  return {
    cumD,
    y,
    broke,
    totalY: y[n - 1] ?? 0,
    totalD: cumD[n - 1] ?? 0,
  };
}

function yForPosition(pm: number, rows: Rows) {
  const { cumD, y } = rows;
  const n = cumD.length;
  if (n === 0) return 0;
  if (pm <= (cumD[0] ?? 0)) return y[0] ?? 0;
  for (let i = 0; i < n - 1; i++) {
    const next = cumD[i + 1] ?? 0;
    if (pm < next) {
      const start = cumD[i] ?? 0;
      const span = next - start;
      const t = span > 0 ? (pm - start) / span : 0;
      return (y[i] ?? 0) + t * ((y[i + 1] ?? 0) - (y[i] ?? 0));
    }
  }
  return y[n - 1] ?? 0;
}

// index of the leg the driver is currently ON (0..n-2), clamped
function currentLegIndex(pm: number, cumD: number[]) {
  const n = cumD.length;
  if (n < 2) return 0;
  for (let i = 0; i < n - 1; i++) {
    if (pm < (cumD[i + 1] ?? 0)) return i;
  }
  return n - 2;
}

function TurnGlyph({
  angleDeg,
  kind,
}: {
  angleDeg: number;
  kind: "start" | "mid" | "end";
}) {
  const c = GLYPH / 2;
  const arm = c - 5;

  if (kind === "start") {
    return (
      <svg width={GLYPH} height={GLYPH} viewBox={`0 0 ${GLYPH} ${GLYPH}`}>
        <circle cx={c} cy={c} r={3} fill="currentColor" />
        <line
          x1={c}
          y1={c + 3}
          x2={c}
          y2={c + arm}
          stroke="currentColor"
          strokeWidth={1.75}
          strokeLinecap="round"
        />
      </svg>
    );
  }
  if (kind === "end") {
    return (
      <svg width={GLYPH} height={GLYPH} viewBox={`0 0 ${GLYPH} ${GLYPH}`}>
        <line
          x1={c}
          y1={c - arm}
          x2={c}
          y2={c}
          stroke="currentColor"
          strokeWidth={1.75}
          strokeLinecap="round"
        />
        <rect
          x={c - 3}
          y={c - 3}
          width={6}
          height={6}
          fill="currentColor"
          transform={`rotate(45 ${c} ${c})`}
        />
      </svg>
    );
  }

  // mid: one polyline, entry -> vertex -> exit-at-true-angle -> arrowhead.
  // exit angle is the ACTUAL turnAngleDeg, not a snapped category — a 15°
  // fork and an 80° hard left are literally different drawings.
  const rad = (angleDeg * Math.PI) / 180;
  const ex = c + Math.sin(rad) * arm;
  const ey = c + Math.cos(rad) * arm;
  const headLen = 4.2;
  const a1 = rad + Math.PI - 0.55;
  const a2 = rad + Math.PI + 0.55;
  const h1x = ex + Math.sin(a1) * headLen;
  const h1y = ey + Math.cos(a1) * headLen;
  const h2x = ex + Math.sin(a2) * headLen;
  const h2y = ey + Math.cos(a2) * headLen;
  const points = [
    `${c},${c - arm}`,
    `${c},${c}`,
    `${ex.toFixed(2)},${ey.toFixed(2)}`,
    `${h1x.toFixed(2)},${h1y.toFixed(2)}`,
    `${ex.toFixed(2)},${ey.toFixed(2)}`,
    `${h2x.toFixed(2)},${h2y.toFixed(2)}`,
  ].join(" ");

  return (
    <svg width={GLYPH} height={GLYPH} viewBox={`0 0 ${GLYPH} ${GLYPH}`}>
      <polyline
        points={points}
        fill="none"
        stroke="currentColor"
        strokeWidth={1.75}
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    </svg>
  );
}

function BreakZigzag() {
  // the standard equation-station break symbol: renders in place of a
  // connector when true spacing fell below the 44px floor, so the
  // compression reads as a deliberate mark, not a silent clamp.
  return (
    <svg
      width={20}
      height={9}
      viewBox="0 0 20 9"
      className="text-ns-muted"
    >
      <polyline
        points="0,4.5 4,0.5 8,8.5 12,0.5 16,8.5 20,4.5"
        fill="none"
        stroke="currentColor"
        strokeWidth={1.25}
      />
    </svg>
  );
}

export function StripStation({
  steps,
  positionM = 0,
  scalePxPerM = 0.09,
  label = "Turn-by-turn directions",
  className = "h-[560px] w-full",
}: StripStationProps) {
  const scrollerRef = useRef<HTMLDivElement>(null);
  const markerRef = useRef<HTMLDivElement>(null);
  const roseRef = useRef<SVGGElement>(null);
  const headingChipRef = useRef<HTMLSpanElement>(null);
  const btnRefs = useRef<(HTMLButtonElement | null)[]>([]);

  const rows = useMemo(() => buildRows(steps, scalePxPerM), [steps, scalePxPerM]);
  const minorStep = useMemo(() => niceStepMeters(scalePxPerM), [scalePxPerM]);
  const majorStep = minorStep * 5;

  const initialLeg = currentLegIndex(clamp(positionM, 0, rows.totalD), rows.cumD);
  const [currentIndex, setCurrentIndex] = useState(() =>
    Math.min(initialLeg + 1, Math.max(0, steps.length - 1))
  );
  const [focusedIndex, setFocusedIndex] = useState(currentIndex);
  const [live, setLive] = useState("");

  const reducedRef = useRef(false);
  const currentIndexRef = useRef(currentIndex);
  const targetYRef = useRef(0);
  const suspendUntilRef = useRef(0);
  const listFocusedRef = useRef(false);
  const lastSetScrollRef = useRef(0);
  const wakeRef = useRef<() => void>(() => {});

  // reduced-motion tracking
  useEffect(() => {
    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
    reducedRef.current = mq.matches;
    const onChange = () => {
      reducedRef.current = mq.matches;
    };
    mq.addEventListener("change", onChange);
    return () => mq.removeEventListener("change", onChange);
  }, []);

  // the persistent scroll-follow loop: chases targetYRef with an ease-out-
  // expo catch-up, never a hard lock — it yields the moment the user scrolls
  // by hand, and it sleeps once caught up rather than spinning forever.
  useEffect(() => {
    let raf = 0;
    let running = false;
    let last = 0;
    const EPS = 0.5;

    const loop = (now: number) => {
      const dt = last === 0 ? 1 / 60 : Math.min(0.1, (now - last) / 1000);
      last = now;
      const scroller = scrollerRef.current;
      if (!scroller) {
        running = false;
        return;
      }
      const H = scroller.clientHeight;
      const maxScroll = Math.max(0, scroller.scrollHeight - H);
      const target = clamp(targetYRef.current - H / 3, 0, maxScroll);
      const cur = scroller.scrollTop;
      const suspended = now < suspendUntilRef.current || listFocusedRef.current;

      if (!suspended) {
        const next = reducedRef.current
          ? target
          : cur + (target - cur) * (1 - Math.pow(2, -10 * dt));
        if (Math.abs(next - cur) > 0.05) {
          scroller.scrollTop = next;
          lastSetScrollRef.current = next;
        }
      }

      if (!suspended && Math.abs(target - scroller.scrollTop) > EPS) {
        raf = requestAnimationFrame(loop);
      } else {
        running = false;
      }
    };

    wakeRef.current = () => {
      if (!running) {
        running = true;
        last = 0;
        raf = requestAnimationFrame(loop);
      }
    };

    return () => cancelAnimationFrame(raf);
  }, []);

  // manual scroll pauses auto-follow for a beat rather than fighting it
  useEffect(() => {
    const el = scrollerRef.current;
    if (!el) return;
    const onScroll = () => {
      if (Math.abs(el.scrollTop - lastSetScrollRef.current) > 3) {
        suspendUntilRef.current = performance.now() + 2500;
      }
    };
    el.addEventListener("scroll", onScroll, { passive: true });
    return () => el.removeEventListener("scroll", onScroll);
  }, []);

  // p (positionM) is the single scalar driving both the marker and the
  // scroll-follow target. The marker itself is never smoothed — it always
  // reads the honest position; only the viewport eases toward it.
  useEffect(() => {
    const pm = clamp(positionM, 0, rows.totalD);
    const y = yForPosition(pm, rows);
    if (markerRef.current) markerRef.current.style.transform = `translateY(${y}px)`;
    targetYRef.current = y;

    const legIdx = currentLegIndex(pm, rows.cumD);
    const heading = steps[legIdx]?.headingDeg ?? 0;
    if (roseRef.current) roseRef.current.style.transform = `rotate(${(-heading).toFixed(1)}deg)`;
    if (headingChipRef.current) {
      headingChipRef.current.textContent = `${Math.round(((heading % 360) + 360) % 360)}°`;
    }

    const upcoming = Math.min(legIdx + 1, Math.max(0, steps.length - 1));
    if (upcoming !== currentIndexRef.current) {
      currentIndexRef.current = upcoming;
      setCurrentIndex(upcoming);
      const step = steps[upcoming];
      if (step) {
        const remain = (rows.cumD[upcoming] ?? 0) - pm;
        setLive(
          `${step.instruction}${remain > 0.5 ? `, in ${formatDistance(remain)}` : ""}, step ${
            upcoming + 1
          } of ${steps.length}`
        );
      }
    }
    wakeRef.current();
  }, [positionM, rows, steps]);

  const focusRow = (i: number) => {
    setFocusedIndex(i);
    requestAnimationFrame(() => {
      btnRefs.current[i]?.focus();
      btnRefs.current[i]?.scrollIntoView({
        block: "nearest",
        behavior: reducedRef.current ? "auto" : "smooth",
      });
    });
  };

  const onListKeyDown = (e: KeyboardEvent<HTMLOListElement>) => {
    if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(e.key)) return;
    e.preventDefault();
    const n = steps.length;
    let next = focusedIndex;
    if (e.key === "ArrowDown") next = Math.min(n - 1, focusedIndex + 1);
    else if (e.key === "ArrowUp") next = Math.max(0, focusedIndex - 1);
    else if (e.key === "Home") next = 0;
    else if (e.key === "End") next = n - 1;
    focusRow(next);
  };

  return (
    <div
      role="region"
      aria-label={label}
      className={`relative flex overflow-hidden rounded-md border border-border bg-background ${className}`}
    >
      {/* compass rose: pinned in its own non-scrolling gutter column (never
          overlaps row content) — aria-hidden, it pays back the bearing the
          strip sacrificed by keeping true heading always in view */}
      <div
        aria-hidden="true"
        className="flex w-12 shrink-0 flex-col items-center gap-1 border-r border-border py-2"
      >
        <svg width={26} height={26} viewBox="0 0 26 26" className="text-foreground">
          <circle cx={13} cy={13} r={11} fill="none" stroke="currentColor" strokeOpacity={0.35} />
          <g
            ref={roseRef}
            style={{ transformOrigin: "13px 13px" }}
            className="transition-transform duration-500 ease-out motion-reduce:transition-none motion-reduce:duration-0"
          >
            <path d="M13 3L15.2 10.5L13 8.8L10.8 10.5Z" fill="currentColor" />
            <circle cx={13} cy={13} r={1.2} fill="currentColor" />
          </g>
        </svg>
        <span
          ref={headingChipRef}
          className="font-mono text-[8px] leading-none text-ns-muted"
        >
          0&deg;
        </span>
      </div>

      <div
        ref={scrollerRef}
        className="h-full min-w-0 flex-1 overflow-y-auto overscroll-contain pr-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
      >
        <div className="relative">
          <ol
            aria-label={label}
            onKeyDown={onListKeyDown}
            onFocusCapture={() => {
              listFocusedRef.current = true;
            }}
            onBlurCapture={(e) => {
              if (!e.currentTarget.contains(e.relatedTarget as Node | null)) {
                listFocusedRef.current = false;
              }
            }}
            className="relative"
          >
            {steps.map((s, i) => {
              const passed = i <= currentLegIndex(clamp(positionM, 0, rows.totalD), rows.cumD);
              const isCurrent = i === currentIndex;
              const kind = i === 0 ? "start" : i === steps.length - 1 ? "end" : "mid";
              const tone = passed && !isCurrent ? "text-ns-muted" : "text-foreground";
              const ticks =
                i > 0 && !rows.broke[i]
                  ? ticksInRange(rows.cumD[i - 1] ?? 0, rows.cumD[i] ?? 0, minorStep).map((m) => ({
                      m,
                      offset: m - (rows.cumD[i - 1] ?? 0),
                      major: Math.abs(m % majorStep) < 1e-6,
                    }))
                  : [];

              return (
                <li key={s.id}>
                  {i > 0 && (rows.y[i] ?? 0) - (rows.y[i - 1] ?? 0) > 0.5 && (
                    <div
                      aria-hidden="true"
                      className="relative"
                      style={{ height: (rows.y[i] ?? 0) - (rows.y[i - 1] ?? 0) }}
                    >
                      {rows.broke[i] ? (
                        <div className="absolute left-1 top-0 -translate-y-1/2">
                          <BreakZigzag />
                        </div>
                      ) : (
                        ticks.map((t) => (
                          <div
                            key={t.m}
                            className="absolute left-0 flex items-center gap-1"
                            style={{ top: t.offset }}
                          >
                            <span
                              className={`h-px ${
                                t.major ? "w-3 bg-foreground/50" : "w-1.5 bg-border"
                              }`}
                            />
                            {t.major && (
                              <span className="font-mono text-[8px] leading-none text-ns-muted">
                                {formatChainage(t.m)}
                              </span>
                            )}
                          </div>
                        ))
                      )}
                    </div>
                  )}

                  <div
                    className="flex items-center gap-2"
                    style={{ height: ROW_H }}
                  >
                    <div
                      aria-hidden="true"
                      className="w-10 shrink-0 text-right font-mono text-[9px] leading-none text-ns-muted"
                    >
                      {formatChainage(rows.cumD[i] ?? 0)}
                    </div>
                    <div aria-hidden="true" className={`shrink-0 ${tone}`}>
                      <TurnGlyph angleDeg={s.turnAngleDeg} kind={kind} />
                    </div>
                    <div className="min-w-0 flex-1">
                      <button
                        ref={(el) => {
                          btnRefs.current[i] = el;
                        }}
                        type="button"
                        tabIndex={focusedIndex === i ? 0 : -1}
                        aria-current={isCurrent ? "step" : undefined}
                        aria-label={`${s.instruction}${
                          s.road ? `, ${s.road}` : ""
                        }${s.distanceM > 0 ? `, in ${formatDistance(s.distanceM)}` : ""}, step ${
                          i + 1
                        } of ${steps.length}`}
                        title={`${s.instruction}${s.road ? ` · ${s.road}` : ""}`}
                        onClick={() => focusRow(i)}
                        onFocus={() => setFocusedIndex(i)}
                        className="flex h-full w-full min-w-0 items-center justify-between gap-2 rounded-md px-2 text-left outline-none transition-colors duration-150 hover:bg-foreground/[0.04] focus-visible:ring-2 focus-visible:ring-ns-accent focus-visible:ring-offset-2 focus-visible:ring-offset-background"
                      >
                        <span className={`min-w-0 flex-1 truncate text-sm ${tone}`}>
                          {s.instruction}
                          {s.road ? (
                            <span className="text-ns-muted"> · {s.road}</span>
                          ) : null}
                        </span>
                        {s.distanceM > 0 && (
                          <span className="shrink-0 font-mono text-[11px] tabular-nums text-ns-muted">
                            {formatDistance(s.distanceM)}
                          </span>
                        )}
                      </button>
                    </div>
                  </div>
                </li>
              );
            })}
          </ol>

          {/* position marker: y is the honest, unsmoothed proportional
              position — only the viewport (above) eases toward it. Foreground,
              not accent — this is a persistent state readout, not an
              interaction affordance. */}
          <div
            ref={markerRef}
            aria-hidden="true"
            className="pointer-events-none absolute left-[38px] top-0 -translate-y-1/2"
          >
            <span className="relative flex h-[10px] w-[10px] items-center justify-center">
              <span className="absolute inline-flex h-full w-full rounded-full border border-foreground/50 motion-safe:animate-ping" />
              <svg width={10} height={10} viewBox="0 0 10 10" className="relative text-foreground">
                <path d="M0 0L10 5L0 10Z" fill="currentColor" />
              </svg>
            </span>
          </div>
        </div>
      </div>

      <div role="status" aria-live="polite" aria-atomic="true" className="sr-only">
        {live}
      </div>
    </div>
  );
}
Build spec

A directions panel rendered as a DOT-style straight-line diagram (SLD). Core data: an ordered array of maneuvers, each with instruction text, an optional road label, distanceM (length of the leg that STARTS at this maneuver), a signed turnAngleDeg (0 straight, negative left, positive right, ±180 U-turn) and an absolute headingDeg for the leg that follows it. LAYOUT: row pitch = max(44px, distanceM * scalePxPerM, default scale 0.09 px/m) — cumulative real distance drives vertical position directly, so a 34km highway leg renders as ~3000px of scrollable strip while a 90m downtown turn collapses toward the 44px floor. FALSIFIABLE FLOOR: when true proportional spacing would fall under 44px, the strip does not silently clamp — it renders the standard equation-station break glyph (a small zigzag polyline) in place of the connector and the chainage numbering visibly skips (no interstitial ladder ticks in that segment), so compression is a legible mark, never a quiet lie about distance. CHAINAGE: DOT-format station labels (e.g. 1+50 = 150m) sit at every maneuver plus a 1-2-5-ladder of intermediate ticks (auto-picked so pixel pitch lands 24-56px) inside any non-broken connector; minor ticks are unlabeled lines, major ticks (5x the minor step) carry the mono chainage label. TURN GLYPHS: each maneuver is a single SVG polyline per glyph tracing entry -> vertex -> exit at the maneuver's ACTUAL turnAngleDeg plus a small arrowhead, so a 15° fork and an 80° hard left are literally different drawings, not different icon names; depart renders a start dot, arrival a diamond waypoint marker. COMPASS ROSE: a small SVG rose lives in its own non-scrolling gutter column (a fixed-width flex column beside the scroller, border-separated, aria-hidden — never an overlay floating on top of row content) and counter-rotates its needle group to -headingDeg of the CURRENT leg (the leg containing positionM) with a 500ms ease-out CSS transition, plus a tiny mono degree readout — this is the bearing the strip gave up by straightening the road, paid back visibly rather than discarded. MOTION: one governing scalar, positionM (route progress in meters), drives both the marker and the scroll target. The marker's own y is never smoothed — it is always the honest proportional position via the same piecewise cumulative-distance mapping used for chainage; only the viewport SCROLLS toward keeping the marker in the top third, via a persistent rAF loop applying an ease-out-expo catch-up (next = cur + (target-cur) * (1 - 2^(-10*dt))), which lets scattered real GPS updates read as continuous glide. The follow loop yields the instant it detects a scrollTop delta it did not itself write (user scrolled by hand) for 2.5s, and again while any row button holds focus — it never hard-locks the viewport against the user's own scroll or keyboard review. Passed maneuvers (index <= the current leg) render in --ns-muted; the upcoming maneuver is --foreground and carries aria-current="step". A11Y: the directions are a real <ol> of <li>, each wrapping one <button> whose accessible name is the composed sentence '{instruction}, {road}, in {distance}, step {i} of {n}' — screen-reader users get the same information sighted users get from the proportional gaps, since the ribbon (ticks/connectors/break glyphs/turn glyphs) and the compass rose are all aria-hidden and purely presentational. Roving tabindex: exactly one button carries tabIndex 0 at a time (the focused/current row), ArrowUp/ArrowDown/Home/End move focus row-to-row and scrollIntoView (block: 'nearest') to match, so Tab always reaches something and keyboard review never fights the mouse. A single polite aria-live region announces '{instruction}, in {distance remaining}, step {i} of {n}' only when the current leg actually changes, never per animation frame. REDUCED MOTION: the scroll-follow loop snaps instead of easing, the compass rotation transition is removed (instant snap), and the demo freezes positionM at one representative frame instead of animating — the layout, chainage, break glyphs and turn angles are all still fully legible statically. Zero dependencies, DOM + inline SVG only, no canvas.

Props

PropTypeDefaultDescription
stepsStripStationManeuver[]the route, depart-to-arrive, oldest first
positionM?number0route progress in meters from the start; drives the marker + auto-follow scroll
scalePxPerM?number0.09pixels per meter of ground distance before the 44px floor applies
label?string"Turn-by-turn directions"accessible label for the directions region
className?string"h-[560px] w-full"extra classes merged onto the rendered root element