docs / atmospheres / utu

Utu

morning fog made into a field. on dark ground it is one luminous body with depth you can see into; on savi the same density reads as damp soaking into clay, transparent where the ground stayed dry.

intensity · roomrsc · client

provenance

authored in usva

layer

atmospheres

intensity

room · the whole environment. nothing competes with it

composition

a landing hero, a splash, one felt moment per viewthe savi stain is calm enough to sit behind longer readingnever behind a table or form. the glow gets one spot per viewnever two glowing bodies in one viewport

a11y

the canvas layer is aria-hidden · children stay in normal flow, fully interactive · reduced motion gets a static frame, not a loopjest-axe

dependencies

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

a faint glow

fog that turns and breathes, and leaves the words clear

customize
modeauto reads the ground; force emit or damp stain
speedrotation and breath rate
1
opacityfog strength, 0 to 1
1
bandsglowing contour-shells through the body
5
interactivethe volume leans toward the pointer
accentColorcollapses the dawn gradient to one brand colour
deepthe valleys, oldest fog
midthe body of the glow
hotthe blown-out cores
advanced14
radiussphere size in short-side units
1.65
swirltwist per unit height, the helix strength
2.2
omegarotation speed of the whole body
0.12
noiseFreqscale of the interior texture
1.6
noiseAmpstrength of that texture
0.85
noiseBasefloor density so fills stay present
0.22
drifthow fast the noise field advects
0.08
wispSigmavertical width of the equator wisp
0.12
wispAmthow far density leaks into the tails
0.35
wispDriftsideways shedding speed of the wisps
0.2
absorbself-occlusion strength
1.1
exposuretone-map exposure, hot cores clip to white
10
breathAmtdepth of the slow breath
0.06
breathRaterate of the slow breath
0.05
usagecopy
import { Utu } from "usva/atmospheres/utu";

<Utu>
  <Hero />
</Utu>

props

proptypedefaultnotes
childrenReactNoderendered in normal flow above the field. omit for a bare orb you position yourself.
speednumber1rotation and breath rate multiplier.
interactivebooleanfalsethe volume eases a lean toward the cursor. off because a hero should not chase the pointer.
bandsnumber5how many glowing bands stack through the body.
colors{ deep?; mid?; hot? }override any stop of the dawn gradient. omitted stops keep violet valleys, magenta body, warm-gold cores.
accentColorstringcollapse the gradient to one brand colour, sunk to black in the valleys and blown toward white at the cores.
opacitynumber1overall fog opacity. lower lets more of the page through.
mode"emissive" | "absorptive"forces the material. by default a dark ground emits fog and a light ground holds a damp stain.

get it

npx shadcn add https://usva.build/r/utu.jsoncopy
source · components/ui/utu-field.tsexactly what this command copiescopy
/**
 * Pure math for Utu: the static field parameters, the slow breathing and
 * pointer-lean easing, and the plum -> orchid -> hot-magenta colour ramp. No DOM
 * and no sula imports, so the atmosphere stays genuinely standalone (its motion never
 * settles, so it borrows none of sula's spring/energy machinery either).
 */

export type Rgb = [number, number, number];

const TAU = Math.PI * 2;

export function clamp01(t: number): number {
  return Math.min(1, Math.max(0, t));
}

/** Quintic smootherstep, the same easing the shader uses for its radial falloff. */
export function smoother(t: number): number {
  const c = clamp01(t);
  return c * c * c * (c * (c * 6 - 15) + 10);
}

/** Eased approach toward a target, per frame. Used for the pointer lean. */
export function approach(
  current: number,
  target: number,
  ease: number,
): number {
  return current + (target - current) * ease;
}

/** How quickly the volume leans toward the cursor. Slow, so it drifts. */
export const LEAN_EASE = 0.06;

/**
 * A very slow sine around a base value: the body inhales, its radius and band
 * count drifting so the contours never sit perfectly still.
 */
export function breathe(
  elapsed: number,
  base: number,
  amount: number,
  rate: number,
): number {
  return base * (1 + amount * Math.sin(TAU * rate * elapsed));
}

/** The static field description handed to the shader once, not per frame. */
export interface UtuParams {
  /** Sphere radius in normalized (short-side) units. */
  radius: number;
  /** Contour count: how many glowing shells stack through the body. */
  bands: number;
  /** Twist per unit height, the helix strength. */
  swirl: number;
  /** Rotation speed of the whole body, rad/s. Slow. */
  omega: number;
  noiseFreq: number;
  noiseAmp: number;
  /** Floor density inside the body so fills stay dim-but-present. */
  noiseBase: number;
  /** How fast the noise field advects, so the texture travels with the twist. */
  drift: number;
  /** Vertical width of the equator wisp mask. */
  wispSigma: number;
  /** How far density leaks past the sphere into the tails. */
  wispAmt: number;
  /** Sideways drift speed of the wisps, so they read as shedding. */
  wispDrift: number;
  /** Beer-Lambert self-occlusion strength. */
  absorb: number;
  /** Tone-map exposure; hot cores clip toward white-magenta. */
  exposure: number;
  breathAmt: number;
  breathRate: number;
}

