docs / atmospheres / vare

Väre

a few broad wavefronts cross the frame from off to the right and interfere. on a dark ground the crests glow; on savi the same ripples press into clay, troughs holding a warm damp pigment. it needs its irregularity: tune that out and the bands snap into a striped diagram.

intensity · roomrsc · client

provenance

authored in usva

layer

atmospheres

intensity

room · the whole environment. nothing competes with it

composition

a hero or full-page room, its content as children in normal flowdark and light themes both work; the blend resolves itself per groundnever behind dense reading surfaces or another atmospherenot a card texture. it is a room, one per page

a11y

the canvas layer is aria-hidden and pointer-events-none · reduced motion paints one still frame · without WebGL2 the canvas never mountsjest-axe

dependencies

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

bands, not waves

move the pointer and the fronts warp as the lens passes

customize
speedwave rate multiplier
1
opacityband strength, 0 to 1
1
modeauto reads the ground, or force emit / stain
interactivea phase-space lens bends the bands toward the pointer
bodythe front of the ramp, reads accent
deepthe back of the ramp
edgethe bright nodes where fronts cross
advanced17
angleshared travel direction of the fronts, radians
3.64
spreadhalf-width of the k-vector fan, radians
0.62
wavenumberbase band frequency, each front scatters around it
3.8
field speedintrinsic travel rate of the fronts
0.9
warpamplitude of the domain warp that bends the field
0.7
warpScalespatial scale of that warp
0.55
jitterper-front phase drift, keeps fronts from lining up
0.7
softridge exponent, higher is a narrower boundary
6.5
detailfrequency of the band-thickness noise
1.6
nodeextra light where two fronts cross
0.9
gainoverall brightness of the ramp
1.15
source · xoff-frame origin x, half-height units by aspect
1.15
source · yoff-frame origin y, half-height units
0.8
falloffinverse-square decay, higher voids sooner
0.09
spandistance over which the hue ramps front to back
4
lensstrength of the pointer lens in phase space
2.2
lensSigmareach of that lens
0.9
usagecopy
import { Vare } from "usva/atmospheres/vare";

<Vare
  speed={1}
>
  <Hero />
</Vare>

props

proptypedefaultnotes
childrenReactNoderendered above the bands, in normal flow.
speednumber1wave rate multiplier.
interactivebooleantruea lens in phase space follows the eased cursor and bends the bands as it passes.
opacitynumber1overall opacity of the bands, 0 to 1.
mode"emissive" | "absorptive"forces the blend. by default a dark ground emits and a light ground stains, which is the only way this survives a light theme.
colors{ body?; deep?; edge? }on dark ground, the front-to-back ramp. on savi, body combines with theme ink to make the trough pigment. omitted stops read their token.
paramsPartial<VareParams>the field: angle, spread, wavenumber, speed, warp, warpScale, jitter, soft, detail, node, gain, source, falloff, span, lens, lensSigma.

get it

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

/**
 * vare (väre, a ripple) emits from the iso-phase lines of a few broad travelling
 * wavefronts, never from the wave bodies. What you see is a set of drifting
 * luminous boundaries: the folded edges of a curtain, seen from very far away.
 *
 * The failure mode is moire. Every defence against it is a parameter here: the
 * domain warp, the per-front phase jitter, the wavenumber scatter and the
 * thickness noise. Turn them to zero and the interference snaps into a diagram.
 */
export interface VareParams {
  /** Shared travel direction of the front, in radians. */
  angle: number;
  /** Half-width of the k-vector fan around that direction, in radians. */
  spread: number;
  /** Base wavenumber. Each front scatters around it, so no two bands match. */
  wavenumber: number;
  speed: number;
  /** Amplitude of the FBM domain warp that bends the whole field. */
  warp: number;
  warpScale: number;
  /** Per-front phase drift, in band periods. This is what stops two fronts from
   * lining up, since the shared warp bends them all the same way. */
  jitter: number;
  /** Exponent of the cosine ridge. Higher is a narrower boundary; low melts
   * the bands into one another. */
  soft: number;
  /** Frequency of the noise that varies band thickness along a front. */
  detail: number;
  /** Extra light where two fronts cross. */
  node: number;
  gain: number;
  /** Off-frame origin of the fronts, in half-height units, x scaled by aspect. */
  source: [number, number];
  /** Inverse-square arrival from the source. Higher decays to void sooner. */
  falloff: number;
  /** Distance over which the hue completes its front-to-back ramp. */
  span: number;
  /** Strength of the pointer lens in phase space. */
  lens: number;
  lensSigma: number;
}

