Free · MIT

Topography

Contour lines of a landscape that slowly changes.

Hairlines on a ground colour, so text sits on top of it. A hill rises under the cursor.

The code

The whole file is below. Install the packages it imports, add the file to your project and import it.

Topography.tsx
"use client";

/**
 * Topography — contour lines of a landscape that slowly breathes.
 *
 * Self-contained: three, @react-three/fiber. One fragment shader:
 * layered noise for the terrain, hairlines where it crosses each
 * level. Reads as a survey map; sits under text at any size.
 *
 *   <Topography line="#e4a256" ground="#0b0908" levels={18} speed={0.4} />
 */
import { Canvas, useFrame, useThree } from "@react-three/fiber";
import { useMemo, useRef } from "react";
import { Color, Vector2, type ShaderMaterial } from "three";

export type TopographyProps = {
  line?: string;
  ground?: string;
  /** Number of contour levels. */
  levels?: number;
  speed?: number;
  className?: string;
};

const vertex = /* glsl */ `
  varying vec2 vUv;
  void main() { vUv = uv; gl_Position = vec4(position, 1.0); }
`;

const fragment = /* glsl */ `
  precision highp float;
  varying vec2 vUv;
  uniform float uTime;
  uniform float uLevels;
  uniform vec2 uAspect;
  uniform vec2 uPointer;
  uniform vec3 uLine;
  uniform vec3 uGround;

  vec2 hash(vec2 p) {
    p = vec2(dot(p, vec2(127.1, 311.7)), dot(p, vec2(269.5, 183.3)));
    return -1.0 + 2.0 * fract(sin(p) * 43758.5453123);
  }
  float noise(vec2 p) {
    vec2 i = floor(p); vec2 f = fract(p);
    vec2 u = f * f * (3.0 - 2.0 * f);
    return mix(
      mix(dot(hash(i), f), dot(hash(i + vec2(1.0, 0.0)), f - vec2(1.0, 0.0)), u.x),
      mix(dot(hash(i + vec2(0.0, 1.0)), f - vec2(0.0, 1.0)), dot(hash(i + vec2(1.0, 1.0)), f - vec2(1.0, 1.0)), u.x),
      u.y);
  }
  float fbm(vec2 p) {
    float v = 0.0; float a = 0.5;
    for (int i = 0; i < 4; i++) { v += a * noise(p); p = p * 2.1 + 3.3; a *= 0.5; }
    return v;
  }

  void main() {
    vec2 p = vUv * uAspect * 2.4;
    float t = uTime * 0.08;
    float h = fbm(p + vec2(t, -t * 0.6)) * 0.5 + 0.5;
    // A hill rises under the pointer.
    float d = distance(vUv * uAspect, uPointer * uAspect);
    h += exp(-d * d * 12.0) * 0.35;

    float level = h * uLevels;
    // Anti-aliased hairline at every integer level.
    float dist = min(fract(level), 1.0 - fract(level));
    float width = fwidth(level) * 0.9 + 0.012;
    float lineMask = 1.0 - smoothstep(0.0, width, dist);
    // Every fifth line heavier, as on a real map.
    float index = floor(level);
    float heavy = mod(index, 5.0) < 0.5 ? 0.45 : 0.0;
    float alpha = lineMask * (0.42 + heavy);

    vec3 col = mix(uGround, uLine, alpha);
    // Faint fill on the higher ground so the shape reads.
    col = mix(col, uLine, smoothstep(0.55, 1.0, h) * 0.06);
    gl_FragColor = vec4(col, 1.0);
  }
`;

function Plane({ line, ground, levels, speed }: Required<Omit<TopographyProps, "className">>) {
  const material = useRef<ShaderMaterial>(null);
  const size = useThree((state) => state.size);
  const pointer = useThree((state) => state.pointer);
  const uniforms = useMemo(
    () => ({
      uTime: { value: 0 },
      uLevels: { value: levels },
      uAspect: { value: new Vector2(1, 1) },
      uPointer: { value: new Vector2(-10, -10) },
      uLine: { value: new Color(line) },
      uGround: { value: new Color(ground) },
    }),
    // eslint-disable-next-line react-hooks/exhaustive-deps
    [],
  );
  useFrame((_, delta) => {
    if (!material.current) return;
    const u = material.current.uniforms;
    u.uTime.value += delta * speed;
    u.uLevels.value = levels;
    u.uAspect.value.set(size.width / size.height, 1);
    u.uPointer.value.set(pointer.x * 0.5 + 0.5, pointer.y * 0.5 + 0.5);
    u.uLine.value.set(line);
    u.uGround.value.set(ground);
  });
  return (
    <mesh>
      <planeGeometry args={[2, 2]} />
      <shaderMaterial ref={material} vertexShader={vertex} fragmentShader={fragment} uniforms={uniforms} />
    </mesh>
  );
}

export function Topography({ line = "#e4a256", ground = "#0b0908", levels = 18, speed = 0.4, className = "" }: TopographyProps) {
  return (
    <div className={className} style={{ width: "100%", height: "100%", minHeight: 240 }}>
      <Canvas dpr={[1, 2]} gl={{ antialias: false, preserveDrawingBuffer: true }} orthographic camera={{ position: [0, 0, 1] }}>
        <Plane line={line} ground={ground} levels={Math.round(levels)} speed={speed} />
      </Canvas>
    </div>
  );
}

export default Topography;

Inside

  • Layered noise terrain with a contour line at every level
  • Anti-aliased hairlines at any pixel density
  • Pointer hill
  • Props for line, ground, levels and speed

Install

npm install three @react-three/fiber

One file.

Kind
Backgrounds
Licence
MIT
Runs on
React 19
Price
Free

More elements

3D objects
Pro
Chrome Blob: open the element page

Chrome Blob

A metallic sphere that distorts as it turns, with studio reflections.

Price
USD 10, all access
Built with
React Three Fiber

FAQ

What you get, what it runs on, what it costs to run and what the licence allows.

01

What do I get?

One React component file per element, plus any shader or asset files it needs. Members of a series also get the small engine file they share. Free elements are copied from the page. Pro elements are a download after one payment of USD 10, which unlocks all of them. Each element imports only public packages, which are listed on its page.

02

What does it run on?

React 19. The 3D and shader elements use three.js through React Three Fiber. The 2D ones use Framer Motion or plain CSS and canvas. They work in Next.js, Vite, Remix and any other React setup. Framer and Webflow users can embed an element as a code component.

03

Can I change the colours and speed?

Yes. Every element exposes its look as props, the same controls shown on its page. Anything beyond the props is yours to edit in the file.

04

Can I use these in a deck, a magazine or a film?

Yes. Every element page has a Save frame button that downloads the current frame as a PNG at the size shown. Make the browser window large for a print-size still. For motion, record the page or ask us for a rendered clip. The licence covers web, print, decks and video, for your own work and your clients' work.

05

How heavy is it?

The 3D elements mount when they scroll into view and unmount when they leave, so an element off screen costs nothing. The shader backgrounds cost a fraction of a video background. The 2D elements are a few kilobytes.

06

What is the licence?

Free elements are MIT. Use them anywhere, including client work. Pro elements are licensed to you for unlimited projects of your own and your clients'. You may not resell or redistribute the source itself.

07

Can you make one for us?

Yes. A custom element, such as your product in 3D or a branded shader, is a small project quoted up front. All-access members are asked first about what we build next.