export const DEFAULT_PARAMS: UtuParams = {
  radius: 1.65,
  bands: 5,
  swirl: 2.2,
  omega: 0.12,
  noiseFreq: 1.6,
  noiseAmp: 0.85,
  noiseBase: 0.22,
  drift: 0.08,
  wispSigma: 0.12,
  wispAmt: 0.35,
  wispDrift: 0.2,
  absorb: 1.1,
  exposure: 10,
  breathAmt: 0.06,
  breathRate: 0.05,
};

export function resolveParams(overrides?: Partial<UtuParams>): UtuParams {
  return { ...DEFAULT_PARAMS, ...overrides };
}

function clampChannel(v: number): number {
  return Math.min(1, Math.max(0, v));
}

/** Scale an rgb toward black, for the deep valley colour. */
export function scaleRgb(color: Rgb, factor: number): Rgb {
  return [
    clampChannel(color[0] * factor),
    clampChannel(color[1] * factor),
    clampChannel(color[2] * factor),
  ];
}

/** Push an rgb toward white, for the blown-out hot rim. */
export function mixWhite(color: Rgb, amount: number): Rgb {
  const a = clamp01(amount);
  return [
    clampChannel(color[0] + (1 - color[0]) * a),
    clampChannel(color[1] + (1 - color[1]) * a),
    clampChannel(color[2] + (1 - color[2]) * a),
  ];
}

export interface UtuEmissionColors {
  deep: Rgb;
  mid: Rgb;
  hot: Rgb;
}

export interface UtuColors extends UtuEmissionColors {
  /** kosteus: the hue the clay takes where the damp is deepest. */
  pigment: Rgb;
}

/**
 * The default emission ramp: a dawn/dusk horizon sweep. Cool violet in the
 * valleys, magenta-rose through the body, warm gold at the hot cores, so the
 * sphere reads as a glow on the horizon rather than one flat tint.
 */
export const DAWN: UtuEmissionColors = {
  deep: [0.2, 0.13, 0.42],
  mid: [0.8, 0.3, 0.72],
  hot: [1.0, 0.72, 0.52],
};

/** The dawn ramp with any stop overridden. Omitted stops keep the dawn colour. */
export function buildRamp(
  overrides?: Partial<UtuEmissionColors>,
): UtuEmissionColors {
  return {
    deep: overrides?.deep ?? DAWN.deep,
    mid: overrides?.mid ?? DAWN.mid,
    hot: overrides?.hot ?? DAWN.hot,
  };
}

/**
 * A single-hue ramp from one accent, for callers who want the sphere to match a
 * brand colour instead of the dawn gradient. Deep sinks toward black, mid holds
 * the accent, hot blows toward white for the clipping cores.
 */
export function monoRamp(accent: Rgb): UtuEmissionColors {
  return {
    deep: scaleRgb(accent, 0.3),
    mid: scaleRgb(accent, 0.95),
    hot: mixWhite(accent, 0.3),
  };
}
source · components/ui/utu-shader.tsexactly what this command copiescopy
/**
 * Utu is a luminous fog volume, not glass. It raymarches an analytic
 * sphere and emits on the isolines of a drifting 3D noise field, so the body
 * reads as stacked glowing contour-shells you can see through, with wispy tails
 * shearing off the equator. Everything here is clean-room field math; it shares
 * nothing with sula-core's metaball shader (which is a lit glass surface, the
 * exact look this avoids). The one forbidden move is a fresnel silhouette rim.
 */

import { glsl } from "./atmospheres-glsl";

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

/** Fixed march depth. Baked as a constant so the loop stays uniform across the
 * quad, which keeps fwidth() (used for the isoline anti-alias) well defined. */
const STEPS = 32;

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

uniform float uTime;
uniform vec2  uResolution;
uniform float uRadius;
uniform float uBands;
uniform float uSwirl;
uniform float uOmega;
uniform float uNoiseFreq;
uniform float uNoiseAmp;
uniform float uNoiseBase;
uniform float uDrift;
uniform float uWispSigma;
uniform float uWispAmt;
uniform float uWispDrift;
uniform float uExtinction;
uniform float uAbsorb;
uniform float uExposure;
uniform vec3  uDeep;
uniform vec3  uMid;
uniform vec3  uHot;
uniform vec3  uPigment;
uniform float uStainFloor;
uniform float uAlpha;
uniform vec2  uLean;
uniform float uLeanAmt;

