docs / atmospheres / routa

Routa

ground frost. water freezes below the surface, lifts the clay into low cells, then breaks it along the walls between them. it is relief first and almost still: fissures propagate across the first half-minute, settle, and never loop or breathe.

intensity · roomrsc · client

provenance

authored in usva

layer

atmospheres

intensity

room · the whole environment. nothing competes with it

composition

wraps a whole region; content sits above the ground in normal flowsavi is its home: the fissures hold pigment there. dark grounds get the raking key insteadone atmosphere per page. it is the room, nothing layers over itnot an ambient pulse. it does not float above the page or breathe like fog

a11y

the canvas layer is aria-hidden and pointer-transparent · reduced motion paints the mature frame at twenty-seven seconds and stopsjest-axe

dependencies

ogl · atmospheres-core from the same package
live demo · try it out

cold from underneath

the fissures hold the dark at every frozen seam

customize
modeauto reads the ground, or force stain / key
speeddomain drift, barely perceptible
1
opacityoverall strength, 0 to 1
1
advanced18
cellScalefrost cells across the short side
3.8
heavehow high the cell interiors lift
0.075
crackWidthwidth of the fissure at each wall
0.055
crackDepthhow far the walls fall below the surface
0.045
unevenScalefrequency of the broad ground swell
0.72
unevenhow differently neighbours lift
0.34
driftdomain travel per second, near still
0.003
growthRateone-shot fissure propagation per second
0.028
slopeheight-gradient gain before the normal
1.35
roughOren-Nayar roughness, 0 to 1
0.92
ambientfill under the raking key
0.32
keystrength of the raking key
0.88
reliefdamp held in the lee of a heave, light grounds only
0.18
ditherdither amplitude for dark-ground emission
0.005
light · xlateral rake of the key across the ground
-0.68
light · yvertical rake of the key
0.46
light · zkeep low so the key stays grazing
0.38
light · coloroptional cold key for the dark ground
usagecopy
import { Routa } from "usva/atmospheres/routa";

<Routa>
  <Article />
</Routa>

props

proptypedefaultnotes
childrenReactNoderendered above the frozen ground, in normal flow.
speednumber1domain drift multiplier. barely perceptible either way.
mode"emissive" | "absorptive"forces the material. by default a light ground stains its fissures and a dark ground catches the raking key.
light{ direction?; color? }[-0.68, 0.46, 0.38]the low raking key. keep z low, the heave only reads when the key rakes across it. color only affects the dark-ground reading.
opacitynumber1overall opacity, 0 to 1.
paramsPartial<RoutaParams>the frost field: cellScale, heave, crackWidth, crackDepth, unevenScale, uneven, drift, growthRate, slope, rough, ambient, key, relief, and dither. relief is the damp a light ground holds in the lee of a heave, and does nothing on a dark one.

get it

npx shadcn add https://usva.build/r/routa.jsoncopy
source · components/ui/routa-field.tsexactly what this command copiescopy
import {
  type BlendMode,
  pigmentFor,
  type Rgb,
} from "./atmospheres-color";

export interface RoutaParams {
  /** Frost cells across one short-side unit. */
  cellScale: number;
  /** Height of the heaved cell interiors. */
  heave: number;
  /** Width of the fissure at each cell wall. */
  crackWidth: number;
  /** How far the cell walls fall below the heaved surface. */
  crackDepth: number;
  /** Spatial frequency of the broad unevenness under the frost. */
  unevenScale: number;
  /** How differently neighbouring cells lift. */
  uneven: number;
  /** Domain travel per second. Deliberately close to still. */
  drift: number;
  /** One-shot fissure propagation per second. */
  growthRate: number;
  /** Height-gradient gain before the normal is built. */
  slope: number;
  /** Oren-Nayar roughness. */
  rough: number;
  /** Ambient fill under the raking key. */
  ambient: number;
  /** Strength of the raking key. */
  key: number;
  /** How much damp the clay holds in the lee of a heave. Light grounds only. */
  relief: number;
  /** Dither amplitude for dark-ground emission. */
  dither: number;
}

