Skip to content

IrisBackgroundsSwitchback

Switchback

Night Road folded into a mountain hairpin — one ribbon of light zigzags up a dark slope under a pale moonlit haze, tiny valley windows twinkling below.

trail

Switchback

Where `night-road` converges on one vanishing point and `contraflow` rides a single gentle S-bend, this ribbon of warm light trail folds back on itself twice climbing a dark mountainside — the amplitude of each hairpin shrinking with height the way a switchback road genuinely reads from an elevated night vantage: wide and close near the camera, small and distant near the ridge. A pale, cool moonlit haze sits over the top of the frame in place of open black sky, and a sparse scatter of tiny warm points twinkles in the valley away from the road — distant windows, not more traffic.

The pointer's x leans the whole zigzag sideways, more so near the camera than near the ridge, the way the near hairpins would swing more than the distant ones; its y throttles the flicker exactly like `night-road`. The same warm headlight bloom rides the cursor.

  • warm
  • perspective
  • light-trails
Family
trail
Status
Available
Licence
Free

Install

No installation needed — self-contained, paste-in code.

Usage

Drop it straight into a page.

example.tsx
import { SwitchbackField } from "./SwitchbackField";

export default function Example() {
  return (
    // Fills its nearest positioned ancestor (it renders itself `absolute
    // inset-0`) — give it a sized, relatively positioned box.
    <div className="relative isolate h-[32rem] w-full overflow-hidden rounded-2xl">
      <SwitchbackField />
    </div>
  );
}

Component

The real source, exactly as it ships — multiple files, kept together.

"use client";

import { useEffect, useRef } from "react";
import { mountShaderSurface } from "@/lib/shader-surface";

/**
 * A live WebGL variation on the night-road family that trades `RoadField`'s
 * single vanishing point for a genuine zigzag: one ribbon of warm light
 * trail folding back on itself twice up a dark mountainside, the amplitude
 * of each hairpin shrinking with height the way a switchback road actually
 * reads from an elevated night vantage — tight and wide near the camera,
 * small and distant near the ridge. A pale moonlit haze sits over the top of
 * the frame instead of `RoadField`'s open black sky, and a scatter of tiny
 * warm points twinkles in the dark valley away from the road — distant
 * windows, not more traffic.
 *
 * The ribbon's centreline is one `roadCenter(y)` term whose amplitude tapers
 * with height and whose frequency is tuned for two full hairpin reversals
 * across the frame, rather than `ContraflowField`'s one gentle S-bend — the
 * sharper, repeated fold is what reads as a mountain switchback instead of a
 * motorway curve. Four lanes ride that centreline as offsets in its
 * normalised cross-position, same discipline as the rest of the family.
 *
 * One pointer interaction, carried over as a driving metaphor rather than a
 * new gesture:
 *  - steering — the pointer's x leans the whole zigzag sideways, more so
 *    near the camera than near the ridge, the way the near hairpins would
 *    swing more than the distant ones.
 *  - throttle — the pointer's y speeds the trail flicker the nearer it sits
 *    to the bottom edge, same contract as `RoadField`.
 * The same warm headlight bloom rides the cursor.
 *
 * One of the reusable background fields, a sibling of `RoadField` and
 * `ContraflowField` in the same trail family. Drop it into any
 * `position: relative`/`isolate` parent — it fills the box. Built on
 * `lib/shader-surface.ts`, so every degradation path is already handled: no
 * WebGL, a blocked or lost context, a hidden tab, or `prefers-reduced-motion`
 * all leave the CSS `.iris-switchbackfield__floor` underneath visible (a
 * still composed frame in the same palette, never a blank box).
 *
 * Fixed palette, not the token ramp — same reasoning as `RoadField`: the
 * moonlit ridge haze and the valley-window colour live nowhere in the accent
 * ramp, so the CSS floor carries the colour rather than a live token read.
 *
 * Reading guard: when `guardSelector` resolves to an element, the field
 * measures that block every frame and clamps its own luminance under a
 * ceiling inside that region (hue and saturation untouched), so copy laid
 * over the field holds WCAG AA with no overlay layer. `null` (the default)
 * turns the guard off — for decorative use where nothing sits on top.
 */