export const VARE_DEFAULTS: VareParams = {
  angle: 3.64,
  spread: 0.62,
  wavenumber: 3.8,
  speed: 0.9,
  warp: 0.7,
  warpScale: 0.55,
  jitter: 0.7,
  soft: 6.5,
  detail: 1.6,
  node: 0.9,
  gain: 1.15,
  source: [1.15, 0.8],
  falloff: 0.09,
  span: 4,
  lens: 2.2,
  lensSigma: 0.9,
};

export interface VareColors {
  /** The body of the front. */
  body: Rgb;
  /** The oldest, furthest bands. */
  deep: Rgb;
  /** The freshest bands, closest to the source. */
  edge: Rgb;
  /** kosteus: the hue the clay takes where the trough holds water. */
  pigment: Rgb;
}

export const POINTER_EASE = 0.06;

export function approach(
  current: number,
  target: number,
  ease: number,
): number {
  return current + (target - current) * ease;
}

export function resolveParams(overrides?: Partial<VareParams>): VareParams {
  return { ...VARE_DEFAULTS, ...overrides };
}
source · components/ui/vare-shader.tsexactly what this command copiescopy
import { glsl } from "./atmospheres-glsl";

const WAVES = 4;

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

uniform float uTime;
uniform vec2  uResolution;
uniform vec2  uMouse;
uniform float uPointer;
uniform float uAngle;
uniform float uSpread;
uniform float uWavenumber;
uniform float uSpeed;
uniform float uWarp;
uniform float uWarpScale;
uniform float uJitter;
uniform float uSoft;
uniform float uDetail;
uniform float uNode;
uniform float uGain;
uniform vec2  uSource;
uniform float uFalloff;
uniform float uSpan;
uniform float uLens;
uniform float uLensSigma;
uniform float uAlpha;
uniform float uAbsorb;
uniform vec3  uBody;
uniform vec3  uDeep;
uniform vec3  uEdgeColor;
uniform vec3  uPigment;
uniform float uStainFloor;

out vec4 fragColor;

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

const float TAU = 6.28318530718;

/** The raking key. Low and from the left, so a ridge one pixel high still casts. */
const vec3 KEY = vec3(-0.62, -0.44, 0.36);

/** How hard the clay drinks. Tuned so a node is damp, not a bruise. */
const float SIGMA = 1.15;

/** The wettest a ripple ever gets. Above this the ground stops being ground. */
const float SOAK = 0.45;

float phaseLens(vec2 p) {
  vec2 toMouse = p - uMouse;
  return uPointer * uLens
    * exp(-dot(toMouse, toMouse) / (uLensSigma * uLensSigma));
}