export const ROUTA_DEFAULTS: RoutaParams = {
  cellScale: 3.8,
  heave: 0.075,
  crackWidth: 0.055,
  crackDepth: 0.045,
  unevenScale: 0.72,
  uneven: 0.34,
  drift: 0.0025,
  growthRate: 0.028,
  slope: 1.35,
  rough: 0.92,
  ambient: 0.32,
  key: 0.88,
  relief: 0.18,
  dither: 0.005,
};

export function resolveParams(
  overrides: Partial<RoutaParams> = {},
): RoutaParams {
  const merged = { ...ROUTA_DEFAULTS, ...overrides };
  return {
    ...merged,
    cellScale: Math.max(merged.cellScale, 0.5),
    heave: Math.max(merged.heave, 0),
    crackWidth: Math.min(Math.max(merged.crackWidth, 0.005), 0.2),
    crackDepth: Math.max(merged.crackDepth, 0),
    uneven: Math.min(Math.max(merged.uneven, 0), 0.8),
    // Past about a third the damp stops reading as a lit surface and starts
    // competing with the fissures, which are the subject on clay.
    relief: Math.min(Math.max(merged.relief, 0), 0.35),
    drift: Math.max(merged.drift, 0),
    growthRate: Math.max(merged.growthRate, 0),
    rough: Math.min(Math.max(merged.rough, 0), 1),
  };
}

export const ROUTA_ROLES = ["bg", "ink", "accent", "accent-alt"] as const;

export type RoutaRole = (typeof ROUTA_ROLES)[number];

export interface RoutaColors {
  /** The dark pigment held in the fissures on clay. */
  pigment: Rgb;
  /** The cold raking key caught by heaved crests on a dark ground. */
  emission: Rgb;
  /** The frozen surface itself on a dark ground. */
  body: Rgb;
  /** Under the surface: the fissures, and ground the key never reaches. */
  fissure: Rgb;
}

function mix(a: Rgb, b: Rgb, t: number): Rgb {
  return [
    a[0] + (b[0] - a[0]) * t,
    a[1] + (b[1] - a[1]) * t,
    a[2] + (b[2] - a[2]) * t,
  ];
}

/**
 * The theme swap changes the material, not the shader, the way kynnös already
 * does it. On clay the fissures hold pigment and the frost is what is left of
 * the ground. On a dark ground it is black ice: a surface that is always there,
 * a grazing key that only the heaved crests catch, and fissures cut below it.
 *
 * The body is the whole point. Without one the shader painted crests and left
 * everything between them transparent, so the relief had nothing to sit on and
 * read as lumps of glow rather than frozen earth.
 */
export function buildColors(
  roles: Record<RoutaRole, Rgb>,
  mode: BlendMode,
  keyColor?: Rgb,
): RoutaColors {
  const hue = mode === "absorptive" ? roles.accent : roles["accent-alt"];
  return {
    pigment: pigmentFor(hue, roles.ink),
    emission: keyColor ?? mix(roles.accent, roles.ink, 0.55),
    body: mix(roles.bg, roles.ink, 0.09),
    fissure: mix(roles.bg, [0, 0, 0], 0.55),
  };
}

/** A low raking key, cold and long across the broken ground. */
export const DEFAULT_LIGHT: [number, number, number] = [-0.68, 0.46, 0.38];
source · components/ui/routa-shader.tsexactly what this command copiescopy
import { glsl } from "./atmospheres-glsl";

export const routaVertexShader = /* glsl */ `#version 300 es
in vec2 position;
void main() {
  gl_Position = vec4(position, 0.0, 1.0);
}
`;