/* Palette, sRGB 0–1. Uniforms, not tokens — see the note above. */
const PALETTE: Record<string, [number, number, number]> = {
  u_black: [0.008, 0.009, 0.012], // the near-black mountainside, cooler than RoadField's
  u_moon: [0.5, 0.58, 0.74], // the pale cool moonlight haze over the ridge
  u_gold: [0.95, 0.74, 0.34], // the main trail colour along the ribbon
  u_amber: [0.99, 0.52, 0.18], // the hotter lane
  u_flare: [1.0, 0.96, 0.88], // the near-white core riding every lane
  u_valley: [0.92, 0.78, 0.45], // the tiny distant valley-window lights
};

const FRAG = `
uniform vec2  u_res;
uniform float u_time;
uniform float u_scale;
uniform vec3  u_pointer;   /* x, y (0 bottom .. 1 top), presence 0..1 */

uniform vec3 u_black;
uniform vec3 u_moon;
uniform vec3 u_gold;
uniform vec3 u_amber;
uniform vec3 u_flare;
uniform vec3 u_valley;

/* Reading region for the contrast guard: xy = centre (uv, y up),
   zw = half-extent. Measured from the live copy block every frame.
   u_guard is 0 when the guard is off. */
uniform vec4  u_readA;
uniform float u_guard;

/* A -1..1 triangle wave, same period as sin(x) — sharp V corners at each
   extreme rather than a rounded crest, which is what makes the centreline
   below read as a folded hairpin instead of ContraflowField's one smooth
   S-bend. */
float triangleWave(float x) {
  return asin(sin(x)) * (2.0 / 3.14159265);
}

/* The ribbon's centreline — amplitude tapers with height (near hairpins
   swing wide, distant ones shrink toward the ridge) and the frequency is
   tuned for two full reversals across the frame, the fold that reads as a
   switchback rather than one gentle curve. 'steer' leans it sideways, more
   near the camera than near the ridge. */
float roadCenter(float y, float steer) {
  float amp = mix(0.42, 0.09, smoothstep(0.0, 1.0, y));
  return 0.5 + amp * triangleWave(y * 10.5 + 0.6) + steer * mix(1.0, 0.35, y);
}

/* Half-width of the ribbon in P-space, narrowing hard with height so the
   distant hairpins read as genuinely far away. */
float roadHalf(float y) {
  return mix(0.11, 0.026, smoothstep(0.0, 1.0, y));
}

/* One lane within the ribbon. 'n' is the pixel's normalised cross position
   (-1..1); 'id' the lane's own centre in that space. 'along' is the travel
   coordinate up the ribbon, driving both the flicker and an fbm-based long
   swell so brightness reads as travelling light rather than a static glow. */
float lane(float n, float along, float id, float seed, float spd, out float core) {
  float sigma = 0.16;
  float dx = n - id;
  float body = exp(-dx * dx / (sigma * sigma));
  core = exp(-dx * dx / (sigma * sigma * 0.1));
  float flicker = 0.75 + 0.25 * sin(along * 0.9 - u_time * 1.3 * spd + seed * 5.0);
  float m = fbm(vec2(along * 0.4 + seed * 3.0, seed * 1.7));
  float bright = smoothstep(0.05, 0.65, m + 0.05);
  return body * flicker * bright;
}

void main() {
  vec2 res = u_res / u_scale;
  vec2 uv  = gl_FragCoord.xy / u_scale / res;            /* 0..1, y up */
  float aspect = res.x / res.y;
  vec2 P = (uv - 0.5) * vec2(aspect, 1.0);

  /* steering: the pointer's x leans the centreline sideways, more near the
     camera. throttle: the pointer's y speeds the lane flicker the nearer it
     sits to the bottom edge — same contract as RoadField. */
  float steer = (u_pointer.x - 0.5) * u_pointer.z * 0.22;
  float speed = 1.0 + u_pointer.z * (1.0 - u_pointer.y) * 2.0;

  float cx = roadCenter(uv.y, steer);
  float hw = roadHalf(uv.y);
  float n  = (P.x - cx) / hw;
  float inRoad = 1.0 - smoothstep(0.85, 1.35, abs(n));

  float wz = uv.y / max(1.0 - uv.y * 0.6, 0.15);
  float along = uv.y * 7.0 + wz * 1.0;

  float c0, c1, c2, c3;
  float b0 = lane(n, along, -0.55, 1.1, speed, c0);
  float b1 = lane(n, along, -0.18, 2.7, speed, c1);
  float b2 = lane(n, along,  0.18, 4.1, speed, c2);
  float b3 = lane(n, along,  0.55, 5.9, speed, c3);

  vec3 col = u_black;

  /* a pale moonlit haze settling over the top of the frame in place of
     RoadField's open black sky */
  float moonGlow = smoothstep(0.5, 1.0, uv.y);
  col += u_moon * moonGlow * 0.06;

  /* the ribbon itself */
  col += (u_gold * b0 * 1.3
        + mix(u_gold, u_amber, 0.4) * b1 * 1.4
        + mix(u_amber, u_gold, 0.4) * b2 * 1.4
        + u_amber * b3 * 1.3) * inRoad;
  col += u_flare * (c0 + c1 + c2 + c3) * 0.55 * inRoad;

  /* a faint warm glow bleeding off the ribbon into the dark mountainside —
     nothing here is pure black, the discipline the rest of the family keeps */
  float glow = exp(-pow(abs(n), 1.4) * 1.3);
  col += mix(u_gold, u_amber, 0.5) * glow * 0.05;

  /* a scatter of tiny valley-window lights — sparse, twinkling, kept away
     from the road and to the lower half where the valley floor would sit */
  vec2 gridUv = uv * vec2(38.0, 22.0);
  vec2 gid = floor(gridUv);
  vec2 gf = fract(gridUv) - 0.5;
  float h = dotHash(gid);
  float isLight = step(0.965, h);
  float twinkle = 0.5 + 0.5 * sin(u_time * 2.0 + h * 40.0);
  float dotGlow = exp(-dot(gf, gf) * 220.0);
  float valleyMask = (1.0 - inRoad) * smoothstep(0.7, 0.0, uv.y);
  col += u_valley * isLight * dotGlow * twinkle * valleyMask * 0.9;

  /* the touch: a warm headlight bloom with a near-white core, the same
     "your touch joined the light" move RoadField makes */
  vec2 ptr = (u_pointer.xy - 0.5) * vec2(aspect, 1.0);
  vec2 toPtr = P - ptr;
  float dPtr = dot(toPtr, toPtr);
  col += u_amber * u_pointer.z * exp(-dPtr * 9.0) * 0.5;
  col += u_flare * u_pointer.z * exp(-dPtr * 40.0) * 0.4;

  col = max(col, 0.0);

  /* ---- the reading guard (see TileField for the full rationale) ---- */
  vec2 rd = abs(uv - u_readA.xy) / max(u_readA.zw, vec2(0.02));
  float m = mix(max(rd.x, rd.y), length(rd), 0.4);
  float band = 1.0 - smoothstep(0.72, 2.1, m);
  col = mix(col, holdUnder(col, 0.09), band * u_guard);

  col += (bayer8(gl_FragCoord.xy) - 0.5) * (2.2 / 255.0);

  gl_FragColor = vec4(col, 1.0);
}
`;

