Waypoint marker
NewA game pin over the spot the player marked: an orange arrow that bobs, counts the distance, grows as you close in and docks on the screen edge when the spot is out of view.
waypoint-marker/waypoint-marker.tsx← → turn · ↑ travel · 1 2 3 pin · Delete clearsDrag to turn · chips to pin
The live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.
When to use
- A spot the player chose on the map and is now travelling to, shown in the world so they never reopen the map to check the way.
- Open worlds, where the goal can sit behind the camera: the marker docks on the nearer edge and points the way to turn.
- A live distance the player watches fall, with the marker growing as they close in and clearing itself on arrival.
- A HUD over your own game view: pass
distanceandbearingfrom the game and the marker does the rest; it draws nothing behind itself.
When not to use
- Several objectives at once. One pin is the point here; use a compass strip with a tick per objective.
- Allies or enemies that move on their own. Use an ally tracker or a radar.
- Where to aim, not where to go. Use an aim reticle.
- Giving directions on a flat map or a route in an app. Use a map with a route line and a turn list.
- A one-off prompt to press a key near an object. Use an action prompt.
You can add this component with the shadcn CLI or by hand:
Into components/waypoint-marker of any React project, with no setup:
npx shadcn@latest add https://brunodelorence.com/r/waypoint-marker.json- Copy the component files
Copy these files into one folder of your project, keeping their relative imports together, or download them as a ZIP:
- Import it
As shown under Usage, below.
Either way it needs React 19 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: it follows your page’s color-scheme, light unless the page sets one, or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.
Usage
import { WaypointMarker } from './waypoint-marker';export function QuestPin({ meters, bearing }: { meters: number; bearing: number }) { return ( <WaypointMarker distance={meters} bearing={bearing} arriveDistance={8} autoplay={false} /> );}Each state below is a live instance, locked so you can study it.
The pieces it is built from, each with its measurements read live.
The marker a game floats over a pinned spot, so the player never has to reopen the map. It drops in with its two bars stamping after it, bobs over the spot with the distance above it, and grows as the player closes in. Turn away and it glides to the nearest edge and points out of the screen, glowing; arrive and it pops and clears itself.
- Only the marker is drawn, on whatever is behind it: the view is the player’s screen, marked by four faint corner ticks, and the demo world (three spots on open ground, the player walking among them) lives only in numbers. Each spot is projected with a pinhole camera (70° across by default), so the marker slides across the view as the player turns, as a game’s would.
- One requestAnimationFrame loop owns the motion, and only while the view is on screen and its tab visible: it turns and moves the player, projects the pin, and writes transforms and the readout’s text. React hears only of a pin placed, an arrival or a clear; the loop sleeps when nothing moves.
- The marker is one SVG in its own units: two bars 16 wide (6.6 and 4.8 tall) over a down-arrow with a 16-wide shaft and a 44.8-wide head, 75.2 from the top bar to the tip, drawn at 1.5 px per unit, filled #fe6500 with a 1.7 white outline drawn behind the fill (paint-order: stroke), round joins, and a thin dark rim outside the white so it holds on a light screen as well as a dark one. Nothing on it is blurred; the readout sits on its own dark pill.
- It scales about its tip with the distance, 0.55 times at 400 m and further up to 1.1 times within 30 m, so far reads as a hint and near as a call. It bobs 4 px over 1.2 s, and the readout above it counts whole metres as the player moves.
- When the tip would leave the view, or the spot is behind, an edge state eases in (about 300 ms): the marker glides to the nearer side, turns ±90° about its tip to point out of the screen, nudges toward the edge instead of bobbing and pulses an orange glow, a blurred copy kept behind the sharp shapes; the readout moves beside its tail. It eases back when the spot comes into view.
- Placing drops the arrow 34 px in with an overshoot and stamps the bars after it, 80 and 150 ms later, while a ring pings under its tip; arriving squashes the marker, pops it to 1.35 times and fades it in 360 ms, and the pin clears itself.
| Prop | Type | Default | Description |
|---|---|---|---|
defaultPin | 'tower' | 'arch' | 'tree' | null | null | The spot pinned at first in the demo world (ahead, left, behind); null for none. |
onPinChange | (pin: 'tower' | 'arch' | 'tree' | null) => void | — | Called when a pin is placed on a spot, cleared, or cleared by an arrival (null). |
onArrive | (pin: 'tower' | 'arch' | 'tree') => void | — | Called when the player reaches the pinned spot; the marker then pops and clears. |
distance | number | — | Drives the marker from your game: the distance to the spot, which sets the readout and the size. Turns the demo world, its controls and the autoplay off. |
bearing | number | 0 | With distance: the spot’s direction from the view’s middle, in degrees (negative is left). Past the edge of the view, the marker docks on that side. |
unit | string | 'm' | The readout’s unit. |
fov | number | 70 | The camera’s horizontal field of view, in degrees. |
nearDistance | number | 30 | Within this distance the marker is at its largest, 1.1 times. |
farDistance | number | 400 | From this distance on the marker is at its smallest, 0.55 times. |
arriveDistance | number | 12 | Within this distance the player has arrived: the marker pops and fades (with distance set, it drops back in when the distance grows again). |
controls | boolean | true | Shows the pin and travel chips of the demo world, under the view. |
autoplay | boolean | true | While on screen and untouched, a short demo pins each spot in turn, looks away (the edge state), turns back and travels there. Any real input hands it over; it is off under reduced motion. |
placeOffscreen | boolean | false | Places the marker once while off screen too, settled and still (it otherwise waits to be seen), so a still copy such as a docs preview opens with it in place. |
colorScheme | 'light' | 'dark' | 'system' | — | Light or dark corner ticks and chips, or the reader’s; without it, the page’s color-scheme. The marker keeps its orange, white and dark rim on both. |
className | string | — | Extra classes for the root. |
The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useCallback, useEffect, useId, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent,} from 'react';import { useAutoplay } from './use-autoplay';import styles from './waypoint-marker.module.css';/* * Waypoint marker: the pin a game floats over the spot the player marked, so they never have to * reopen the map to know where they were going. * * - The marker is an orange down-arrow with two stacked bars above it, outlined in white and then * in a thin dark line, so it reads on a bright view and a dark one alike, as a HUD's glyphs do. * It hangs over the pinned spot's direction, bobbing slowly, with the distance above it on a * dark plate, counting live as the player moves. It grows as they close in (0.55 times at 400 m/* Waypoint marker (waypoint-marker.tsx): the marker alone, on whatever is behind it. The view is the player's screen, marked only by four faint corner ticks; the loop places the marker by transforms from the view's top-left, at its tip, which it turns (at the edges) and scales (with proximity) about. The chips and the hint sit below the view, as plain lab controls. Colours are the tokens below, light-dark(light, dark) where the two differ: the component takes the color-scheme it inherits from the page, or the one its colorScheme prop sets (data-color-scheme). The marker keeps the kit's orange and white on both, with a thin dark rim outside the white and the readout on its own dark plate, so it reads on a light stage too. The marker, in the kit's units (1.5 px each at scale 1): bars 16 wide, 6.6 and 4.8 tall, 7.6 apart; the shaft 16 wide, 15 tall; the head 44.8 wide, 33.2 tall; a 1.7 white outline outside the orange, round-joined, and a 1.3 dark rim outside that. */.root { /* The view's corner ticks. */ --frame: rgb(0 0 0 / 0.18); /* The marker: the kit's orange and white, the dark rim, the glow at the edge. */ --orange: #fe6500; --outline: #ffffff; --rim: rgb(12 12 14 / 0.55); --glow: #ff7a1a; /* The readout's plate. */ --plate: rgb(14 15 18 / 0.82);'use client';import { useCallback, useEffect, useRef, useState } from 'react';/* * When a demo may play on its own: while part of it is on screen and the tab is visible, never * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it * takes back over `idle` ms after their last input. `visible` and `reduced` are exposed too, for * motion that is not the demo (a loop that should sleep offscreen, a bob that should stop). */export function useAutoplay<T extends HTMLElement>(idle = 6000) { const ref = useRef<T>(null); const [visible, setVisible] = useState(false); const [reduced, setReduced] = useState(true); const [taken, setTaken] = useState(false); const timer = useRef(0); useEffect(() => { const element = ref.current; if (!element) return; let onScreen = false; const update = () => setVisible(onScreen && !document.hidden); const observer = new IntersectionObserver( ([entry]) => {- The view is a focusable group with a game-view role description and a hint: the arrow keys (or W A S D) turn and travel, 1 2 3 pin a spot, Delete clears. It shows an orange focus ring.
- The pin buttons are toggle buttons (aria-pressed) with their key shortcuts; the turn and travel buttons are named, and work held down by pointer or by Space and Enter.
- A polite live region says what a player would see: a pin placed and how far it is, the pin going off screen to one side or back in view, an arrival, a clear. The live distance itself is not announced on every metre.
- The marker, the readout, the ping and the corner ticks are decorative (aria-hidden): what they show is in the announcements.
- Under reduced motion there is no demo, bob, nudge, glow pulse or overshoot, and the edge state switches without gliding; placing and arriving are short fades.