vec2 waves(vec2 p, vec2 source) {
  float lens = phaseLens(p);
  float bands = 0.0;
  float squares = 0.0;
  float basins = 0.0;
  float basinSquares = 0.0;

  for (int i = 0; i < ${WAVES}; i++) {
    float fi = float(i);
    float angle = uAngle + (hash11(fi * 3.17 + 0.5) - 0.5) * uSpread;
    float k = uWavenumber * (0.7 + 1.1 * hash11(fi * 7.31 + 1.7));
    float w = uSpeed * (0.6 + 0.8 * hash11(fi * 11.7 + 4.3));

    vec2 dir = vec2(cos(angle), sin(angle));

    // The shared domain warp bends every front identically, so on its own it
    // cannot stop two fronts beating against each other. This one is per front.
    float drift = fbm2(
      p * uWarpScale * 1.7 + vec2(fi * 21.7, uTime * 0.018), 2
    ) - 0.5;

    vec2 bendAxis = normalize(vec2(
      cos(angle + 1.5708 + fi * 0.31),
      sin(angle + 1.5708 + fi * 0.31)
    ));
    float bend = (fbm2(
      p * (uWarpScale * (0.7 + 0.18 * fi)) + vec2(fi * 7.1),
      2
    ) - 0.375) * (0.7 + 0.22 * fi);

    float phase = dot(p - source + bendAxis * bend, dir) * k - w * uTime
      + hash11(fi * 5.13 + 2.9) * TAU + drift * uJitter * TAU + lens;

    float thickness = 0.5 + 0.5
      * fbm2(p * uDetail + vec2(fi * 13.0, uTime * 0.02), 2);
    float weight = fi < 2.0 ? 1.0 : mix(0.4, 0.6, hash11(fi + 8.4));
    // A soft cosine ridge, not a hairline: the boundary glows and falls off,
    // and a faint square-law fill keeps the space between from reading as a
    // diagram on black.
    float wave = 0.5 + 0.5 * cos(phase);
    float exponent = uSoft * (0.8 + 0.4 * hash11(fi * 2.3 + 0.7));
    float crest = pow(wave, exponent);
    float trough = pow(1.0 - wave, exponent);
    float band = (crest + 0.1 * wave * wave) * thickness * weight;
    float basinBand = trough * thickness * weight;

    bands += band;
    squares += band * band;
    basins += basinBand;
    basinSquares += basinBand * basinBand;
  }

  float overlap = max(0.5 * (bands * bands - squares), 0.0);
  float nodes = smoothstep(0.3, 1.0, overlap);
  nodes *= nodes;
  float emission = bands + nodes * uNode;

  float basinOverlap = max(0.5 * (basins * basins - basinSquares), 0.0);
  float basinNodes = smoothstep(0.3, 1.0, basinOverlap);
  basinNodes *= basinNodes;
  float basin = basins + basinNodes * uNode;

  return vec2(emission, basin);
}

