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 usvalayer
atmospheresintensity
room · the whole environment. nothing competes with itcomposition
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 viewporta11y
the canvas layer isaria-hidden · children stay in normal flow, fully interactive · reduced motion gets a static frame, not a loopjest-axedependencies
ogl · atmospheres-core from the same packagelive 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
opacityfog strength, 0 to 1
bandsglowing contour-shells through the body
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
swirltwist per unit height, the helix strength
omegarotation speed of the whole body
noiseFreqscale of the interior texture
noiseAmpstrength of that texture
noiseBasefloor density so fills stay present
drifthow fast the noise field advects
wispSigmavertical width of the equator wisp
wispAmthow far density leaks into the tails
wispDriftsideways shedding speed of the wisps
absorbself-occlusion strength
exposuretone-map exposure, hot cores clip to white
breathAmtdepth of the slow breath
breathRaterate of the slow breath
usage
import { Utu } from "usva/atmospheres/utu";
<Utu>
<Hero />
</Utu>props
| prop | type | default | notes |
|---|---|---|---|
| children | ReactNode | — | rendered in normal flow above the field. omit for a bare orb you position yourself. |
| speed | number | 1 | rotation and breath rate multiplier. |
| interactive | boolean | false | the volume eases a lean toward the cursor. off because a hero should not chase the pointer. |
| bands | number | 5 | how 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. |
| accentColor | string | — | collapse the gradient to one brand colour, sunk to black in the valleys and blown toward white at the cores. |
| opacity | number | 1 | overall 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.jsonsource · components/ui/utu-field.tsexactly what this command copies
/**
* 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 copies
/**
* 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 copies
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 copies
"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";