out vec4 fragColor;

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

const int STEPS = ${STEPS};
const float SIGMA = 1.05;
const float SOAK = 0.32;

float hash13(vec3 p) {
  p = fract(p * 0.1031);
  p += dot(p, p.yzx + 33.33);
  return fract((p.x + p.y) * p.z);
}

float vnoise(vec3 x) {
  vec3 i = floor(x);
  vec3 f = fract(x);
  f = f * f * (3.0 - 2.0 * f);
  float n000 = hash13(i + vec3(0.0, 0.0, 0.0));
  float n100 = hash13(i + vec3(1.0, 0.0, 0.0));
  float n010 = hash13(i + vec3(0.0, 1.0, 0.0));
  float n110 = hash13(i + vec3(1.0, 1.0, 0.0));
  float n001 = hash13(i + vec3(0.0, 0.0, 1.0));
  float n101 = hash13(i + vec3(1.0, 0.0, 1.0));
  float n011 = hash13(i + vec3(0.0, 1.0, 1.0));
  float n111 = hash13(i + vec3(1.0, 1.0, 1.0));
  return mix(
    mix(mix(n000, n100, f.x), mix(n010, n110, f.x), f.y),
    mix(mix(n001, n101, f.x), mix(n011, n111, f.x), f.y),
    f.z
  );
}

float fbm(vec3 p) {
  float a = 0.5;
  float s = 0.0;
  for (int i = 0; i < 3; i++) {
    s += a * vnoise(p);
    p = p * 2.02 + vec3(11.3, 7.7, 3.1);
    a *= 0.5;
  }
  return s;
}

float smoother(float t) {
  t = clamp(t, 0.0, 1.0);
  return t * t * t * (t * (t * 6.0 - 15.0) + 10.0);
}

vec3 ramp(float d) {
  float t = clamp(d, 0.0, 1.0);
  vec3 lo = mix(uDeep, uMid, smoothstep(0.0, 0.55, t));
  return mix(lo, uHot, smoothstep(0.55, 1.0, t));
}