void main() {
  float aspect = uResolution.x / max(uResolution.y, 1.0);
  vec2 ndc = (2.0 * gl_FragCoord.xy - uResolution) / uResolution.y;

  vec2 warp = vec2(
    fbm2(ndc * uWarpScale + vec2(uTime * 0.03, 0.0), 2),
    fbm2(ndc * uWarpScale + vec2(9.7, uTime * 0.024), 2)
  ) - 0.5;
  vec2 p = ndc + warp * uWarp;

  vec2 source = vec2(uSource.x * aspect, uSource.y);
  vec2 travel = vec2(cos(uAngle), sin(uAngle));

  vec2 field = waves(p, source);
  float emission = field.x;

  float depth = clamp(dot(p - source, travel) / uSpan, 0.0, 1.0);
  vec3 hue = mix(uBody, uDeep, depth);
  hue = mix(uEdgeColor, hue, smoothstep(0.0, 0.3, depth));

  float reach = length(p - source);
  float arrival = 1.0 / (1.0 + uFalloff * reach * reach);

  vec3 col = hue * emission * arrival * uGain;

  // Tonemapped on luminance, not per channel, so a bright node saturates
  // toward the accent instead of washing to white. sisu must not bloom.
  float lum = max(col.r, max(col.g, col.b));
  float peak = 1.0 - exp(-lum);
  vec3 tint = col / max(lum, 1e-4);

  vec3 emissive = dither(tint, gl_FragCoord.xy, 0.006);
  float emissiveAlpha = peak * uAlpha;

  if (uAbsorb < 0.5) {
    float alpha = ditherAlpha(emissiveAlpha, gl_FragCoord.xy, 0.004);
    fragColor = composite(emissive, alpha);
    return;
  }

  float eps = 2.0 / max(uResolution.y, 1.0);
  float heightX = waves(p + vec2(eps, 0.0), source).x;
  float heightY = waves(p + vec2(0.0, eps), source).x;
  vec3 normal = normalize(vec3(
    emission - heightX,
    emission - heightY,
    eps * 5.0
  ));
  float key = clamp(dot(normal, normalize(KEY)), 0.0, 1.0);
  float amount = clamp(field.y * arrival * uGain, 0.0, 1.0);
  float wet = amount * SOAK * (1.0 - 0.62 * key);

  vec3 absorbed = hold(uPigment, uStainFloor);
  float alpha = clamp(soak(wet, SIGMA) * uAlpha, 0.0, 1.0);
  fragColor = vec4(absorbed * alpha, alpha);
}
`;
source · components/ui/vare-uniforms.tsexactly what this command copiescopy
import { MAX_STAIN } from "./atmospheres-color";
import {
  setUniform,
  type Uniforms,
} from "./atmospheres-gl";
import type { VareColors, VareParams } from "./vare-field";

export interface VareFrame {
  time: number;
  /** Eased pointer in half-height screen units, y up. */
  mouse: [number, number];
  /** Fades the phase lens in and out, 0..1. */
  pointer: number;
  alpha: number;
  /** The house law: 1 stains the ground, 0 emits into it. */
  absorb: number;
}

export function vareUniforms(colors: VareColors, params: VareParams): Uniforms {
  return {
    uTime: { value: 0 },
    uResolution: { value: [1, 1] },
    uMouse: { value: [0, 0] },
    uPointer: { value: 0 },
    uAngle: { value: params.angle },
    uSpread: { value: params.spread },
    uWavenumber: { value: params.wavenumber },
    uSpeed: { value: params.speed },
    uWarp: { value: params.warp },
    uWarpScale: { value: params.warpScale },
    uJitter: { value: params.jitter },
    uSoft: { value: params.soft },
    uDetail: { value: params.detail },
    uNode: { value: params.node },
    uGain: { value: params.gain },
    uSource: { value: [...params.source] },
    uFalloff: { value: params.falloff },
    uSpan: { value: params.span },
    uLens: { value: params.lens },
    uLensSigma: { value: params.lensSigma },
    uAlpha: { value: 1 },
    uAbsorb: { value: 0 },
    uBody: { value: [...colors.body] },
    uDeep: { value: [...colors.deep] },
    uEdgeColor: { value: [...colors.edge] },
    uPigment: { value: [...colors.pigment] },
    uStainFloor: { value: MAX_STAIN },
  };
}

export function setVareColors(u: Uniforms, colors: VareColors): void {
  setUniform(u, "uBody", [...colors.body]);
  setUniform(u, "uDeep", [...colors.deep]);
  setUniform(u, "uEdgeColor", [...colors.edge]);
  setUniform(u, "uPigment", [...colors.pigment]);
}

export function setVareParams(u: Uniforms, params: VareParams): void {
  setUniform(u, "uAngle", params.angle);
  setUniform(u, "uSpread", params.spread);
  setUniform(u, "uWavenumber", params.wavenumber);
  setUniform(u, "uSpeed", params.speed);
  setUniform(u, "uWarp", params.warp);
  setUniform(u, "uWarpScale", params.warpScale);
  setUniform(u, "uJitter", params.jitter);
  setUniform(u, "uSoft", params.soft);
  setUniform(u, "uDetail", params.detail);
  setUniform(u, "uNode", params.node);
  setUniform(u, "uGain", params.gain);
  setUniform(u, "uSource", [...params.source]);
  setUniform(u, "uFalloff", params.falloff);
  setUniform(u, "uSpan", params.span);
  setUniform(u, "uLens", params.lens);
  setUniform(u, "uLensSigma", params.lensSigma);
}

export function setVareFrame(u: Uniforms, frame: VareFrame): void {
  setUniform(u, "uTime", frame.time);
  setUniform(u, "uMouse", [...frame.mouse]);
  setUniform(u, "uPointer", frame.pointer);
  setUniform(u, "uAlpha", frame.alpha);
  setUniform(u, "uAbsorb", frame.absorb);
}
source · components/ui/vare.tsxexactly what this command copiescopy
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import {
  type BlendMode,
  blendStyleFor,
  blendUniform,
  pigmentFor,
  type Rgb,
  resolveBlendMode,
  resolveColor,
} from "./atmospheres-color";
import { useGlCanvas } from "./use-gl-canvas";
import {
  useThemeVersion,
  useTokenColors,
} from "./use-token-colors";
import {
  approach,
  POINTER_EASE,
  resolveParams,
  type VareColors,
  type VareParams,
} from "./vare-field";
import { vareFragmentShader } from "./vare-shader";
import {
  setVareColors,
  setVareFrame,
  setVareParams,
  vareUniforms,
} from "./vare-uniforms";

const ROLES = ["accent", "accent-2", "accent-alt", "ink"] as const;

/** Far enough in that the fronts have separated in the still frame. */
const STILL_TIME = 9;

export interface VareProps extends React.HTMLAttributes<HTMLDivElement> {
  /** Wave rate multiplier. Defaults to 1. */
  speed?: number;
  /** When on, a phase lens follows the eased cursor and bends the bands.
   * Defaults to true. */
  interactive?: boolean;
  /** Overall opacity of the bands, 0..1. Defaults to 1. */
  opacity?: number;
  /** Force the blend. Defaults to emissive on a dark ground, absorptive on a
   * light one, which is the only way this survives a light theme. */
  mode?: BlendMode;
  /** Override any hue stop with a CSS colour. Omitted stops read their token. */
  colors?: { body?: string; deep?: string; edge?: string };
  /** Escape hatch for the field parameters, for tuning demos. */
  params?: Partial<VareParams>;
  children?: React.ReactNode;
}

export const Vare = React.forwardRef<HTMLDivElement, VareProps>(
  (
    {
      speed = 1,
      interactive = true,
      opacity = 1,
      mode,
      colors,
      params,
      className,
      children,
      ...props
    },
    forwardedRef,
  ) => {
    const cBody = colors?.body;
    const cDeep = colors?.deep;
    const cEdge = colors?.edge;

    const speedRef = React.useRef(speed);
    speedRef.current = speed;
    const interactiveRef = React.useRef(interactive);
    interactiveRef.current = interactive;
    const opacityRef = React.useRef(opacity);
    opacityRef.current = opacity;

    const paramsRef = React.useRef<VareParams>(resolveParams(params));
    paramsRef.current = resolveParams(params);

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

    // biome-ignore lint/correctness/useExhaustiveDependencies: a theme swap re-resolves the same colour strings to new channels.
    const ramp = React.useMemo<VareColors>(() => {
      const stop = (value: string | undefined, fallback: Rgb): Rgb =>
        value ? resolveColor(value) : fallback;
      const body = stop(cBody, tokens.colors.accent);
      return {
        body,
        deep: stop(cDeep, tokens.colors["accent-2"]),
        edge: stop(cEdge, tokens.colors["accent-alt"]),
        pigment: pigmentFor(body, tokens.colors.ink),
      };
    }, [cBody, cDeep, cEdge, tokens, themeVersion]);

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

    const mouse = React.useRef<[number, number]>([0, 0]);

    const canvas = useGlCanvas({
      fragment: vareFragmentShader,
      uniforms: () => vareUniforms(rampRef.current, paramsRef.current),
      pointer: true,
      pointerEase: POINTER_EASE,
      stillTime: STILL_TIME,
      maxDpr: 1.5,
      renderScale: 0.8,
      onFrame: (u, frame) => {
        const half = Math.max(frame.height, 1) / 2;
        if (interactiveRef.current) {
          mouse.current[0] = approach(
            mouse.current[0],
            frame.pointer.x / half,
            POINTER_EASE,
          );
          mouse.current[1] = approach(
            mouse.current[1],
            frame.pointer.y / half,
            POINTER_EASE,
          );
        }
        setVareColors(u, rampRef.current);
        setVareParams(u, paramsRef.current);
        setVareFrame(u, {
          time: frame.time * speedRef.current,
          mouse: mouse.current,
          pointer: interactiveRef.current ? frame.pointer.amount : 0,
          alpha: opacityRef.current,
          absorb: blendUniform(blendRef.current),
        });
      },
    });

    const { redraw } = canvas;
    // biome-ignore lint/correctness/useExhaustiveDependencies: the still frame must repaint when the ramp or the blend changes.
    React.useEffect(() => {
      redraw();
    }, [redraw, ramp, 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>
    );
  },
);
Vare.displayName = "Vare";