World map
NewA game’s map screen: a navy grid over contour lines, the player’s arrow and view cone, a pulsing destination, stamps, beaming pins, names by zoom, all pannable and zoomable.
Map
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 game’s full-screen map, opened from the pause menu, where the player checks where they stand and where they are heading.
- Planning a route: setting a destination from a marked place, dropping a few coloured pins, zooming out to see the regions.
- A world with more names than fit at once: regions zoomed out, places at the middle zoom, small spots only up close.
- A screen read with a controller as well as a mouse: a reticle that snaps to markers and names them, with A, B and Y hints at the foot.
- A diegetic map drawn on a device in the world, with its own bezel, clock and readouts around the view.
When not to use
- Real places, addresses or directions. Use a web map library with real tiles, search and accessible routing.
- Showing the surroundings while playing. Use a minimap or radar in a HUD corner; this is a full screen that stops the game.
- A handful of locations to pick from. Use a list or a fast-travel menu; panning to find a name is slower than reading one.
- Data plotted on a geography, such as a choropleth or a store finder. Use a chart or a plain map that leads with the data.
- Unlimited markers or notes. Pins here are one per colour, five at most; use a marker list when players keep many.
You can add this component with the shadcn CLI or by hand:
Into components/world-map of any React project, with no setup:
npx shadcn@latest add https://brunodelorence.com/r/world-map.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 { WorldMap, type MapPin, type MapStamp } from './world-map';const STAMPS: MapStamp[] = [ { id: 'well', name: 'Old Well', x: 980, y: 690, kind: 'shrine' }, { id: 'camp', name: 'River Camp', x: 1190, y: 940, kind: 'camp' },];export function MapScreen({ onClose }: { onClose: () => void }) { return ( <WorldMap stamps={STAMPS} player={{ x: 1090, y: 705, heading: 40 }} defaultDestination={{ x: 1240, y: 600, label: 'Meet the ferryman' }} time="06:42 PM" temperature="+12 °C" autoplay={false} onPinsChange={(pins: MapPin[]) => console.log(pins)} onBack={onClose} /> );}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 screen a player opens to see where they are, where they are going and what they have marked, on a tablet with a bronze bezel and a cyan glow. A navy surface carries a fine grid, an invented island traced as contour lines, a river and dashed borders; the player is a gold arrow with a view cone, the destination a gold disc whose dashed ring turns and pulses while it is set, stamps are cyan diamond icons and pins come in five colours, each standing a beam of light up off the map. The cursor reticle snaps open around whatever it rests on and names it. Drag or use the arrow keys to pan, the wheel, a pinch or +/- to zoom, and click or press Enter to place a pin.
- The camera is three registered custom properties (@property, typed <number>): the centre’s x and y in world units and the zoom. The terrain’s SVG group, the HTML layer of markers and each marker’s counter-scale (1 / zoom, so markers keep their size) are all written from them, so one CSS transition moves the whole map as one: 150 ms for the wheel and the arrow keys, 560 ms for a step of the zoom. A drag writes them with no transition, so the ground stays under the finger.
- The island is a height field: a long oval of land falling into the sea at its rim, gaussian hills, a lake and a bay carved out, and three octaves of seeded value noise. Marching squares trace it every 0.07 of height on a 16-unit lattice, the segments are joined into runs and smoothed once (Chaikin); level 0 is the coast, every other level a main line. Under it, a ground texture of dense rings is traced the same way from a noise field that wraps at 440 units, so its pattern tile repeats without a seam. Both are traced once, after mount.
- The grid is cut to the part of the world in view: lines every 27.5 and 55 units, small crosses in each cell, larger ones where major lines meet and the largest every fourth major line. Every stroke on the map is non-scaling, so lines stay one px wide at any zoom; below 0.75 the fine lines drop out.
- The reticle is four clip-path triangles around a point. Within 26 px of a marker it snaps to it: the triangles spring from 24 px apart to 70 (260 ms, an overshoot curve) and the marker’s name and what A does there slide in on the side with room, wrapping if neither side has enough. On empty ground A places a pin of the chosen colour; with all five placed the reticle shakes.
- A placed pin drops in from above at 1.5× with a flash and its beam grows up from it (scaleY, 700 ms); the palette greys that colour and moves to the next free one. The destination’s ring is a repeating conic gradient masked to a thin circle: it turns once every 14 s, and an echo ring swells and fades every 1.8 s while the destination is set.
- Names show by zoom tier through data-zoom on the map: regions below 0.75, places from 0.75, small spots from 1.4; each fades and settles in. The player’s arrow and cone turn by a rotate transition with a small overshoot, and the cone breathes.
- Every size is a multiple of --u, 1 px on a 1014 px screen and down to 0.7 px on a narrow stage, where the screen keeps its 572-unit height, the corners shrink, the tab names and the Move hint go and the map simply shows less. The bezel is bronze relief: bars with ring bosses and cyan gems over a dark stone backing and a blurred blue glow, and a running relief of worn bars and studs along both bands.
- A demo plans a short trip while the map is on screen and untouched: it reads a shrine, drops pins, zooms in and out, sets a camp as the destination, turns the sensor on and clears a pin; any input hands it to the reader. Loops pause off screen and in a hidden tab, and reduced motion drops every glide and pulse.
| Prop | Type | Default | Description |
|---|---|---|---|
places | MapPlace[] | sixteen sample names | The names on the map: { id, name, x, y, tier }. Tier 1 (regions) shows zoomed out, tier 2 (places) from the middle zoom, tier 3 (small spots) zoomed in. x and y are world units on a 2400 × 1500 world. |
stamps | MapStamp[] | five sample stamps | Marked locations: { id, name, x, y, kind }, kind being tower, shrine, camp or village. A on a stamp makes it the destination. |
player | MapPlayer | { x: 1091, y: 705, heading: 52 } | Where the player stands, and which way they face in degrees clockwise from north. |
defaultDestination | MapDestination | null | { x: 1240, y: 600, label: 'Follow the beacon' } | The destination at first, with the name shown beside the reticle, or null for none. |
defaultPins | MapPin[] | one blue pin | The pins at first: { color, x, y }, at most one per colour (red, blue, amber, green, pink). |
defaultTracked | boolean | true | Whether the destination opens set, its ring turning and pulsing (true), or shown dimmed and not followed (false). Passing it turns the demo off. |
defaultSensor | boolean | false | Whether the sensor opens on. Passing it turns the demo off. |
defaultZoom | number | 1 | The zoom it opens at, from 0.5 to 2.4. |
defaultCenter | MapPoint | — | The world point it opens centred on; by default just past the destination, or the player when there is none. |
time | string | '09:10 AM' | The clock in the lower left corner. |
temperature | string | '+28 °F' | The thermometer under the clock. |
title | string | 'Map' | The screen’s title, in the top band. |
tabs | [string, string] | ['Tools', 'Album'] | The neighbouring screens either side of the title, on the L and R shoulder keys. |
onPinsChange | (pins: MapPin[]) => void | — | Called with all pins whenever one is placed or removed. |
onDestinationChange | (destination: MapDestination | null) => void | — | Called when the destination is set, moved to a stamp or cleared (null). |
onTarget | (target: MapTarget | null) => void | — | Called when the reticle snaps to a marker (destination, stamp, pin or player), or leaves it (null). |
onSensorChange | (on: boolean) => void | — | Called when the sensor is turned on or off (Y or the Sensor hint). |
onZoomChange | (zoom: number) => void | — | Called when the zoom changes. |
onBack | () => void | — | Called on B, Escape or the Back hint. |
autoplay | boolean | true | While on screen and untouched, a short planning session: a shrine read, pins dropped, a zoom in and out, a destination set, the sensor turned on. |
colorScheme | 'light' | 'dark' | 'system' | — | Forces light or dark for the screen’s edge, shadow and glow on the page; by default it follows the page. The screen itself stays dark. |
className | string | — | Extra class names on 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, useEffectEvent, useId, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent, type PointerEvent,} from 'react';import { Corner, FaceButton, KeyCap, PinGlyph, PlayerArrow, Relief, Script,// Copyright (c) 2026 Bruno Delorence. MIT License.import type { PinColor, StampKind } from './terrain';/* * The map's art, small SVGs drawn in px of the full-size screen (the CSS sizes each one, so they * scale with it). * * - Controls: a face button (a disc with its letter), a stick (a ringed disc, with up and down * arrows for zoom) and a shoulder key cap. * - The player: a gold arrowhead with a notched base, and its view cone, a soft wedge of light. * - Stamps: a pale icon held in a cyan diamond bracket (a tower, a shrine arch, a tent, a house). * - Pins: a rounded square in the pin's colour with a dark mark of its own (a target, a chevron, * a wave, a cross, a triangle), so the colours read apart for colour-blind players too. * - Script: a strip of an invented, decorative script, the small print a game puts under a word. * - The bezel: bronze relief bars, ring bosses with a cyan gem, and the running relief of the * top and bottom bands, all drawn as thick round strokes over a shadow and under a highlight. *//** A face button: a disc with its letter, upright and bold. */export function FaceButton({ letter }: { letter: string }) { return ( <svg viewBox="0 0 20 20" aria-hidden="true"> <circle cx="10" cy="10" r="10" fill="currentColor" />// Copyright (c) 2026 Bruno Delorence. MIT License./* * The world under the map: an invented island, its contour lines, a river, its region borders and * the names on it. Everything is in world units, 1 unit = 1 px of the map at zoom 1, on a * 2400 × 1500 world. * * - The ground is a height field: a long oval of land that falls into the sea at its rim, a few * hills and a mountain mass added as gaussians, a lake and a bay carved out the same way, and * two octaves of seeded value noise so no line is a perfect curve. * - Contours are traced from that field by marching squares on a 20-unit lattice, joined into * runs, smoothed once (Chaikin) and written as SVG path data with one decimal. Level 0 is the * coast; the others are the faint height lines. The work is done once, on first use. */export const WORLD = { width: 2400, height: 1500 };/** A name on the map. Tier 1 shows zoomed out, tier 2 from the middle zoom, tier 3 zoomed in. */export type MapPlace = { id: string; name: string; x: number; y: number; tier: 1 | 2 | 3 };export type StampKind = 'tower' | 'shrine' | 'camp' | 'village';/** A marked location: a cyan diamond icon that the cursor can select. */export type MapStamp = { id: string; name: string; x: number; y: number; kind: StampKind };// Copyright (c) 2026 Bruno Delorence. MIT License.'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` alone says whether it is on screen * in a visible tab, for clocks that should stop when nobody can see them. */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);/* World map (world-map.tsx): a game's map screen on a tablet-like device, centred on a plain stage. Every size is a multiple of --u, 1 px on a full 1014 × 572 screen; on a narrower stage --u shrinks down to 0.7 px, then the screen keeps its height and shows less of the map. The device is dark on any page, as a game draws it: navy map, black bands, bronze bezel, cyan type. Only what touches the page follows the colour scheme, through light-dark(): the screen's edge, its shadow and the reach of its glow. The camera is three registered custom properties. The terrain's SVG group, the marker layer and each marker's counter-scale read them, so one transition (data-glide) moves them all together, and a drag (no transition) keeps the map under the finger. Strokes on the map are non-scaling, so lines stay one device-independent px wide at any zoom. */@property --world-map-cx { syntax: '<number>'; inherits: true; initial-value: 0;}@property --world-map-cy { syntax: '<number>'; inherits: true; initial-value: 0;}@property --world-map-z {- The map is one focusable application region with a roledescription of map and a label that gives its keys: the arrow keys pan (Shift pans further), + and − zoom, A, Enter or Space selects what the reticle rests on or places a pin, Delete removes a pin, 1 to 5 choose the pin colour, Y toggles the sensor, B or Escape goes back.
- From the keyboard the reticle sits in the middle of the map and the map moves under it, as a game cursor does; whatever it snaps to is announced in a polite live region with what A does there, and so is every pin placed or removed, destination set or cleared, zoom step and sensor change.
- The pin palette is a radio group (arrow keys move within it, the placed colours say so), and each pin colour also has its own dark mark (a target, a chevron, a wave, a cross, a triangle), so the colours read apart without colour vision.
- The control hints are real buttons with their shortcuts in aria-keyshortcuts; Sensor is a toggle button with aria-pressed. The tabs, bezel, terrain, markers and invented script are decorative and hidden from assistive tech.
- Focus rings are visible on the map, the palette and every hint. Reduced motion drops the glides, the reticle’s spring, the ring and echo pulses, the cone’s breathing and the pin drop, and the demo does not play; loops pause off screen.