export const routaFragmentShader = /* glsl */ `#version 300 es
precision highp float;

uniform float uTime;
uniform vec2  uResolution;
uniform float uCellScale;
uniform float uHeave;
uniform float uCrackWidth;
uniform float uCrackDepth;
uniform float uUnevenScale;
uniform float uUneven;
uniform float uDrift;
uniform float uGrowthRate;
uniform float uSlope;
uniform float uRough;
uniform float uAmbient;
uniform float uKey;
uniform float uRelief;
uniform float uDither;
uniform float uAlpha;
uniform float uAbsorb;
uniform vec3  uLightDir;
uniform vec3  uPigment;
uniform vec3  uEmission;
uniform vec3  uBody;
uniform vec3  uFissure;
uniform float uStainFloor;

out vec4 fragColor;

${glsl("fbm", "worley", "dither", "stain", "composite")}

const vec3 VIEW = vec3(0.0, 0.0, 1.0);
const float SIGMA = 1.12;
const float SOAK = 0.52;
/** How far in from a fissure the plate has finished rising, in wall units. */
const float PLATE_EDGE = 0.2;

vec2 frostAt(vec2 p) {
  vec2 travel = vec2(uTime * uDrift, -uTime * uDrift * 0.37);
  vec2 q = p + travel;
  float wall = craquelure(q * uCellScale);
  float fissure = 1.0 - smoothstep(0.0, uCrackWidth, wall);
  float arrival = fbm2(
    q * uCellScale * 0.55 + vec2(7.3, 1.9),
    2
  ) / 0.75;
  float progress = clamp(0.1 + uTime * uGrowthRate, 0.0, 1.0);
  fissure *= smoothstep(arrival - 0.08, arrival + 0.08, progress);
  /* A heaved plate is flat, and flatness is also what makes it drawable.
     craquelure is F2 - F1, whose slope kinks along the medial axis of every
     cell, where the second-nearest cell changes. The normal is built by
     differencing the height, so a slope kink becomes a hard seam straight
     across each plate. Doming the interior put a gradient there for the kink
     to bend; a flat interior gives it nothing, and the shape lands where it
     belongs, on the shoulder falling into the seam. */
  float dome = smoothstep(uCrackWidth, uCrackWidth + PLATE_EDGE, wall);
  dome = dome * dome * (3.0 - 2.0 * dome);
  float broad = fbm2(q * uUnevenScale + vec2(13.1, -7.4), 3) / 0.875;
  float lift = dome * mix(1.0 - uUneven, 1.0 + uUneven, broad);
  return vec2(uHeave * lift - uCrackDepth * fissure, fissure);
}

float heightAt(vec2 p) {
  return frostAt(p).x;
}

float orenNayar(vec3 n, vec3 l, float rough) {
  float s2 = rough * rough;
  float a = 1.0 - 0.5 * s2 / (s2 + 0.33);
  float b = 0.45 * s2 / (s2 + 0.09);
  float ndl = dot(n, l);
  float ndv = dot(n, VIEW);
  float thetaL = acos(clamp(ndl, -1.0, 1.0));
  float thetaV = acos(clamp(ndv, -1.0, 1.0));
  float alpha = max(thetaL, thetaV);
  float beta = min(thetaL, thetaV);
  vec3 lp = l - n * ndl;
  vec3 vp = VIEW - n * ndv;
  float cosPhi = 0.0;
  if (length(lp) > 1e-4 && length(vp) > 1e-4) {
    cosPhi = max(dot(normalize(lp), normalize(vp)), 0.0);
  }
  return max(ndl, 0.0) * (a + b * cosPhi * sin(alpha) * tan(min(beta, 1.5)));
}

void main() {
  float unit = max(min(uResolution.x, uResolution.y), 1.0);
  vec2 p = (gl_FragCoord.xy - 0.5 * uResolution) / unit;
  vec2 field = frostAt(p);
  float height = field.x;

  float eps = 1.5 / unit;
  float heightX = heightAt(p + vec2(eps, 0.0));
  float heightY = heightAt(p + vec2(0.0, eps));
  vec3 normal = normalize(vec3(
    (height - heightX) * uSlope,
    (height - heightY) * uSlope,
    eps * 3.0
  ));

  vec3 light = normalize(uLightDir);
  float diffuse = orenNayar(normal, light, uRough);
  float lit = clamp(uAmbient + uKey * diffuse, 0.0, 1.2);

  // Black ice. The ground is opaque and always painted, and the key only picks
  // out the crests: that surface is what separates frozen earth from a handful
  // of lit lumps floating on whatever happens to be behind the canvas.
  if (uAbsorb < 0.5) {
    float crest = smoothstep(0.0, uHeave, max(height, 0.0));
    float rake = smoothstep(0.36, 1.0, lit);
    vec3 ground = mix(uFissure, uBody, smoothstep(0.18, 0.72, lit));
    vec3 col = mix(ground, uEmission, crest * rake * 0.85);
    col = mix(col, uFissure, field.y * 0.9);
    col = dither(col, gl_FragCoord.xy, uDither);
    fragColor = composite(col, uAlpha);
    return;
  }

  /* Clay carries the heave as damp, never as a darker multiply: the multiply
     composite is inert under isolate, so a light ground can only show relief by
     holding more pigment. Wet in the lee of a plate, dry where the key lands.
     Kept out of the fissures so it never doubles with the pigment already
     there, and an order under them so the seams stay the subject. */
  float relief = smoothstep(0.62, 0.18, lit) * (1.0 - field.y) * uRelief;
  float fissure = field.y * mix(1.0, 0.68, diffuse);
  vec3 absorbed = hold(uPigment, uStainFloor);
  float alpha = clamp(soak(fissure * SOAK + relief, SIGMA) * uAlpha, 0.0, 1.0);
  fragColor = vec4(absorbed * alpha, alpha);
}
`;
source · components/ui/routa-uniforms.tsexactly what this command copiescopy
import { MAX_STAIN } from "./atmospheres-color";
import {
  setUniform,
  type Uniforms,
} from "./atmospheres-gl";
import type { RoutaColors, RoutaParams } from "./routa-field";

