Docs

How to install topolines, mount it in React or plain JavaScript, and a few recipes for common scroll and theme setups.

Install

topolines has no dependencies. Install it with npm.

npm i topolines

Quick start: React

Import Topolines from topolines/react and place it in any container with a height. Every prop is optional; the defaults match the reference look.

import { Topolines } from "topolines/react";

export default function Hero() {
  return (
    <div style={{ position: "fixed", inset: 0 }}>
      <Topolines seed="hero" />
    </div>
  );
}

Quick start: vanilla

Without React, use the TopoField class directly. It mounts a fresh canvas inside the host element you give it and starts drawing right away.

import { TopoField } from "topolines";

const host = document.querySelector("#hero");
const field = new TopoField(host, { seed: "hero" });

// later
field.setOptions({ opacity: 0.24 });
field.destroy();

Using it in Next.js

Topolines is already a client component; the package ships its own "use client" directive. Import it straight into a server component tree, no dynamic import and no ssr: false wrapper needed. On the server it renders an empty container, and the canvas mounts after hydration.

Fallback and isSupported

Some browsers lack WebGL or the derivatives extension the shader needs. Pass a fallback and the component renders it instead of the canvas.

<Topolines fallback={<div className="static-bg" />} />

To check support before you render the component at all, call isSupported() directly. The result is cached after the first call.

import { isSupported } from "topolines";

if (!isSupported()) {
  // show a static image instead
}

What it does automatically

Each instance pauses itself when its host scrolls out of view, using an IntersectionObserver, and again when the browser tab is hidden. If the visitor has reduced motion set, the field holds a single still frame instead of animating. A ResizeObserver keeps the canvas matched to its host, and the clock is clamped after a pause so time never jumps forward in one big step. devicePixelRatio is capped by maxDpr to keep the shader affordable on high-density screens.

Recipes

Theme from CSS variables

Read your design tokens with getComputedStyle instead of hardcoding a hex value, so the field always matches the current theme.

"use client";
import { Topolines } from "topolines/react";

export function ThemedField() {
  const accent = getComputedStyle(document.documentElement)
    .getPropertyValue("--accent")
    .trim();

  return <Topolines color={accent || "#C3D82C"} opacity={0.2} />;
}

Scroll color journey

colorStops remap the line color and opacity across a 0 to 1 progress value. By default that value is whole-document scroll progress; pass getProgress to drive it from something narrower, like one section's own scroll range.

<Topolines
  colorStops={[
    { at: 0, color: "#C3D82C", opacity: 0.16 },
    { at: 0.5, color: "#FF4D00", opacity: 0.4 },
    { at: 1, color: "#9EC7E8", opacity: 0.2 },
  ]}
  getProgress={() => {
    const el = document.querySelector("#story");
    if (!el) return 0;
    const r = el.getBoundingClientRect();
    return Math.min(1, Math.max(0, -r.top / (r.height - window.innerHeight)));
  }}
/>

One map, two sections

Same seed means the same starting field. Mount two Topolines instances in stacked sections with a matching seed and drift, and the noise underneath reads as one continuous map rather than two unrelated patterns.

<section style={{ position: "relative", height: "100vh" }}>
  <Topolines
    seed="atlas"
    drift={[0.004, 0.002]}
    style={{ position: "absolute", inset: 0 }}
  />
</section>
<section style={{ position: "relative", height: "100vh" }}>
  <Topolines
    seed="atlas"
    drift={[0.004, 0.002]}
    style={{ position: "absolute", inset: 0 }}
  />
</section>

Freeze the pan through a pinned section

scrollPan drives the map from window.scrollY by default, so a pinned section would otherwise still pan underneath it while the page keeps scrolling. getPanScroll overrides the value scrollPan reads; clamp it to the scroll position where the pin starts and the map holds still for the length of the pin.

const pinStart = 1200; // scrollY where the pinned section begins

<Topolines
  scrollPan={[0, 0.0006]}
  getPanScroll={() => Math.min(window.scrollY, pinStart)}
/>