void main() {
  float m = min(uResolution.x, uResolution.y);
  vec2 uv = (gl_FragCoord.xy * 2.0 - uResolution) / m;
  uv -= uLean * uLeanAmt * 0.12;

  float ym = exp(-(uv.y * uv.y) / max(uWispSigma, 1e-3));
  float Rout = uRadius * (1.0 + uWispAmt * ym);

  float rr2 = dot(uv, uv);
  float core = uRadius * uRadius - rr2;
  float zspan = core > 0.0 ? sqrt(core) : 0.0;

  float wisp = uWispAmt * ym * uRadius * 0.6;
  wisp *= 1.0 - smoothstep(uRadius * 0.4, Rout, length(uv));
  zspan = max(zspan, wisp);

  if (zspan <= 0.0) {
    fragColor = vec4(0.0);
    return;
  }

  float stepLen = (2.0 * zspan) / float(STEPS);
  float dphase = uDrift * uTime;
  vec3 col = vec3(0.0);
  float T = 1.0;
  float depth = 0.0;

  for (int i = 0; i < STEPS; i++) {
    float z = zspan - (float(i) + 0.5) * stepLen;
    vec3 p = vec3(uv, z);

    float ang = uSwirl * p.y + uOmega * uTime;
    float s = sin(ang);
    float c = cos(ang);
    vec3 q = vec3(c * p.x - s * p.z, p.y, s * p.x + c * p.z);

    vec3 qn = q;
    qn.x *= mix(1.0, 0.65, ym);
    qn.y *= mix(1.0, 1.35, ym);
    qn += vec3(dphase * 0.5 + uWispDrift * uTime * ym, -dphase * 0.3, dphase);

    float ax = p.x / (1.0 + uWispAmt * ym);
    float rad = length(vec3(ax, p.y, p.z));
    float shell = smoother(1.0 - rad / uRadius);

    float n = fbm(qn * uNoiseFreq);
    float density = max(shell * (uNoiseBase + uNoiseAmp * n), 0.0);
    depth += density * stepLen;

    float b = density * uBands;
    float aa = fwidth(b) * 1.5 + 0.04;
    float fb = fract(b);
    float rim = smoothstep(0.5 - aa, 0.5, fb) * (1.0 - smoothstep(0.5, 0.5 + aa, fb));

    float em = (rim * 1.6 + 0.18) * density;
    col += T * em * ramp(density) * (1.0 / float(STEPS));
    T *= exp(-density * uExtinction * stepLen);
  }

  col *= uExposure;
  col = vec3(1.0) - exp(-col);
  float peak = clamp(max(col.r, max(col.g, col.b)), 0.0, 1.0);
  vec3 display = col / max(peak, 1e-4);
  if (uAbsorb < 0.5) {
    fragColor = composite(display, peak * uAlpha);
    return;
  }

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

/** The per-frame values: everything else is set on change, not per frame. */
export interface UtuFrame {
  /** Seconds since start. */
  time: number;
  /** Breathing radius for this frame. */
  radius: number;
  /** Breathing band count for this frame. */
  bands: number;
  /** Eased pointer offset in normalized units; [0,0] when not interacting. */
  lean: [number, number];
  /** Fades the lean in and out, 0..1. */
  leanAmt: number;
  alpha: number;
  /** The house law: 1 stains the ground, 0 emits into it. */
  absorb: number;
}

export function utuUniforms(colors: UtuColors, params: UtuParams): Uniforms {
  return {
    uTime: { value: 0 },
    uResolution: { value: [1, 1] },
    uRadius: { value: params.radius },
    uBands: { value: params.bands },
    uSwirl: { value: params.swirl },
    uOmega: { value: params.omega },
    uNoiseFreq: { value: params.noiseFreq },
    uNoiseAmp: { value: params.noiseAmp },
    uNoiseBase: { value: params.noiseBase },
    uDrift: { value: params.drift },
    uWispSigma: { value: params.wispSigma },
    uWispAmt: { value: params.wispAmt },
    uWispDrift: { value: params.wispDrift },
    uExtinction: { value: params.absorb },
    uAbsorb: { value: 0 },
    uExposure: { value: params.exposure },
    uDeep: { value: colors.deep },
    uMid: { value: colors.mid },
    uHot: { value: colors.hot },
    uPigment: { value: colors.pigment },
    uStainFloor: { value: MAX_STAIN },
    uAlpha: { value: 1 },
    uLean: { value: [0, 0] },
    uLeanAmt: { value: 0 },
  };
}

export function setUtuColors(u: Uniforms, colors: UtuColors): void {
  setUniform(u, "uDeep", colors.deep);
  setUniform(u, "uMid", colors.mid);
  setUniform(u, "uHot", colors.hot);
  setUniform(u, "uPigment", colors.pigment);
}

export function setUtuParams(u: Uniforms, params: UtuParams): void {
  setUniform(u, "uSwirl", params.swirl);
  setUniform(u, "uOmega", params.omega);
  setUniform(u, "uNoiseFreq", params.noiseFreq);
  setUniform(u, "uNoiseAmp", params.noiseAmp);
  setUniform(u, "uNoiseBase", params.noiseBase);
  setUniform(u, "uDrift", params.drift);
  setUniform(u, "uWispSigma", params.wispSigma);
  setUniform(u, "uWispAmt", params.wispAmt);
  setUniform(u, "uWispDrift", params.wispDrift);
  setUniform(u, "uExtinction", params.absorb);
  setUniform(u, "uExposure", params.exposure);
}

export function setUtuFrame(u: Uniforms, frame: UtuFrame): void {
  setUniform(u, "uTime", frame.time);
  setUniform(u, "uRadius", frame.radius);
  setUniform(u, "uBands", frame.bands);
  setUniform(u, "uLean", frame.lean);
  setUniform(u, "uLeanAmt", frame.leanAmt);
  setUniform(u, "uAlpha", frame.alpha);
  setUniform(u, "uAbsorb", frame.absorb);
}
source · components/ui/utu.tsxexactly what this command copiescopy
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import {
  type BlendMode,
  blendStyleFor,
  blendUniform,
  pigmentFor,
  resolveBlendMode,
  resolveColor,
} from "./atmospheres-color";
import { useGlCanvas } from "./use-gl-canvas";
import {
  useThemeVersion,
  useTokenColors,
} from "./use-token-colors";
import {
  approach,
  breathe,
  buildRamp,
  LEAN_EASE,
  monoRamp,
  resolveParams,
  type UtuColors,
  type UtuEmissionColors,
  type UtuParams,
} from "./utu-field";
import { utuFragmentShader } from "./utu-shader";
import {
  setUtuColors,
  setUtuFrame,
  setUtuParams,
  utuUniforms,
} from "./utu-uniforms";

const ROLES = ["ink"] as const;

export interface UtuProps extends React.HTMLAttributes<HTMLDivElement> {
  /** Rotation and breath rate multiplier; higher turns faster. Defaults to 1. */
  speed?: number;
  /** When on, the volume leans toward the eased cursor. Defaults to false. */
  interactive?: boolean;
  /** Contour count: how many glowing shells stack through the body. */
  bands?: number;
  /** Collapse the dawn gradient to a single brand colour instead. */
  accentColor?: string;
  /** Override any dawn gradient stop with a CSS colour. Omitted stops keep the
   * dawn default (violet valleys, magenta body, warm-gold cores). */
  colors?: { deep?: string; mid?: string; hot?: string };
  /** Overall opacity of the fog, 0..1. Lower lets more of the page through.
   * Defaults to 1. */
  opacity?: number;
  /** Force the material. Defaults to fog on a dark ground and damp pigment on
   * a light one. */
  mode?: BlendMode;
  /** Escape hatch for the field parameters, for tuning demos. */
  params?: Partial<UtuParams>;
  children?: React.ReactNode;
}

interface ColorOverrides {
  accentColor?: string;
  deep?: string;
  mid?: string;
  hot?: string;
}

function readColors(overrides: ColorOverrides): UtuEmissionColors {
  const stop = (c?: string) => (c ? resolveColor(c) : undefined);
  const stops = {
    deep: stop(overrides.deep),
    mid: stop(overrides.mid),
    hot: stop(overrides.hot),
  };
  if (overrides.accentColor) {
    const base = monoRamp(resolveColor(overrides.accentColor));
    return {
      deep: stops.deep ?? base.deep,
      mid: stops.mid ?? base.mid,
      hot: stops.hot ?? base.hot,
    };
  }
  return buildRamp(stops);
}

export const Utu = React.forwardRef<HTMLDivElement, UtuProps>(
  (
    {
      speed = 1,
      interactive = false,
      bands,
      accentColor,
      colors,
      opacity = 1,
      mode,
      params,
      className,
      children,
      ...props
    },
    forwardedRef,
  ) => {
    const cDeep = colors?.deep;
    const cMid = colors?.mid;
    const cHot = colors?.hot;

    /* These tune what the loop draws, not the GL context, so they ride refs: a
     * live prop change must never tear the context down and rebuild it on the
     * same canvas, which would race a scheduled frame. */
    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<UtuParams>(
      resolveParams({ ...params, ...(bands !== undefined ? { bands } : {}) }),
    );
    paramsRef.current = resolveParams({
      ...params,
      ...(bands !== undefined ? { bands } : {}),
    });

    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<UtuColors>(() => {
      const emission = readColors({
        accentColor,
        deep: cDeep,
        mid: cMid,
        hot: cHot,
      });
      return {
        ...emission,
        pigment: pigmentFor(emission.mid, tokens.colors.ink),
      };
    }, [accentColor, cDeep, cMid, cHot, tokens, themeVersion]);
    const rampRef = React.useRef(ramp);
    rampRef.current = ramp;
    const blendRef = React.useRef(blend);
    blendRef.current = blend;

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

    const canvas = useGlCanvas({
      fragment: utuFragmentShader,
      uniforms: () => utuUniforms(rampRef.current, paramsRef.current),
      pointer: true,
      pointerEase: LEAN_EASE,
      maxDpr: 1.5,
      renderScale: 0.8,
      onFrame: (u, frame) => {
        const p = paramsRef.current;
        const elapsed = frame.time;
        const radius = breathe(elapsed, p.radius, p.breathAmt, p.breathRate);
        const bandsNow = breathe(
          elapsed,
          p.bands,
          p.breathAmt * 0.6,
          p.breathRate,
        );
        if (interactiveRef.current) {
          const short = Math.min(frame.width, frame.height) || 1;
          lean.current[0] = approach(
            lean.current[0],
            (frame.pointer.x / short) * 2,
            LEAN_EASE,
          );
          lean.current[1] = approach(
            lean.current[1],
            (frame.pointer.y / short) * 2,
            LEAN_EASE,
          );
        }
        setUtuColors(u, rampRef.current);
        setUtuParams(u, p);
        setUtuFrame(u, {
          time: elapsed * speedRef.current,
          radius,
          bands: bandsNow,
          lean: lean.current,
          leanAmt: 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 changes.
    React.useEffect(() => {
      redraw();
    }, [redraw, ramp, blend]);

    const sphereOn = 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-fluid={sphereOn ? "on" : "off"}
        data-blend={blend}
        className={cn("relative isolate overflow-hidden", className)}
        {...props}
      >
        {sphereOn ? (
          <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>
    );
  },
);
Utu.displayName = "Utu";