export interface RoutaFrame {
  time: number;
  alpha: number;
  absorb: number;
  light: [number, number, number];
}

export function routaUniforms(
  colors: RoutaColors,
  params: RoutaParams,
  light: [number, number, number],
): Uniforms {
  return {
    uTime: { value: 0 },
    uResolution: { value: [1, 1] },
    uCellScale: { value: params.cellScale },
    uHeave: { value: params.heave },
    uCrackWidth: { value: params.crackWidth },
    uCrackDepth: { value: params.crackDepth },
    uUnevenScale: { value: params.unevenScale },
    uUneven: { value: params.uneven },
    uDrift: { value: params.drift },
    uGrowthRate: { value: params.growthRate },
    uSlope: { value: params.slope },
    uRough: { value: params.rough },
    uAmbient: { value: params.ambient },
    uKey: { value: params.key },
    uRelief: { value: params.relief },
    uDither: { value: params.dither },
    uAlpha: { value: 1 },
    uAbsorb: { value: 1 },
    uLightDir: { value: [...light] },
    uPigment: { value: [...colors.pigment] },
    uEmission: { value: [...colors.emission] },
    uBody: { value: [...colors.body] },
    uFissure: { value: [...colors.fissure] },
    uStainFloor: { value: MAX_STAIN },
  };
}

export function setRoutaColors(u: Uniforms, colors: RoutaColors): void {
  setUniform(u, "uPigment", [...colors.pigment]);
  setUniform(u, "uEmission", [...colors.emission]);
  setUniform(u, "uBody", [...colors.body]);
  setUniform(u, "uFissure", [...colors.fissure]);
}

export function setRoutaParams(u: Uniforms, params: RoutaParams): void {
  setUniform(u, "uCellScale", params.cellScale);
  setUniform(u, "uHeave", params.heave);
  setUniform(u, "uCrackWidth", params.crackWidth);
  setUniform(u, "uCrackDepth", params.crackDepth);
  setUniform(u, "uUnevenScale", params.unevenScale);
  setUniform(u, "uUneven", params.uneven);
  setUniform(u, "uDrift", params.drift);
  setUniform(u, "uGrowthRate", params.growthRate);
  setUniform(u, "uSlope", params.slope);
  setUniform(u, "uRough", params.rough);
  setUniform(u, "uAmbient", params.ambient);
  setUniform(u, "uKey", params.key);
  setUniform(u, "uRelief", params.relief);
  setUniform(u, "uDither", params.dither);
}

export function setRoutaFrame(u: Uniforms, frame: RoutaFrame): void {
  setUniform(u, "uTime", frame.time);
  setUniform(u, "uAlpha", frame.alpha);
  setUniform(u, "uAbsorb", frame.absorb);
  setUniform(u, "uLightDir", [...frame.light]);
}
source · components/ui/routa.tsxexactly what this command copiescopy
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import {
  type BlendMode,
  blendStyleFor,
  blendUniform,
  resolveBlendMode,
  resolveColor,
} from "./atmospheres-color";
import { useGlCanvas } from "./use-gl-canvas";
import { useTokenColors } from "./use-token-colors";
import {
  buildColors,
  DEFAULT_LIGHT,
  ROUTA_ROLES,
  type RoutaParams,
  resolveParams,
} from "./routa-field";
import { routaFragmentShader } from "./routa-shader";
import {
  routaUniforms,
  setRoutaColors,
  setRoutaFrame,
  setRoutaParams,
} from "./routa-uniforms";

