This guide explains how to use topographic. Each section covers one topic. To try the settings live, open the playground. To see how the parts of the code fit together, read design.md.
- Install
- Three ways to use it
- Sizing and layering
- Settings
- Locations
- Shapes
- Looks
- Scenes
- Colours, CSS variables and dark mode
- Seed
- Snapshots and PNG export
- Performance and accessibility
- Fallbacks
- Server rendering and Next.js
- The playground
- WebMCP tools
- Troubleshooting
npm install topographic # core and the web component
npm install topographic-react # React bindings (needs React 18 or 19)The packages are ESM only. They have no dependencies. They are marked as side-effect free, except topographic/element. That one registers the element when you import it.
With a coding agent, npx skills add yahyabedirhan/topographic installs the topographic skill.
The skill installs the packages, adds a first terrain and points the agent at this guide.
On npm: topographic and topographic-react.
import { createTopography } from 'topographic';
const topography = createTopography(element, settings?, options?);The call adds a canvas inside element. The canvas fills the element, so give the element a size. The call returns an instance with these members:
| Member | What it does |
|---|---|
update(partialSettings, { instant? }) |
Changes some settings and applies them right away. Values are kept inside their ranges. Only the changed values are sent to the GPU. With scenes set, a new scene and a new theme mode fade in; instant: true jumps to them at once. |
setOptions({ maxPixelRatio?, maxPixels?, maxFps? }) |
Changes the renderer options below while the renderer runs. The values you leave out stay. The canvas is sized again at once. |
blend |
{ weights, dark }: the weight of each scene, from 0 to 1, and the dark amount drawn now, from 0 (light) to 1 (dark). See Scenes. |
snapshot({ frame?, width?, height?, region?, motion? }) |
Draws one PNG image in the background and returns it as a Blob. See Snapshots. |
record({ seconds?, fps?, width?, height?, frame?, format?, motion? }) |
Records a short 3D video with a moving camera. Returns it as an MP4 or WebM Blob. |
setHeightfield({ samples, size, aspect } | null) |
Draws the lines from your own elevation data (a height map) instead of the generated shape. The data is a square grid. samples is a Float32Array of size × size values, from 0 (low) to 1 (high). Row 0 is the south edge. aspect is the place's width divided by its height. null brings back the shape's own shape. The grid stays until the location setting changes. The location setting is usually simpler. |
setInsets({ top, right, bottom, left } | null) |
Tells the 3D view how much space your page's UI covers on each side, in CSS pixels. A toolbar or a dock are examples. The 3D picture then moves so the terrain is centred in the free space. The camera does not move, so orbiting still turns around the terrain. 2D does not change. Snapshots and videos ignore this setting (see Snapshots). null clears it. insets reads it back. |
clampPan(panX, panZ) |
Returns the pan as [x, z], limited so the view stays over the terrain. This is the same limit the renderer uses for camera.panX and camera.panZ. |
stats |
{ fps, frameMs, mesh, quality } for the 3D view. mesh is 0 in 2D. quality is the tier actually drawing: low, medium or high. full reports high. |
pose |
{ azimuth, elevation, radius, panX, panZ }: where the 3D camera is now on its motion path. At azimuth 0, the camera looks from the south and north is at the top of the screen. The angle grows clockwise, as seen from above. So as it grows, north turns towards the right of the screen. |
destroy() |
Stops drawing. Removes the listeners and the canvas, and frees the WebGL context. |
settings |
The current settings, with every value filled in. |
status |
'webgl2', 'webgl1', 'fallback', 'lost' or 'destroyed'. |
canvas |
The canvas element. |
options is optional. You rarely need it:
| Option | Default | What it does |
|---|---|---|
maxPixelRatio |
2 |
The highest device pixel ratio to draw at. A quality setting can cap it further. |
maxPixels |
4147200 (1920×1080×2) |
The most pixels to draw. Above this, the canvas draws at a lower resolution. A quality setting can cap this further. Infinity removes the limit. |
maxFps |
0 |
The most frames to draw each second, such as 30 on a phone. 0 draws on every display frame. |
powerPreference |
'low-power' on a phone, else 'default' |
The GPU the WebGL context asks for: 'default', 'low-power' or 'high-performance'. A background that must stay light can ask for 'low-power' everywhere. |
forceWebGL1 |
false |
Skip WebGL2. Useful for testing. |
setOptions changes maxPixelRatio, maxPixels and maxFps later, for example on a resize.
<script type="module">import 'topographic/element';</script>
<topographic-view shape="long-ridge" line-count="36" peaks="2" theme="dark"></topographic-view>- Every setting has an HTML attribute (listed in Settings). There are also
look,shape,location,seed,theme,inkandpaper. - A changed attribute applies right away. A removed attribute goes back to the shape's default.
element.topographicis the instance.element.snapshot(opts)is a shortcut to itssnapshot.- The element is
display: block. Size and position it with CSS, as Sizing and layering shows.
import { Topography, useTopography } from 'topographic-react';
<Topography
shape="dunes"
lines={{ count: 24 }}
theme={{ mode: 'dark' }}
style={{ position: 'fixed', inset: 0, zIndex: -1 }}
/>- Props:
look,shape,location,seed,lines,terrain,motion,layout,theme,mode,elevation,extent,detail,quality,camera,view2d,scenes,scene,column,options,onReady, plus anydivprops. Children are drawn on top. styleis React's normal inline style. The land shape is theshapeprop.TopographyInstanceis the type of the instance.- Each group of settings updates only when its values change. A new object with the same values does nothing.
onReady(instance)gives you the instance after mount, for example to callsnapshot. It givesnullon unmount.- A change to
optionscreates the renderer again. Every other change updates in place.
To use your own element, use the hook:
const ref = useTopography({ shape: 'seed-pod' });
return <section ref={ref} style={{ height: 400 }} />;
// ref.topographic is the instance while mountedThe background fills the element you give it. The element needs a size: without a height, it draws nothing.
To put the background behind the whole page, fix it to the window and move it behind the content:
<topographic-view shape="long-ridge" style="position:fixed;inset:0;z-index:-1"></topographic-view><Topography shape="long-ridge" style={{ position: 'fixed', inset: 0, zIndex: -1 }} />To fill one section, give the element a height.
Children of <Topography> are drawn on top:
<Topography shape="granite" style={{ height: '100vh' }}>
<h1>Hello</h1>
</Topography>A className works the same way: size the element in your own CSS.
In plain JavaScript, size the element you pass to createTopography.
The settings are in groups. Pass only the values you want to change:
{
look: 'survey-map',
shape: 'dunes',
location: null,
seed: 0,
lines: { count, width, opacity, indexEvery },
terrain: { peaks, spread, roughness, sharpness, terracing, variant },
motion: { speed, warp, sparkle },
layout: { anchor, fade },
theme: { mode, ink, paper },
quality: 'auto',
column: 300,
scenes: [],
scene: 0,
}column, scenes and scene are described in Layout and Scenes.
look is a named design, described in Looks. The other settings override its values.
A value outside its range is moved to the nearest limit. Whole-number settings are rounded. A value that is not a number uses the default. Some shapes have their own defaults. For example, dunes uses a lighter centre fade.
| Setting | Attribute | Range | Default | What it does |
|---|---|---|---|---|
lines.count |
line-count |
6–60, whole | 30 | How many contour lines there are. |
lines.width |
line-width |
0–1 | 0.26 | How thick each line is. |
lines.opacity |
line-opacity |
0–1 | 0.4 | How strongly the lines show against the background. |
lines.indexEvery |
index-every |
0–10, whole | 5 | Draws every Nth line bolder, like the index lines on a map. 0 turns it off. The playground calls it Bold lines ("1 in 5"). |
| Setting | Attribute | Range | Default | What it does |
|---|---|---|---|---|
terrain.peaks |
peaks |
1–6, whole | 1 | How many summits the terrain has. |
terrain.spread |
spread |
0–1 | 0.5 | How far the extra peaks are from the main summit. |
terrain.roughness |
roughness |
0–1 | 0 | Adds noise to the slopes. Low is smooth hills. High is rocky ground. |
terrain.sharpness |
sharpness |
0–1 | 0.5 | Below 0.5, summits get flatter. Above 0.5, they get sharper. |
terrain.terracing |
terracing |
0–1 | 0 | Groups the lines into steps. |
terrain.variant |
variant |
0–1 | 0.5 | Changes the land in a way that depends on the shape. For drifting lobes, it goes from three lobes to four. |
| Setting | Attribute | Range | Default | What it does |
|---|---|---|---|---|
motion.speed |
speed |
0–3 | 1 | Animation speed. 0 stops the animation. |
motion.warp |
warp |
0–1 | 0.47 | How much the lines wobble as they move. |
motion.sparkle |
sparkle |
0–1 | 0.6 | Small bright dots that travel along the lines. |
| Setting | Attribute | Range | Default | What it does |
|---|---|---|---|---|
layout.anchor |
anchor |
0–1 | 0 | 0 keeps the summit to the side. 1 puts it in the centre. |
layout.fade |
fade |
0–1 | 0.7 | Fades the lines behind a text column in the centre. |
column |
column |
0–4000 px, whole | 300 | Half the width of that faded centre column, in CSS pixels. The column is never wider than the view. It is a top-level setting, not part of layout. |
mode is '2d' or '3d'. 2D is the library default. The playground opens a bare URL on the built-in Istanbul map terrain in 3D, with the camera slowly pushing in and back. A URL with settings opens in 2D unless it has mode=3d. 3D shows the same contour lines as a landscape. With the same seed and settings, you get the same map in both modes. You can switch between them at any time.
| Setting | Attribute | Range | Default | What it does |
|---|---|---|---|---|
mode |
mode |
2d, 3d |
2d |
The flat map, or the same map as a landscape. |
elevation |
elevation |
0–1 | 0.62 | How tall the 3D terrain is. 0 is flat, like the map. Higher values make it taller, with a rounded top. |
extent |
extent |
endless, block |
endless |
endless is a plane that fades into the background colour. block is a solid slab. Its sides fade softly into the background colour. |
detail |
detail |
0–1 | 0.5 | How many points the 3D surface has, from a 32² grid to a 160² grid. |
quality |
quality |
auto, low, medium, high, full |
auto |
Graphics budget. auto follows the device and steps down if frames miss 60 Hz, or 120 Hz once that refresh has been seen. low, medium and high stay put. The budget caps the pixel ratio, the pixel count and the mesh size. full is high without the pixel caps, so only the renderer options limit the canvas. |
camera.azimuth |
azimuth |
any finite number | 349.7 | The camera's angle around the terrain, in degrees. |
camera.elevation |
camera-elevation |
2–80 | 33.6 | The camera's angle above the horizon, in degrees. The camera never goes below the terrain. |
camera.radius |
radius |
0.4–6.5 | 1.92 | The camera's distance from the centre of the terrain. |
camera.motion |
camera-motion |
still, orbit, push, flyover, drone |
still |
Makes the camera move. motion.speed still animates the terrain shape. |
camera.speed |
camera-speed |
0–3 | 1 | How fast the camera moves along its path. 1 is the path's own pace, such as an orbit in about 26 seconds. 0 holds the camera. A change keeps the camera where it is. |
camera.panX, camera.panZ |
pan-x, pan-z |
−6–6 | 0 | The point the camera looks at, moved from the centre of the terrain. X is east and Z is south, in terrain units. The camera and every motion path turn around this point. The point stays over the terrain, so part of the terrain is always in view. |
view2d.x, view2d.y, view2d.zoom |
view-x, view-y, view-zoom |
−6–6, −6–6, 1–8 | 0, 0, 1 | The 2D map's own pan and zoom, apart from the 3D camera: the point at the centre of the view, x to the right and y up in screen heights, and how far it is zoomed in. 3D ignores it. |
createTopography(el, {
shape: 'gentle-summit',
seed: 4,
mode: '3d',
elevation: 0.7,
extent: 'block',
camera: { azimuth: 42, elevation: 34, radius: 2.45, motion: 'orbit' },
});The default camera looks from a little west of south. It is about a third of the way up. It is close enough that the terrain fills a wide window. record({ motion: 'flyover', format: 'mp4' }) records a video. It does not resize the live canvas. You can also use format: 'webm' if the browser can encode it.
A location draws a real place instead of the generated terrain.
The location setting takes the id of a built-in place, or a height grid.
It works in 2D and in 3D.
createTopography(el, { location: 'everest' });
createTopography(el, { location: 'fuji', mode: '3d', camera: { motion: 'orbit' } });<topographic-view location="matterhorn" mode="3d"></topographic-view><Topography location="grand-canyon" mode="3d" />| Id | Place |
|---|---|
istanbul |
Istanbul, the whole metro area with the Bosphorus |
cyprus |
Cyprus, the whole island with some sea around it |
matterhorn |
The Matterhorn and Zermatt |
geirangerfjord |
Geirangerfjord |
grand-canyon |
The Grand Canyon, from the South Rim to the North Rim |
yosemite |
Yosemite Valley |
torres-del-paine |
Torres del Paine |
fuji |
Mount Fuji and the Fuji Five Lakes |
everest |
Everest and the Khumbu |
kilimanjaro |
Kilimanjaro |
These are the playground's places.
Each place is a 256 × 256 height grid that comes with the package, so it works offline and gives the same picture every time.
A page downloads only the places it uses.
A place's file is 25 to 115 KB when the server compresses it.
Until the file arrives, the view shows only the paper colour.
snapshot() and record() wait for it.
A built-in place also sets the look the playground gives it.
That is the camera angle and distance, the 3D elevation, extent: 'block', the lines and a light centre fade:
{
camera: { azimuth, elevation, radius: 1.32 }, // each place has its own angle
elevation, // each place has its own height
extent: 'block',
lines: { count: 36, width: 0, opacity: 0.9, indexEvery: 4 },
layout: { fade: 0.15 },
}It does not set mode, so a place is a 2D map unless you set mode: '3d'.
The values you set win over the place's own.
A change to another place sets that place's look again, except for the values in the same update.
LOCATIONS from topographic/locations lists each place with its id, name, bbox, aspect and defaults.
loadLocation(id) returns a place's grid, with minMeters and maxMeters for its lowest and highest point.
decodeGrid16(bytes) reads a grid saved in the same format, a square 16-bit greyscale PNG with north at the top, and returns { samples, size } with heights from 0 to 1.
The playground serves its catalog places this way.
import { fetchLocation } from 'topographic/locations';
const grid = await fetchLocation({ west: 28.45, south: 40.78, east: 29.65, north: 41.36 });
topography.update({ location: grid });fetchLocation() downloads the elevation of any area and makes a 256 × 256 grid from it, the same way the playground does.
The edges are in degrees.
The result also has minMeters, maxMeters, the tile zoom and the number of tiles it downloaded.
| Option | Default | What it does |
|---|---|---|
relief |
'dramatic' |
'dramatic' stretches the area's own heights to fill the terrain, so a flat city still shows its hills. 'measured' makes 400 m the full height, so flat ground stays flat. |
smooth |
'medium' |
How much the heights are smoothed: 'light', 'medium' or 'strong'. |
tiles |
AWS Open Data | A URL for Terrarium elevation tiles, with {z}, {x} and {y}. |
signal |
none | An AbortSignal that stops the downloads. |
areaProblem(bbox) from the same entry point tells you before the download whether an area is too small, too large or too thin.
A height grid in location is used as it is, and it brings no camera.
To use your own elevation data, pass { samples, size, aspect } as described for setHeightfield() in Vanilla JavaScript.
React compares a grid by identity, so keep the same object between renders.
There are seven shapes. Each one is a different starting form for the land. The settings change each shape in many ways, so the number of possible looks is not limited to seven.
| Id | Name | Looks like |
|---|---|---|
drifting-lobes |
Drifting lobes | Three soft lobes that drift to the side and slowly change towards four. |
granite |
Granite | Many small, lumpy lobes with a stone-like texture. |
five-petals |
Five petals | A summit with five petals that slowly grows and shrinks. |
seed-pod |
Seed pod | Two long lobes that turn slowly, like a seed pod. |
gentle-summit |
Gentle summit | One soft, slightly oval hill that leans as it moves. |
long-ridge |
Long ridge | A long, winding ridge with rounded ends. |
dunes |
Dunes | Long ripples, like sand shaped by wind. There are no summits. |
An unknown shape uses drifting-lobes instead and prints one warning in the console.
terrain.variant changes the land in a way that depends on the shape. Today it has the biggest effect on drifting lobes, where it goes from three lobes to four.
The image shows every shape and three looks in dark mode. Each tile is the frame at 4 seconds. The first seven tiles are the shapes with their own defaults. The next three are looks, by name and id, in the same tile colours as the shapes. The last six tiles each use a shape with a different seed and one or two changed settings:
| Tile | Settings |
|---|---|
| Twin peaks | drifting-lobes, seed 7, peaks 2, spread 1 |
| Fine lines | drifting-lobes, seed 21, line count 48 |
| Scree | granite, seed 3, roughness 0.6 |
| Bloom | five-petals, seed 12, line count 42, variant 0.9 |
| Wide dunes | dunes, seed 5, line count 16, line width 0.4 |
| Plateau | seed-pod, seed 9, terracing 0.6 |
The tiles are small, so every tile uses a few extra settings to make the shapes easy to see. The summit is in the centre (layout.anchor 1). The centre fade is off (layout.fade 0). The lines are brighter and a little thicker: line opacity 0.85, line width 0.4, and ink #9fb0cc on paper #0b0b0d. To make the image again, run node scripts/styles-grid.mjs.
A look is a named, finished design.
It sets the shape, the seed, the lines, terrain, motion and layout values, the colours and a 3D camera pose.
The playground's dock shows the same looks, in the same order.
Pass a look's id as the look setting:
createTopography(el, { look: 'survey-map' });<topographic-view look="rose-pod"></topographic-view><Topography look="sea-dunes" />The other settings next to look override the look's values.
A setting the look leaves out keeps its default.
For example, { look: 'rose-pod', lines: { count: 40 } } draws Rose pod with 40 lines, and keeps its colours, seed and line width.
A shape next to a look draws the look's values on that shape.
In update(), a look replaces the design and keeps the view.
The view is mode, elevation, extent, detail, quality, view2d, and the camera motion and pan.
The look also keeps column, scenes and scene.
The other values in the same update apply on top of the look.
In React, a new look prop applies the other props again on top of the new look.
An unknown look is ignored, and the console shows one warning.
The looks are exported as LOOKS, with the type Look.
findLook(id) returns one look, or undefined.
LookId is the type of the ids.
In a playground link, look= takes the same ids, such as ?look=night-lobes&lc=40.
Each tile is the 2D frame at 4 seconds.
To make the image again, run node scripts/looks-grid.mjs.
| Id | Name | Shape | Colours |
|---|---|---|---|
survey-ridge |
Survey ridge | long-ridge |
dark, ink #e8e6e1 on #141414 |
fine-granite |
Fine granite | granite |
dark, ink #d7d2c8 on #0b0b0d |
wide-lobes |
Wide lobes | drifting-lobes |
dark, ink #c9c2b4 on #12141a |
rose-pod |
Rose pod | seed-pod |
dark, ink #f0b2a8 on #1a1214 |
terraces |
Terraces | gentle-summit |
dark, ink #e0b089 on #16130f |
high-dunes |
High dunes | dunes |
dark, ink #c6d6a8 on #101614 |
moss-granite |
Moss granite | granite |
dark, ink #9dcec0 on #101614 |
sea-dunes |
Sea dunes | dunes |
dark, ink #8ec4d4 on #0e1a22 |
night-flower |
Night flower | five-petals |
dark, ink #a9c7e4 on #10151c |
night-lobes |
Night lobes | drifting-lobes |
dark, ink #9fb0cc on #0b0b0d |
violet-pod |
Violet pod | seed-pod |
dark, ink #b9a4e0 on #16131a |
ink-petals |
Ink petals | five-petals |
dark, ink #cbb6ea on #16131a |
stepped-petals |
Stepped petals | five-petals |
light, ink #6b3050 on #f6eef2 |
day-lobes |
Day lobes | drifting-lobes |
light, ink #5c3348 on #f6eef2 |
still-dunes |
Still dunes | dunes |
light, ink #7a3e2e on #f3ece4 |
rocky-spine |
Rocky spine | long-ridge |
light, ink #5c3a28 on #efe6d6 |
pale-dunes |
Pale dunes | dunes |
light, ink #6b4630 on #efe6d6 |
stone-grain |
Stone grain | granite |
light, ink #3a342c on #e7e2d8 |
open-pod |
Open pod | seed-pod |
light, ink #2c2c2a on #f4f1ea |
quiet-hill |
Quiet hill | gentle-summit |
light, ink #2c2c2a on #f7f4ee |
bloom |
Bloom | five-petals |
light, ink #3c4a36 on #f3efe6 |
soft-summit |
Soft summit | gentle-summit |
light, ink #1e4650 on #e5eef0 |
paper-ridge |
Paper ridge | long-ridge |
light, ink #3d4c63 on #f7f4ee |
centred-lobes |
Centred lobes | drifting-lobes |
light, ink #243656 on #e7eef6 |
survey-map |
Survey map | gentle-summit |
Follows the page theme |
rocky |
Rocky | long-ridge |
Follows the page theme |
Survey map and Rocky set no colours, so they follow the page's light or dark theme and the CSS variables. The image shows them in dark mode.
A background can blend between scenes.
Each scene has its own shape and line colour, and shares every other setting.
scenes is the list, and scene is the index of the target scene.
When scene changes, the target fades in and the other scenes fade out.
Each frame, every weight moves toward 1 for the target and toward 0 for the others, by 1 - exp(-dt × 4.5), where dt is the frame time in seconds, at most 0.05.
The shader draws each scene with a weight above 0.002, and divides the colour sum by the weight sum.
const topography = createTopography(el, {
scenes: [
{ shape: 'drifting-lobes' }, // no ink: the theme's ink
{ shape: 'long-ridge', ink: 'rgb(98% 50% 13%)' }, // orange lines
{ shape: 'dunes' },
],
scene: 0,
});
topography.update({ scene: 1 }); // fades to the long ridge
topography.update({ scene: 2 }, { instant: true }); // jumps to the dunesscenesholds at most 8 scenes. An unknown shape uses the default shape and logs one warning. An empty list, the default, drawsshapealone.inkis any CSS colour. Without it, the scene uses the theme's ink.- A shape's own defaults, such as the lighter fade of dunes, do not apply to a scene. Set the shared values yourself.
- While scenes are set, a theme mode change fades too. The dark amount goes from 0 (light) to 1 (dark) and moves toward its target by
1 - exp(-dt × 6). The paper and the theme's ink are mixed between the light and dark colours by that amount.update(patch, { instant: true })sets the scene weights and the dark amount at once. A new renderer always starts on its targets. blendreads the weights and the dark amount drawn now.- While scenes are set, the animation time also takes at most 0.05 s a frame, so a slow frame does not jump the terrain ahead.
- A new
lookkeeps the scenes, the target scene andcolumn. - With reduced motion, the terrain holds still, but a fade still runs to its end. Then drawing stops on a still frame.
- Scenes apply to the 2D view. 3D draws
shape, with the faded theme colours. snapshotdraws the scenes as they are now. Withpatch, it draws the patched settings at rest: the target scene alone, in the theme's own colours.- The web component takes
scenesas JSON andsceneas a number:<topographic-view scenes='[{"shape":"dunes"}]' scene="0">. Scenes set withupdate()stay until one of those attributes is used. The React component takesscenes,sceneandcolumnprops.
This setup draws the background of yahyapak.dev, pixel for pixel.
scripts/blend-check.mjs compares the two in a browser.
const coarse = matchMedia('(pointer: coarse)').matches;
const ratio = () => (innerWidth < 600 || coarse ? 1 : 1.5);
const sized = () => ({
lines: { count: innerWidth < 600 ? 36 : innerWidth < 1024 ? 30 : 24 },
layout: { fade: 0.7 * (innerWidth < 720 ? 0.4 : 1) },
});
const topography = createTopography(document.getElementById('bg'), {
scenes: [
{ shape: 'drifting-lobes' },
{ shape: 'long-ridge', ink: 'rgb(98% 50% 13%)' },
{ shape: 'five-petals', ink: 'rgb(16% 66% 40%)' },
{ shape: 'seed-pod', ink: 'rgb(47% 38% 98%)' },
{ shape: 'gentle-summit' },
{ shape: 'dunes' },
],
column: 332, quality: 'full', theme: { mode: 'light' },
lines: { width: 0.9 / 3.5 }, motion: { warp: 0.7 / 1.5 }, ...sized(),
}, { maxPixelRatio: ratio(), maxPixels: Infinity, maxFps: coarse ? 30 : 0, powerPreference: 'low-power' });
addEventListener('resize', () => { topography.setOptions({ maxPixelRatio: ratio() }); topography.update(sized()); });
// On a page change, and when the theme changes:
topography.update({ scene: 2 });
topography.update({ theme: { mode: 'dark' } });powerPreference: 'low-power'asks for the same GPU as the old background did.quality: 'full'leaves the pixel ratio and the pixel count to the options, so a large screen is not drawn smaller.lines.widthandmotion.warpare set so the shader receives a line width of 1.4 and a warp of 0.7.- Leave
theme.inkand--topographic-inkunset: the neutral scenes then follow the dark amount between the light and dark default inks. - The renderer sizes its canvas from the element. On a page with classic scrollbars,
innerWidthincludes the scrollbar and the element does not, so give the element the whole viewport.
There are two colours. ink is the line colour. paper is the background colour. Each colour comes from the first of these that is set: the settings, then the CSS variables, then the light or dark defaults.
topographic-view, .my-hero {
--topographic-ink: #9fb3c8;
--topographic-paper: transparent;
}theme.mode:autois the default. It follows the operating system and changes when the system changes.lightordarkalways use that mode.theme.ink/theme.paper: any CSS colour, including named colours andoklch().- Transparent paper: with
paper: 'transparent', your page shows through the background. - Light mode draws ink lines on the paper. In dark mode, the lines glow softly on dark paper.
- If you change a CSS variable later, call
update({ theme: {} })to read it again.
The seed is a number that picks a variation of a shape. seed is a whole number from 0 to 999999. The same seed gives the same terrain on every screen. 0 is the original look. Change the seed to get a new variation of a shape. The other settings stay the same.
const blob = await topography.snapshot({ width: 1920, height: 1080, frame: 4000 });- It draws in the background. The live canvas keeps its size and keeps animating.
patchandfielddraw that image from temporary settings and a temporary height grid. The live view is put back before the call returns, so the page does not flash and a second WebGL context is not required.- To keep the on-screen shape, give only
widthor onlyheight. If you give neither, you get the current drawing size. frameis the animation time in milliseconds. The same settings and the same frame always give the same image.regionkeeps only part of the view:{ x, y, width, height }, as fractions measured from the top-left corner. The image is exactly that part of the screen, atwidth×heightpixels. For example, you can take a phone-shaped strip from a wide window.motiondraws the 3D view at a point on a camera path ('orbit','push'and so on). The point is the camera's position atframe. You get the same picture as that moment ofrecord.- It always centres the terrain in the image and ignores
setInsets, because an image has no toolbar. Aregionis a part of that centred image.recordworks the same way. - The largest size is the GPU's maximum renderbuffer size, usually 8192 or 16384 pixels. With a
region, the limit applies to the whole view, not only the region. - Without WebGL, it fails (the promise rejects).
The playground's PNG export uses this. The sizes are 2× the window, a phone size, or 1920 × 1080.
- Paused when hidden: drawing stops when the element is off-screen or the tab is hidden. It starts again when it is visible.
- Still when nothing moves: when
motion.speedis 0, there is no animation loop. It draws again only when something changes. - Reduced motion: when the system asks for reduced motion, it draws one still frame and ignores speed. A scene fade still runs to its end, on a still terrain.
- Frame rate cap: the
maxFpsoption draws at most that many frames each second. A skipped display frame adds its time to the next drawn frame. - Pixel limit: the pixel ratio is at most 2, and
qualitycan cap it lower. Very large canvases draw at a lower resolution abovemaxPixels. The GPU code smooths the lines, so they stay sharp. - Graphics budget:
quality: 'auto'starts a phone at low and a desktop at high, then steps down when frames miss the refresh period. It does not step back up. Setlow,mediumorhighto keep one tier.fulldraws likehighwithout its pixel caps: onlymaxPixelRatioandmaxPixelslimit the canvas. - Screen readers: the canvas is
aria-hidden, because it is only decoration. Put real content on top of it. - Readability:
layout.fademakes the lines softer behind a text column in the centre.layout.anchormoves the main summit from the side (0) to the centre (1).
| Situation | What you see |
|---|---|
| WebGL2 available | Full rendering (status: 'webgl2') |
| Only WebGL1 | The same rendering through WebGL1 ('webgl1') |
| No WebGL at all | Only the paper colour, and one console warning ('fallback') |
| The GPU context is lost | Only the paper colour until the browser restores the context. If the browser never restores it, the canvas is replaced and drawing starts again ('lost' until then) |
Nothing uses the DOM or WebGL until the code runs in the browser.
- React:
topographic-reactstarts with'use client'. On the server,<Topography>renders an emptydiv. The canvas appears after hydration. In the Next.js App Router, you can use it directly inside a server component's tree. - Web component: you can safely import
topographic/elementon the server. It registers the element only whencustomElementsexists. - Vanilla: call
createTopographyin the browser. For example, call it in an effect or afterDOMContentLoaded.
The playground (apps/playground) is the project's live demo page.
-
The page background is the preview. The dock at the bottom holds your saved map terrains, then the built-in looks as thumbnails. On a phone the previews are a little narrower, so four of them fit. The row fades at an end that still has terrains, and a chevron scrolls to the next one. Theme, Shuffle, paper and ink sit in a 2×2 grid on the right. Each item in the dock is a complete terrain. One click changes the shape and the colours together.
-
The view follows the gestures of Google Maps and Google Earth:
- Drag, or one finger: move the terrain. It follows the pointer and can't leave the screen.
- Scroll, pinch, or ⌘/Ctrl + scroll: zoom. In 2D the zoom keeps the point under the pointer in place, up to eight times closer.
- In 3D, ⌘, Ctrl or Shift + drag, or a right-drag: turn and tilt the camera.
- On touch, in 3D: twist two fingers to turn, or move two fingers up or down together to tilt.
- Double tap: zoom in. Two finger tap: zoom out.
- Double-click: move the pan smoothly back to the centre.
2D and 3D keep their own pan and zoom: the 2D view is
view2d(vx,vy,vzin the URL), the 3D view is the camera. The Anchor setting still places the summit in 2D: 0 keeps it to the side, 1 puts it in the centre. The terrain is centred in the space between the top buttons and the dock, not behind the dock. The pan and the zoom are part of the camera. A saved map terrain keeps them. A click on a look, or the camera reset in Tune, moves the camera back to the centre. -
The buttons at the top right are Map, 2D/3D, Tune and Export, and Install in its own circle in the corner. Each one opens its panel below the buttons. Only one panel is open at a time. The 2D/3D button has a small badge that shows the current mode.
-
Tune opens the settings, one tab at a time: Lines, Terrain, Motion, Layout and 3D.
- Each tab starts with a small live picture of its settings. The pictures show the contour rings, the side view of the terrain, the movement, the page layout, or the camera from above and from the side. The camera picture follows a moving camera a few times a second.
- Every row has a small icon. Each slider's track shows what the setting does, such as thicker lines, rougher ground or wider waves.
- Choices are icon buttons. The selected one shows its name.
- Line width shows in pixels (0.5 to 4 px).
- The 3D tab holds the scene (extent and elevation), the camera (motion, azimuth, angle and distance), the detail level and the graphics budget (Auto, Low, Med or High).
- The playground starts at High detail and High budget, unless the link or saved terrain names its own. If the frame rate stays below 30 fps, a Low performance: reduce quality button shows under the top right buttons. It sets Detail and Budget to medium. It shows once per browser session.
- In 3D, the frame rate shows just above the dock, at its left edge, with the graphics tier that is actually drawing.
- When a map place is applied, the Terrain sliders and Motion's Warp are dimmed, because the place sets the terrain. A note with Clear map is above them.
-
Install shows a preview of the view with its main settings, the agent skill command
npx skills add yahyabedirhan/topographic, the npm install lines and this view's code as onecreateTopographycall. Copy link copies the same link as Export's Link tab, and Export opens the Export panel. -
Export gives you a link, a PNG, a 4 second 3D video as MP4 or WebM, and the code as React, HTML or JSON.
- The link tab shows a labelled still of the terrain the link will open, then each of the link's parameters as a key and value.
- The PNG tab shows the view with a frame for each size: 2× the window, a phone (1170 × 2532) or 1920 × 1080. If a size has a different shape from the window, it crops the view. Drag its frame, or use the arrow keys, to choose the part to keep. For example, choose which part of a wide view goes on a phone. The frame is centred each time Export opens. The image is exactly that part of the screen, at full detail.
- The Video tab previews the video. Drag the timeline to see any moment of the selected camera path. The path is drawn from above.
- The files are named after the named place on the map, such as
topographic-mount-fuji.pngandtopographic-mount-fuji-orbit.mp4. Without a named place, a PNG is named after the shape and the seed, such astopographic-seed-pod-71.png, and a video after its camera path, such astopographic-orbit.mp4. - The code tabs number their lines. They colour the values that come from your settings.
-
Map opens a map with a frame. Use it to draw a real place as the terrain.
- Pan and zoom the map, or drag the frame's corners and edges. You can also pick a place from the badge's menu. It holds 108 places, grouped by continent: the ten built-in places and 98 more, listed in Places.
- The menu opens with a search field. Type a name, a country, a continent or a kind such as
volcano; case and accents do not matter, soreunionfinds Réunion. The arrow keys move through the results and Enter picks one. - The ten built-in places load from the library. Every other place loads its pre-rendered grid from
/places/<id>.png, and falls back to the elevation tiles when that file fails. - Press Apply. The real elevation inside the frame becomes the terrain.
- Apply switches to 3D and uses the map terrain view: 36 lines at 0.5 px and 0.9 opacity, every fourth line bold, a block at elevation 0.12, and a closer camera.
- Each place keeps that view but has its own camera angle and elevation. This puts its main feature in the middle, with the skyline above it.
- Apply also saves the terrain in the dock, after the built-in map terrains. Its name is the place's name, or Area.
- The dock starts with eleven built-in map terrains for everyone: Istanbul, Grand Canyon, Geirangerfjord, Yosemite Valley, Mount Fuji, Matterhorn & Zermatt, Torres del Paine, Everest, Kilimanjaro, Machu Picchu, and Naples & Vesuvius. The last two are catalog places and load their grids from
/places/<id>.png. Each one is like a look: it does not keep your changes, and a click on it always brings back its own settings, camera and pan. - When a saved item is active, your changes to it are kept, including camera moves. A click on it brings back the view you left it in.
- A built-in look does not keep your changes. A click on it always brings back its own camera. A plain shape uses the playground camera.
- Clear goes back to the generated terrain. It also brings back the lines, camera, extent and elevation you had before.
- When a map terrain is active, a small map above the right end of the dock shows the area you framed. In 3D, it turns with the camera, so it always matches the terrain on screen. Its compass points north. Click the compass to turn the camera back to north at the top (azimuth 0). Click the small map to open the map. In 2D, north is already at the top, so the small map does not turn.
- The map loads only when you open it. It needs a network connection.
- A link that opens a place hides the canvas until the heights arrive, so the default terrain does not flash. Contour rings draw in while it waits, on a desktop and on a phone. With reduced motion, the rings stay still. The page sets
data-loadingtoterrain, andget_regionreturnsloading: truewith the messageLoading terrain.
-
The URL stores the settings as short keys, so a link shows the same background.
- It stores only the values that are different from the shape's defaults or from a look:
?s=dunes&lc=24,?look=night-lobes&mode=3d.look=takes the library's look ids, and links made before 0.6.0 use the same ids. - Colours have no
#(ink=9fb0cc). - 3D uses the library defaults unless the URL changes them: endless, azimuth 349.7, angle 33.6 and distance 1.92.
- A pan away from the centre adds
pxandpz. A 2D pan or zoom addsvx,vyandvz. - Quality writes
qlwhen it islow,mediumorhigh.autois the default, so it is left out. - The address bar updates a few times a second at most, and again when a drag or pinch ends. A gesture does not write the URL on every frame.
?place=istanbulor?region=west,south,east,northopens a real place.place=takes any id in Places.
- It stores only the values that are different from the shape's defaults or from a look:
-
It serves
/llms.txt,/llms-full.txtand/docs/*.md. These files are made from the settings table in the code.
To run it, use npm install && npm run dev.
A public build does not include the debug heartbeat. Set VITE_TOPOGRAPHIC_DEBUG=1 for that build to include it. The heartbeat records errors, WebGL context loss, how many contexts are alive, canvas size and memory. It shows a small on-screen log and posts batches to ./__debug. astro dev prints those batches. A static host has no such path, and the page ignores the failed post. A URL cannot turn the heartbeat on.
The playground can send product analytics to PostHog and errors to Sentry. Both are set at build time, and both are off without their variable: the call sites do nothing, and the client is not in the build. The npm packages never include either, and scripts/check-public-bundle.mjs fails the build if they do.
| Variable | Effect |
|---|---|
VITE_POSTHOG_KEY |
PostHog project key. Turns on analytics. |
VITE_POSTHOG_HOST |
PostHog ingest host. Defaults to https://eu.i.posthog.com. |
VITE_SENTRY_DSN |
Sentry DSN. Turns on error reports. |
Each client loads in its own chunk when the page is idle after the first paint.
-
PostHog runs without cookies (
persistence: 'memory',cookieless_mode: 'always', which needs the project's cookieless server hash setting), with no session recording and no autocapture, and it respects Do Not Track. It sends pageview and pageleave (time on page, referrer and UTM parameters) and the typed events inapps/playground/src/analytics.ts:- Navigation:
ui_click(header links),view_mode_changed,panel_opened,panel_closed(with time open),export_tab_changed. - Looks and settings:
dock_item_clicked,saved_terrain_deleted,place_applied,region_loaded,setting_changed,quality_changed,theme_toggled,shuffle_clicked,colour_changed. - Sharing and export:
link_copied,code_copied,install_copied,export_image_downloaded,export_image_failed,export_video_started,export_video_completed,export_video_failed. performance_sample, once about 20 seconds into a visible visit: median and 10th-percentile fps, refresh rate, quality tier, viewport, device pixel ratio, GPU renderer, device memory, browser, OS, long frames, context losses and time to first frame.low_performance_prompt_shown(fps, view, detail and budget levels) andlow_performance_prompt_clicked, from the low performance prompt.analytics_opted_out, once, from the Opt out button.agent_tool_called, after each WebMCP tool call (see below).
Every event carries fixed super properties:
site,app,app_version,viewport_class,is_mobile,platform,osandinitial_view_mode, plus the actor properties fromactor.ts:actor:humanoragent.- A person's events carry
visitor_id: a random UUID thatvisitor.tssaves in local storage undertopographic:visitor, so one browser's visits can be counted together across days. - An agent's events carry
agent_idinstead, a random UUID in that tab's session storage undertopographic:agent, withagent_nameandpurposewhen the agent identified itself (theidentify_agenttool, or?analytics_agent=<name>&analytics_purpose=<purpose>on the first load), andautomation(headlessorvisible) in a WebDriver browser. Names and purposes come only from the fixed lists inactor.ts; any other value is refused. agent_tool_calledis sent by the page after every WebMCP tool call, with the tool name, whether it worked and a duration range. It never carries the tool's input, output or error message.
PostHog's distinct id stays the server's daily hash. posthog-js starts with
__preview_capture_bot_pageviews, so WebDriver browsers are sent instead of dropped; their pageviews arrive as$bot_pageview. Either opt out deletes the visitor id and the tab's agent id and stops sending them. Properties never hold free text, colours or the query string. - Navigation:
-
Sentry reports errors only, with no tracing or replay and no user data, cookies, headers or query string. It reports error-boundary crashes and lost WebGL contexts, tagged with the quality tier and GPU. A small buffer keeps errors from before it loads, and one page sends at most 10 reports.
Visitors can opt out with ?analytics_optout (pageviews only, no error reports), ?analytics_optout=all (nothing; neither client loads) or ?analytics_optin, or with the button on /about#analytics. The choice is kept in local storage under topographic:analytics, per browser. PRIVACY.md lists every event, and a test checks that it names each one. The Analytics section of /about is a short summary that links to it.
The playground is a Cloudflare Worker with static assets. With Workers Builds, add these as build variables (the Worker's Settings, then Build, then Variables and secrets). Vite reads them while building, so set them before the deploy that should use them.
The playground site is built with Astro as static files in apps/playground/dist.
/is the playground. It is a React island that renders only in the browser (client:only), fromindex.html./aboutand/docsare HTML pages,about.htmlanddocs.html, with their text in the HTML. Each page has its own title, description and share image, fromapps/playground/src/meta.ts. On/about, only the opt-out row runs in the browser./docshydrates for its live figures and code tabs.- Astro's client router moves between the three pages without a full load, with an animation. A browser without the View Transition API gets Astro's own animation. Each page change sends one pageleave for the page left and one pageview for the page shown.
- The build also writes
llms.txt,llms-full.txtanddocs/*.md, fromapps/playground/llms.ts.
wrangler.jsonc at the repo root describes the Worker topographic. It serves apps/playground/dist as static assets, with _headers from apps/playground/public for caching. /about and /docs are served from about.html and docs.html.
-
A browser that navigates to an unknown path gets
index.html, since the playground keeps its state in the query string. The page then goes home and keeps the query. -
Any other request for an unknown path, such as
curlorfetchfor/.well-known/mcp.json, reaches the small scriptworker/index.jsand gets a plain-text 404 that points tollms.txt. -
robots.txtallows every crawler and points tollms.txtandsitemap.xml. -
npm run deploybuilds the playground and runswrangler deploy. -
Workers Builds, connected to this repo, uses the build command
npm run build, the deploy commandnpx wrangler deployandNODE_VERSION=22. Astro needs Node.js 22.23.4 or later.
WebMCP lets a web page give tools to AI agents in the browser. In browsers that support it, the playground registers these tools with document.modelContext.registerTool(). Without WebMCP, no tools are registered.
Most headless and agent browsers have no native WebMCP yet. An agent there can still drive the playground:
-
With URL parameters.
llms.txton the playground lists every key, and the page applies them on load. -
With a stub. Before the page loads, give it a
document.modelContextwhoseregisterToolkeeps each tool. Then call a tool'sexecute(input). In Playwright:await page.addInitScript(() => { window.__tools = {}; document.modelContext = { registerTool: (tool) => { window.__tools[tool.name] = tool; } }; }); await page.goto('https://topographic.yahyapak.dev/?analytics_agent=claude&analytics_purpose=exploring'); await page.waitForFunction(() => window.__tools?.get_settings); await page.evaluate(() => window.__tools.set_settings.execute({ shape: 'dunes', mode: '3d' }));
The ?analytics_agent=<name>&analytics_purpose=<purpose> keys in that first URL are a polite request, not a rule. They tell the maintainer which agent visited and why, and change nothing else. Analytics and error reports lists the names and purposes, and ?analytics_optout=all sends nothing.
| Tool | What it does |
|---|---|
identify_agent |
Optional. The agent gives its name and a purpose, both from fixed lists, so analytics count it apart from people. Any other value is an error. |
list_shapes |
Lists the shapes, with their ids and descriptions, and the looks, with their ids and settings. |
get_settings |
Returns the current settings, the shortest config JSON, and the graphics tier that is drawing (quality, fps, frameMs, mesh). |
set_settings |
Applies some settings. look applies a look by id, and the other keys in the same call override it. The land shape is shape; the old key style still works until the next release. Reports values that were moved into range and keys that were ignored. A new theme.mode clears the earlier ink and paper, as the theme button does, unless the same call gives them. The result says which colours were cleared. |
randomize |
Picks a new random seed. With { "shape": true }, it also picks a random shape. |
take_screenshot |
Returns a PNG of the background as a data URL, 1200 px wide by default. An optional region ({ x, y, width, height }, as fractions of the view) keeps only that part. |
record_video |
Records a short 3D video of the current terrain with a moving camera. Returns it as an MP4 or WebM data URL. The default is a 2 second orbit at 640×360. |
get_embed_code |
Returns code for html, react, vanilla or json. |
get_install_instructions |
Returns setup steps for npm, web-component, react or vanilla. |
set_region |
Draws a real place as the terrain. Use a named place, any id in Places, or a bounding box in degrees. relief and smooth are optional. |
get_region |
Returns the current region, relief, smoothing, load status and URL query. While heights are still loading, loading is true and message is Loading terrain. |
clear_region |
Removes the region and brings back the generated terrain. |
The input schemas are in /docs/webmcp.md on the playground. WebMCP needs a secure page (HTTPS or localhost).
- Nothing shows. The element needs a size. See Sizing and layering.
- I only see a flat colour. WebGL is not available, or it was lost. Check
status, and look for the warning in the console. - It doesn't move. Check
motion.speed. Check whether reduced motion is on in your system. Check whether the element is on screen. - The lines are too faint. Increase
lines.opacityorlines.width, or use a strongerink. - The lines disappear behind my text. That's
layout.fade. Lower it, or set it to 0. - My CSS variable change didn't apply. Call
update({ theme: {} })after you change it. snapshotthrows. The size is larger than the GPU limit, or WebGL is not available.- A location shows only the paper colour. The place's file did not load. Look for the warning in the console. For
fetchLocation(), the page needs network access to the tile host. fetchLocation()rejects the area. The shorter side must be at least 1.5 km, and the longer side at most 300 km. The longer side can be at most 3 times the shorter side.areaProblem(bbox)names the problem before the download.- There's a warning about an unknown location. Compare the id with the ten places.
- There's a warning about an unknown shape. Compare the id with the Shapes list. Ids use dashes, like
seed-pod.

