contour-field
v0.2.0
Published
Decorative topographic contour-line backdrops, generated from a Gaussian elevation field. Framework-agnostic core with an optional React wrapper.
Maintainers
Readme
contour-field
Decorative topographic contour-line backdrops, generated from a Gaussian elevation field.
Rather than drawing independent rings — which cross once they grow into each other — this models a single continuous elevation field and extracts iso-lines from it with marching squares. Because every line is a level set of one field, lines never intersect, exactly like real topographic contours.
No dependencies. React is optional. The backdrop is static by default and ships no client JavaScript — or make it follow the pointer, from a separate entry point.
Gallery tiles are rendered by npm run gallery at a coarser sampling and a stronger opacity ramp than the defaults — the shipped ramp is tuned to sit behind page content and is near-invisible at thumbnail size.
Install
npm install contour-fieldReact
import { ContourBackground } from "contour-field/react";
export default function Layout({ children }) {
return (
<div>
<ContourBackground />
{children}
</div>
);
}Renders an aria-hidden, pointer-events: none layer at position: absolute; z-index: -1, sized to the field's aspect ratio so the contours continue below the fold as the page scrolls.
There is no "use client" directive: with no hooks or state it works as a Server Component and inside a client one.
Without React
<div id="bg" style="position:absolute;inset:0;z-index:-1"></div>
<script type="module">
import { mount } from "https://esm.sh/contour-field";
const unmount = mount(document.getElementById("bg"), { color: "#c2bfbf" });
</script>Or generate an SVG file at build time and ship zero runtime JavaScript:
import { writeFileSync } from "node:fs";
import { renderToStaticSVG } from "contour-field";
writeFileSync("public/contour.svg", renderToStaticSVG({ color: "#c2bfbf" }));renderToStaticSVG emits a standalone document with xmlns, so the output is a valid .svg file as well as something you can drop into innerHTML.
Interactive
A separate entry point makes the field respond to the pointer: a hill rises under the cursor, contours flow around it, and lines near it tint towards an accent colour.
import { ContourBackground } from "contour-field/react";
import { LiveContourBackground } from "contour-field/interactive/react";
<LiveContourBackground accentColor="var(--accent)">
<ContourBackground />
</LiveContourBackground>;Or without React:
import { mount } from "contour-field";
import { mountLive } from "contour-field/interactive";
const el = document.getElementById("bg");
mount(el, { color: "#c2bfbf" });
const stop = mountLive(el, { color: "#c2bfbf", accentColor: "#3300ff" });How it degrades
The interactive layer is an enhancement over the static one, never a replacement. Render the static field first; mountLive adds a canvas on top and, on first hover, fades the SVG out from under it. Because the first canvas frame is generated from the same primitives as the SVG, the two are geometrically identical and the swap cannot be seen.
It bails and leaves your static layer untouched when the device reports a coarse pointer, when prefers-reduced-motion: reduce is set, or when no 2D context is available. With no JavaScript at all, the server-rendered SVG is simply what stays on screen. Every one of those paths lands on exactly <ContourBackground />.
Why children
A Client Component re-runs in the browser during hydration, so a component that generated the field itself would pay for it twice — once in Node, once on the main thread while the page comes alive. Children passed in from a Server Component are rendered on the server and arrive serialised, so the browser never runs the field generator at all.
Omit children and it renders its own SVG. That works everywhere and is the only option outside RSC — it just isn't free.
Options
Every geometry option applies, plus:
| Option | Default | |
|---|---|---|
| accentColor | var(--color-accent, currentColor) | Colour the lines tint towards near the cursor |
| bump | { sigma: 85, height: 0.5 } | Cursor hill: spread in world units, height as a fraction of the field's range |
| tintFalloff | [120, 260] | Full-tint and half-tint distances from the cursor |
| requireFinePointer | true | Skip the effect on touch devices |
| respectReducedMotion | true | Skip the effect under prefers-reduced-motion |
color and accentColor are resolved in the container's own cascade, so a CSS custom property re-themes the effect for free — the same option renders blue on one route and orange on another with no per-page configuration.
Cost
Roughly one full field generation up front, run during idle time so the first hover never pays for it. After that a frame restrokes cached geometry and re-marches only the cells inside the bump's support — a ~60×60 patch rather than the full 160×178 grid.
That works because levels stay pinned to the base field's elevation range. The bump pushes terrain up through existing level sets, so new rings bloom around the cursor while every distant line holds perfectly still — which is both the effect you want and the reason distant geometry can be cached at all.
Colour and theming
The default stroke is var(--color-border-strong, currentColor).
If your app defines that CSS variable, the contours follow it automatically — including across a light/dark theme switch, with no JavaScript and no theme context. If it doesn't, the stroke falls back to currentColor and inherits from the parent. Either way it renders something sensible out of the box. Pass color to override.
The package never reads a theme; this is pure CSS cascade.
Props
<ContourBackground /> takes these presentation props, plus every geometry option below.
| Prop | Default | |
|---|---|---|
| color | var(--color-border-strong, currentColor) | Stroke colour |
| opacity | 0.6 | Opacity of the whole layer |
| className | — | |
| style | — | Merged after the defaults, so it can override any of them |
Geometry
These are identical on the component and on buildContours(options).
| Option | Default | |
|---|---|---|
| hills | DEFAULT_HILLS | The peaks defining the field — see below |
| numLevels | 30 | How many contour lines. The main density knob |
| levelRange | [0.04, 0.98] | Fraction of the elevation range the lines span. Narrowing crowds lines onto the slopes |
| indexContourEvery | 5 | Draw every Nth line bolder |
| viewW / viewH | 1440 / 1600 | World size. Sets the viewBox and the layer's aspect ratio |
| cols / rows | 160 / 178 | Sampling resolution. Higher is smoother and slower |
| strokeWidths | { line: 0.7, index: 1.3 } | |
| opacityRange | [0.05, 0.5] | Per-line fade, lowest to highest elevation |
numLevels and levelRange are the two independent ways to concentrate lines: more of them, or the same number packed into a narrower elevation band.
Unknown options throw rather than being ignored, so a typo'd numLevel fails loudly instead of silently rendering the default field.
Hills
Each hill is an anisotropic (sx ≠ sy), rotated Gaussian peak:
{ cx: 360, cy: 250, sx: 330, sy: 205, theta: 0.5, amp: 1.0 }| | |
|---|---|
| cx, cy | Centre, in world units (viewW × viewH) |
| sx, sy | Spread along the hill's local axes. Must be > 0 |
| theta | Rotation of those axes, in radians |
| amp | Peak height, relative to the other hills |
Spread DEFAULT_HILLS to extend the shipped look rather than start over:
import { DEFAULT_HILLS } from "contour-field";
<ContourBackground
hills={[...DEFAULT_HILLS, { cx: 700, cy: 900, sx: 280, sy: 180, theta: 0.2, amp: 0.8 }]}
/>Performance
Generation cost scales as cols × rows × numLevels — roughly 80 ms at the defaults, on import-free, lazily-evaluated code. Results are memoized by options, so a given field is built once per process no matter how many times it renders.
Doubling cols and rows quadruples the field sampling. Past ~600 in either you'll get a console warning; if you want that much detail, render to a static .svg at build time instead.
Troubleshooting
The contours are invisible. Almost always z-index: -1 meeting a stacking context. If any ancestor of the layer creates a stacking context and paints its own background, -1 puts the contours behind that background rather than behind your content. Fix it from the consuming side:
<ContourBackground style={{ zIndex: 0 }} />...and give the sibling content position: relative so it stacks above. The other candidate is a --color-border-strong that resolves to your page background — pass an explicit color to check.
Nothing renders and the console shows ContourFieldOptionsError. That's deliberate: an unknown or degenerate option throws rather than rendering a broken field. The message names the offending value.
The interactive layer does nothing. By design it bails silently rather than degrading badly, so check the three gates in order: a coarse pointer (requireFinePointer), prefers-reduced-motion: reduce (respectReducedMotion), and canvas availability. Both gates can be turned off if you want the effect regardless. If it still does nothing, confirm the container is positioned — the canvas is position: absolute and needs a positioned ancestor to fill.
The cursor tint never appears. accentColor has to resolve to a colour the canvas can parse. It is resolved in the container's cascade, so a var(--accent) that isn't defined at that point in the tree falls back to currentColor — which is usually the same as the line colour, making the tint invisible. Pass a literal colour to check.
API
Four entry points. The split is structural, not cosmetic: importing the static backdrop can never pull in a client boundary or the interactive layer's code.
| Entry | |
|---|---|
| contour-field | Core and static render targets. No React |
| contour-field/react | ContourBackground. Renders as a Server Component |
| contour-field/interactive | mountLive and helpers. No React |
| contour-field/interactive/react | LiveContourBackground. A Client Component |
contour-field
| | |
|---|---|
| buildContours(options?) | → readonly { d, width, opacity }[]. Pure, memoized, frozen |
| renderToStaticSVG(options?) | → string. Standalone SVG document |
| renderToCanvas(ctx, options?) | Draw into a 2D context. Restores the context's state |
| mount(el, options?) | → () => void. Renders into a DOM element; returns an unmount |
| normalizeOptions(options?) | Validate and fill in defaults. Throws on unknown keys |
| clearCache() | Drop memoized geometry. For tests and benchmarks |
| DEFAULT_HILLS, DEFAULT_OPTIONS, DEFAULT_COLOR, DEFAULT_OPACITY | |
| ContourFieldOptionsError | |
contour-field/interactive
| | |
|---|---|
| mountLive(el, options?) | → () => void. Adds the cursor-reactive canvas |
| dirtyRect(cursor, radius, grid) | Cell range a bump affects, clamped. Null when it misses |
| bumpAt(x, y, cursor, sigma, height) | Gaussian bump height at a point |
| tintBand(distance, falloff) | → 0 \| 1 \| 2. Which tint band a distance falls in |
| DEFAULT_ACCENT_COLOR, DEFAULT_BUMP, DEFAULT_TINT_FALLOFF | |
Field primitives
buildContours runs the whole pipeline; these are the stages beneath it, for building a render target this package doesn't ship. Using them keeps your output consistent with the SVG's — the interactive layer is built on nothing else.
| | |
|---|---|
| elevation(x, y, hills) | The raw field function |
| buildField(options) | → { values, vmin, vmax, dx, dy, cols, rows }. Samples the grid |
| computeLevels(field, options) | → { level, isIndex, width, opacity }[]. The elevation ladder |
| marchLevel(args) | Marching squares for one level |
marchLevel takes { level, read, cols, rows, dx, dy, bounds?, out? } and appends [x1, y1, x2, y2, c, r] per segment. Three details make it usable for animation:
read(c, r)is yours to supply, so you can perturb the field without resampling it.boundsrestricts the march to a cell range, so only what changed is recomputed.- each segment carries its cell, so geometry you cached knows what a bounded re-march has superseded.
options for buildField and computeLevels must be normalized — pass them through normalizeOptions first.
TypeScript declarations are included; the source is plain JavaScript.
License
MIT