export interface RoutaLight {
  /** Keep z low: the heave only reads when the key rakes across it. */
  direction?: [number, number, number];
  /** Optional CSS colour for the dark-ground key. */
  color?: string;
}

export interface RoutaProps extends React.HTMLAttributes<HTMLDivElement> {
  /** Domain drift multiplier. Defaults to 1 and remains barely perceptible. */
  speed?: number;
  /** Force the material. By default the ground chooses frost-light or stain. */
  mode?: BlendMode;
  /** Raking light direction and optional dark-ground colour. */
  light?: RoutaLight;
  /** Overall opacity, 0..1. Defaults to 1. */
  opacity?: number;
  /** Escape hatch for frost cell, fissure, and relief parameters. */
  params?: Partial<RoutaParams>;
  children?: React.ReactNode;
}

const STILL_TIME = 27;

export const Routa = React.forwardRef<HTMLDivElement, RoutaProps>(
  (
    {
      speed = 1,
      mode,
      light,
      opacity = 1,
      params,
      className,
      children,
      ...props
    },
    forwardedRef,
  ) => {
    const lightColor = light?.color;
    const direction = light?.direction ?? DEFAULT_LIGHT;

    const speedRef = React.useRef(speed);
    speedRef.current = speed;
    const opacityRef = React.useRef(opacity);
    opacityRef.current = opacity;
    const lightRef = React.useRef(direction);
    lightRef.current = direction;

    const scopeRef = React.useRef<HTMLDivElement | null>(null);
    const tokens = useTokenColors(ROUTA_ROLES, { scopeRef });
    const blend = resolveBlendMode(mode, tokens.bg);

    const colors = React.useMemo(
      () =>
        buildColors(
          tokens.colors,
          blend,
          lightColor ? resolveColor(lightColor) : undefined,
        ),
      [tokens.colors, blend, lightColor],
    );
    const colorsRef = React.useRef(colors);
    colorsRef.current = colors;

    const resolved = resolveParams(params);
    const paramsRef = React.useRef(resolved);
    paramsRef.current = resolved;

    const blendRef = React.useRef(blend);
    blendRef.current = blend;

    const canvas = useGlCanvas({
      fragment: routaFragmentShader,
      stillTime: STILL_TIME,
      maxDpr: 1.5,
      uniforms: () =>
        routaUniforms(colorsRef.current, paramsRef.current, lightRef.current),
      onFrame: (u, frame) => {
        setRoutaColors(u, colorsRef.current);
        setRoutaParams(u, paramsRef.current);
        setRoutaFrame(u, {
          time: frame.time * speedRef.current,
          alpha: opacityRef.current,
          absorb: blendUniform(blendRef.current),
          light: lightRef.current,
        });
      },
    });

    const { redraw } = canvas;
    // biome-ignore lint/correctness/useExhaustiveDependencies: the still frame must repaint when the material, key, or ground changes.
    React.useEffect(() => {
      redraw();
    }, [redraw, colors, direction, blend]);

    const on = canvas.active;

    return (
      <div
        ref={(node) => {
          canvas.containerRef.current = node;
          scopeRef.current = node;
          if (typeof forwardedRef === "function") forwardedRef(node);
          else if (forwardedRef) forwardedRef.current = node;
        }}
        data-blend={blend}
        className={cn("relative isolate overflow-hidden", className)}
        {...props}
      >
        {on ? (
          <div
            aria-hidden="true"
            className="pointer-events-none absolute inset-0 -z-10"
          >
            <canvas
              ref={canvas.canvasRef}
              className="block h-full w-full"
              style={blendStyleFor(blend)}
            />
          </div>
        ) : null}
        {children}
      </div>
    );
  },
);
Routa.displayName = "Routa";