Backgrounds

Topography

A seamlessly tiling contour-map backdrop traced from seeded hash noise — quiet relief lines for docs shells, auth screens and empty states.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

/**
 * Keyframes ship inside the component via React 19 hoisted <style> —
 * no tailwind config edits, and duplicates dedupe by href.
 * One cycle translates the field by exactly one tile on both axes, so the
 * end frame is pixel-identical to the start frame: the drift never resets.
 */
const KEYFRAMES = `@keyframes zy-topography-drift{from{transform:translate3d(0,0,0)}to{transform:translate3d(var(--zy-topo-tile),var(--zy-topo-tile),0)}}`

/** Marching-squares grid resolution inside one tile. 48 keeps facets under a

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/topography.json

Prompt

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), not Math.random(). That keeps render pure (no react-hooks/purity violation), 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.

On This Page