export interface SwitchbackFieldProps {
  className?: string;
  /**
   * CSS selector for the block the reading guard should keep readable,
   * resolved against `document`. `null` (the default) turns the guard off.
   */
  guardSelector?: string | null;
}

export function SwitchbackField({ className, guardSelector = null }: SwitchbackFieldProps) {
  const canvasRef = useRef<HTMLCanvasElement>(null);

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;

    const guardOn = guardSelector != null;

    /* The block the reading guard protects. Looked up once, lazily. */
    let guardEl: Element | null | undefined;
    const readGuardEl = () => {
      if (guardEl === undefined) {
        guardEl = guardOn
          ? document.querySelector(guardSelector as string)
          : null;
      }
      return guardEl;
    };

    return mountShaderSurface(canvas, {
      fragment: FRAG,
      uniforms: [...Object.keys(PALETTE), "u_readA", "u_guard"],
      onInit: (gl, u) => {
        for (const name of Object.keys(PALETTE)) {
          if (u[name]) gl.uniform3fv(u[name], PALETTE[name]);
        }
        if (u.u_readA) gl.uniform4f(u.u_readA, 0.5, 0.5, 0.44, 0.32);
        if (u.u_guard) gl.uniform1f(u.u_guard, guardOn ? 1 : 0);
      },
      onFrame: (gl, u, s) => {
        if (!guardOn || !u.u_readA) return;
        let cx = 0.5, cy = 0.5, hw = 0.44, hh = 0.32;
        const el = readGuardEl();
        const { rect } = s;
        if (el && rect.width > 0 && rect.height > 0) {
          const r = el.getBoundingClientRect();
          const padX = rect.width * 0.09;
          const padY = rect.height * 0.11;
          cx = (r.left + r.width / 2 - rect.left) / rect.width;
          cy = 1 - (r.top + r.height / 2 - rect.top) / rect.height;
          hw = (r.width / 2 + padX) / rect.width;
          hh = (r.height / 2 + padY) / rect.height;
        }
        gl.uniform4f(u.u_readA, cx, cy, hw, hh);
      },
      onPainted: () => canvas.setAttribute("data-shader", "on"),
      /* No onIdle — same contract as RoadField / ContraflowField: once
         painted, the last frame stays on screen while the surface is parked
         off-view, so scrolling away and back does not crossfade to the
         static floor. A lost context still clears it below. */
      onLost: () => canvas.removeAttribute("data-shader"),
      maxPixels: 2_200_000,
      dprCap: 1.5,
    });
  }, [guardSelector]);

  return (
    <div
      className={`absolute inset-0 overflow-hidden${className ? ` ${className}` : ""}`}
      aria-hidden="true"
    >
      <div className="absolute inset-0 [background:radial-gradient(_60%_34%_at_50%_4%,oklch(0.5_0.05_250_/_0.55)_0%,transparent_75%_),radial-gradient(_26%_34%_at_34%_82%,oklch(0.93_0.07_80)_0%,transparent_14%_),radial-gradient(_36%_46%_at_34%_82%,oklch(0.78_0.15_60)_0%,transparent_34%_),radial-gradient(_14%_20%_at_63%_42%,oklch(0.86_0.09_70)_0%,transparent_20%_),radial-gradient(_22%_30%_at_63%_42%,oklch(0.66_0.16_50)_0%,transparent_42%_),radial-gradient(_100%_90%_at_45%_60%,oklch(0.14_0.03_230)_0%,transparent_80%_),oklch(0.02_0.008_230)] after:content-[''] after:absolute after:inset-0 after:[background-image:radial-gradient(_oklch(0.85_0.08_75_/_0.55)_6%,transparent_60%_)] after:[background-size:9%_7%] after:[background-position:0_0] after:[-webkit-mask-image:radial-gradient(_70%_60%_at_78%_78%,#000_0_40%,transparent_78%_)] after:[mask-image:radial-gradient(_70%_60%_at_78%_78%,#000_0_40%,transparent_78%_)]" />
      <canvas ref={canvasRef} className="absolute inset-0 w-full h-full opacity-0 transition-opacity duration-[--duration-slow] ease-[--ease-standard] data-[shader=on]:opacity-100" />
    </div>
  );
}

More backgrounds

Custom work

Need one that isn't in the catalogue?

Describe what you're after and I'll reply by email — no promise on turnaround yet, this is a new channel, not a service with a set price or queue.

Fardin Omor Afnan

Fardin Omor Afnan

Reads and answers every request himself

Or start with a section.

Whole page sections, composed and ready to drop in — take one and build the rest of the page around it.

Browse sections