Topography
A seamlessly tiling contour-map backdrop traced from seeded hash noise — quiet relief lines for docs shells, auth screens and empty states.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/topography.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "Topography" component — a seamlessly
tiling contour-map (topographic relief) backdrop. Its only dependency is a cn()
class merger (clsx + tailwind-merge). It IS a client component ("use client")
for exactly one reason: React.useId().
Contract
- export function Topography(props): props extend
Omit<React.ComponentProps<"div">, "children"> (rest props spread onto the root
div) plus:
- seed?: number (default 1) — integer that picks the terrain. Same seed, same
contours, forever.
- density?: number (default 9, clamped 2-16) — how many contour levels are
traced per tile. This is the line-density knob.
- strokeWidth?: number (default 1, clamped 0.25-4) — line width in px at the
rendered tile size.
- scale?: number (default 260, clamped 80-1200) — tile size in px. The pattern
repeats every `scale` px, so a larger value means larger landforms.
- drift?: boolean (default true) — a very slow diagonal translation of the
whole field.
- tone?: "border" | "muted" | "primary" (default "muted") — which semantic
token the strokes inherit.
- It renders no children: it is a decoration layer, like a background image.
The consumer puts it inside a `relative isolate` ancestor and writes content
in a `relative` sibling that stacks above it.
- Numeric props are clamped and non-finite values fall back to the default:
scale=0 divides by zero when the stroke width is compensated for the pattern
transform, and density=0 would draw nothing at all.
Behavior
- Height field: value noise over an integer-hash lattice, sampled in tile space
x,y in [0,1] on a 49x49 grid. THE LATTICE WRAPS — lattice coordinates are
taken modulo the octave's period, so the sample at x=1 hits the same corner as
x=0. That is the whole trick behind seamless tiling: a contour that leaves the
right edge re-enters the next tile at exactly the same height, so no seam can
appear no matter how the pattern is tiled.
- Three octaves at periods 3 / 6 / 12 lattice cells per tile, weighted
0.6 / 0.3 / 0.1, each with its own seed offset. The grid is then normalized to
its observed min/max; without that step some seeds sit between two contour
levels and draw almost nothing.
- Determinism is mandatory. The field comes from a bit-mixing integer hash
(Math.imul + xorshift steps), never Math.random() and never Date.now().
Random values during render break SSR/client parity and are a lint error under
react-hooks/purity. The traced path is memoized on (seed, density).
- Contours: marching squares over the grid at `density` evenly spaced levels,
level = (l + 0.5) / density. The two ambiguous saddle cases (5 and 10) are
resolved with the average of the four corner heights.
- Segments are chained into polylines by matching endpoint coordinates: two
neighbouring cells compute their shared edge crossing from the same two corner
values, so the endpoints are identical and the chain joins exactly. Chain each
level separately — never across levels — or two different heights get spliced
into one wrong line whenever their crossings happen to round equal. Drop
zero-length chains (a contour passing exactly through a grid corner collapses
both edge hits onto one point, which would paint as a stray dot), but KEEP the
short chains near the tile edge: those are the pieces that carry a contour
across the seam.
- The traced path is authored in a 0-100 square and placed inside
<pattern patternUnits="userSpaceOnUse" width={scale} height={scale}> with
transform="scale(scale/100)", and strokeWidth pre-divided by that same factor
so the line keeps the requested pixel width. Consequence: changing `scale` is
only a transform, it never re-runs the tracing.
- UNIQUE PATTERN ID IS MANDATORY. SVG ids are document-global, so two instances
sharing one id make the second silently paint the first one's map. Derive it
from React.useId() and strip the characters that are illegal in an XML id (and
therefore inside url(#...)):
`zy-topography-${React.useId().replace(/[^a-zA-Z0-9]/g, "")}`. This is the
only reason the component needs "use client".
- drift=true: the layer holding the <svg> is inset by minus one tile on all four
sides and animates transform from translate3d(0,0,0) to
translate3d(tile, tile, 0). Translating by exactly one tile lands on a
pixel-identical frame, so the loop never visibly resets, and the one-tile
overhang means the moving layer can never uncover an edge of the container.
The duration is read from a --zy-topo-drift custom property (default 120s per
tile) so a consumer can retune it without touching the keyframes.
- The @keyframes ship inside the component via a React 19 hoisted
<style href="zyeon-topography" precedence="medium"> tag — no Tailwind config
edits, and multiple instances dedupe to one style tag by href.
- prefers-reduced-motion: motion-reduce:[animation:none] sits in the same
arbitrary-value class as the animation (not a competing inline style, which
reduced motion could not out-rank), so the drift reliably stops while the map
stays fully rendered. The static pattern is the point; the motion is garnish.
Rendering & styling
- Semantic tokens only. The path is stroke="currentColor" and the root div sets
that color with a token utility plus the alpha that makes both themes read the
same: "border" -> text-border, "muted" -> text-muted-foreground opacity-40,
"primary" -> text-primary opacity-25. No hex / rgb() / oklch() anywhere, so
the map re-skins itself with the host theme and gets dark mode for free.
Both halves are plain utilities, so a consumer's className can out-merge
either one through cn().
- Root: aria-hidden="true" pointer-events-none absolute inset-0 overflow-hidden.
It is pure decoration so it must never take a click, and the overflow clip is
what keeps the oversized drifting layer inside the container.
- It belongs under content, not over it: the tone alphas are deliberately low.
Push them past ~0.5 and the contours start fighting body text.
Customization levers
- Terrain: `seed` — try a handful of integers and keep the landforms that suit
the layout. Nothing else about the component changes.
- Line density: `density` (2-16). 4-6 reads as a sparse elevation map, 12-16 as
a dense relief print; pair high density with a thinner strokeWidth.
- Landform size: `scale`, the tile in px. 160-220 for busy small surfaces,
320-500 for full-page hero/auth backdrops where a few big shapes is the point.
- Weight: `strokeWidth` in px, independent of `scale` because it is pre-divided
by the pattern transform.
- Palette: `tone` maps to a (token, alpha) pair — extend that record with e.g.
accent or destructive instead of hardcoding a color anywhere.
- Motion: `drift` on/off, plus the --zy-topo-drift custom property for the
duration. Below ~30s per tile the creep becomes noticeable and stops being
background.
- Terrain character: the octave weights (0.6 / 0.3 / 0.1) and the base period
(3 lattice cells per tile). A larger base period gives more and smaller hills;
raising the later weights crinkles the coastlines.
- Fidelity vs. payload: the 48x48 sample grid sets both the facet size and the
length of the emitted path string (~11 kB at the defaults, and it ships inside
the SSR HTML). Drop it to 32 for a coarser, cheaper map.
- Fade: give it a mask-image (radial or linear) through className if the
contours should dissolve toward the edges instead of meeting the container
border.Concepts
- Wrapping lattice = no seam — the noise lattice is indexed modulo the octave's period, so the height field is periodic over exactly one tile. A contour crossing the right edge continues at the identical height on the left edge of the next tile, which is what lets an SVG
<pattern>repeat forever without a visible join. - Seeded hash noise — every lattice corner's value comes from a bit-mixing integer hash of
(x, y, seed), notMath.random(). That keeps render pure (noreact-hooks/purityviolation), makes SSR and hydration agree, and turns "which terrain" into a single integer you can commit to source. - Marching squares — contours are traced cell by cell: each cell's four corners are compared against a level, and the crossing points are linearly interpolated along the edges. Neighbouring cells derive a shared edge crossing from the same two corner values, so segments meet exactly and chain into continuous lines.
- Chain per level — segments are joined by endpoint identity, so chaining must be scoped to a single contour level; mixing levels lets two different heights splice into one line whenever their crossings round to the same coordinate.
- Tile-quantised drift — the moving layer is grown by one tile on every side and travels exactly one tile per cycle, so the last frame is pixel-identical to the first: the loop never snaps back, and no edge is ever uncovered.
- Per-instance pattern id — SVG ids are document-global; two instances sharing one would silently paint the same map. The id is derived from
useId()with XML-illegal characters stripped, which is the single reason this component is client-side.