docs / atmospheres / kuulto
Kuulto
a sheet of silk far larger than the frame, creased three times and lit by three coloured lamps. everything you see is the lighting: a fold takes the hue of whichever lamp it turns toward, and the ground is black because no light reaches it. nothing is painted.
intensity · roomrsc · client
provenance
authored in usvalayer
atmospheresintensity
room · the whole environment. nothing competes with itcomposition
wraps a hero or a full section; children sit above the drape in normal flowrecolour through colors: the hues are lamps, so it is native to no theme and at home in allnot on savi. light in a void needs a dark ground; reach for kynnös or väre therenever behind dense reading surfaces. it is the room, not a texturea11y
the canvas sits in anaria-hidden wrapper behind the content · reduced motion paints one static frame · no WebGL2, no canvasjest-axedependencies
ogl · atmospheres-core from the same packagelive demo · try it out
colour is a shadow
no palette is sampled here; a fold takes the hue of whichever lamp it turns toward
customize
speeddrift and drape rate
opacitysheet strength, 0 to 1
modeauto reads the ground, or force emit / stain
interactivethe cursor swings the key lamp
keythe main lamp, reads accent
fillthe shadow side, reads accent-2
rimthe grazing lamp on the crests
reliefhow steeply a fold turns through the lamps
creasedepth of the pleats
glossspecular weight, silk vs paper
advanced20
scalefold size, smaller pulls the drape back
creaseWidthpleat width, wide is satin, narrow is foil
drifthow far the pleats travel
drapeweight of the organic wander over the pleats
drapeScalesize of that wander
sheenspecular exponent, high is a tight glint
wraplambert wrap, 0 hard terminator to 1 bled round
contrastpower on the wrapped diffuse, high drives to black
puritygamma on the tint, above 1 keeps lamps from greying
key · xkey lamp direction, x in eye space
key · ykey lamp direction, y in eye space
key · zkey lamp direction, z toward the eye
fill · xfill lamp direction, x in eye space
fill · yfill lamp direction, y in eye space
fill · zfill lamp direction, z toward the eye
rim · xrim lamp direction, x in eye space
rim · yrim lamp direction, y in eye space
rim · zrim lamp direction, z toward the eye
tilthow far the pointer swings the key light
gainoverall brightness of the lit sheet
usage
import { Kuulto } from "usva/atmospheres/kuulto";
<Kuulto
speed={1}
>
<Hero />
</Kuulto>props
| prop | type | default | notes |
|---|---|---|---|
| children | ReactNode | — | rendered above the drape, in normal flow. |
| speed | number | 1 | drift and drape rate multiplier. |
| interactive | boolean | true | the cursor swings the key lamp and the folds re-catch it. |
| opacity | number | 1 | overall opacity of the sheet, 0 to 1. |
| mode | "emissive" | "absorptive" | — | forces the blend. by default a dark ground emits and a light ground stains, the only way this survives a light theme. |
| colors | { key?; fill?; rim? } | — | the three lamps: key reads accent, fill reads accent-2, rim reads accent-alt. omitted lamps read their token. |
| params | Partial<KuultoParams> | — | the drape: scale, relief, crease, creaseWidth, drift, drape, drapeScale, sheen, gloss, wrap, contrast, purity, key, fill, rim, tilt, gain. |
get it
npx shadcn add https://usva.build/r/kuulto.jsonsource · components/ui/kuulto-field.tsexactly what this command copies
import type { Rgb } from "./atmospheres-color";
/**
* kuulto is translucency, the state of being dimly visible: something light passes
* through or reflects softly. Here that is a vast sheet of silk, creased a few
* times and lit from three sides. Nothing here samples a
* palette. The colour is what the lighting does to the surface, so a fold turning
* toward one lamp reads as that lamp's hue and the same fold rolling away catches
* the next. The ground is black because no light reaches it, not because it was
* painted black.
*/
export interface KuultoParams {
/** Fold size. Smaller values pull the drape further back from the eye. */
scale: number;
/** How steeply the normals turn across a fold. The biggest knob on the look. */
relief: number;
/** Depth of the pleats folded into the sheet. */
crease: number;
/** Gaussian width of a pleat. Wide pleats read as satin, narrow ones as foil. */
creaseWidth: number;
/** How far the pleats travel. */
drift: number;
/** Weight of the organic wander laid over the pleats. */
drape: number;
drapeScale: number;
/** Specular exponent. High is a tight glint, low is a broad sheen. */
sheen: number;
/** Weight of the specular term against the diffuse one. */
gloss: number;
/** Lambert wrap. At 0 the terminator is hard; at 1 light bleeds right round. */
wrap: number;
/** Power on the wrapped diffuse. High drives the unlit sheet to black. */
contrast: number;
/** Gamma on the normalised tint. Above 1 keeps overlapping lamps from
* greying each other out; 1 leaves the mix alone. */
purity: number;
/** Fill and rim directions, in eye space. The key is steered by the pointer. */
fill: [number, number, number];
rim: [number, number, number];
/** Key direction with the pointer at rest. */
key: [number, number, number];
/** How far the pointer swings the key light. */
tilt: number;
gain: number;
}
export const KUULTO_DEFAULTS: KuultoParams = {
scale: 1.2,
relief: 3.0,
crease: 1.15,
creaseWidth: 1.0,
drift: 0.06,
drape: 0.45,
drapeScale: 0.35,
sheen: 36,
gloss: 1.0,
wrap: 0.08,
contrast: 4.5,
purity: 1.9,
key: [-0.8, 0.45, 0.3],
fill: [0.85, -0.35, 0.26],
rim: [0.1, 0.95, 0.22],
tilt: 0.55,
gain: 1.35,
};
export interface KuultoColors {
/** The key lamp. Whatever faces it takes this hue. */
key: Rgb;
/** The fill lamp, opposite the key, holding the shadow side off black. */
fill: Rgb;
/** The rim lamp, grazing the sheet so only fold crests catch it. */
rim: Rgb;
}
export const POINTER_EASE = 0.045;
/** The number of pleats folded into the sheet. Three read as cloth; more read as
* corrugation, which is the object the background must never become. */
export const CREASES = 3;
export function approach(
current: number,
target: number,
ease: number,
): number {
return current + (target - current) * ease;
}
export function resolveParams(overrides?: Partial<KuultoParams>): KuultoParams {
return { ...KUULTO_DEFAULTS, ...overrides };
}
function normalize(v: [number, number, number]): [number, number, number] {
const len = Math.hypot(v[0], v[1], v[2]);
if (len < 1e-6) return [0, 0, 1];
return [v[0] / len, v[1] / len, v[2] / len];
}
/**
* The key lamp swings with the eased pointer, so the cursor turns the silk in the
* light rather than dragging a glow across it. The z term is floored: a lamp that
* swings past the horizon would light the sheet from behind and the folds would
* flip inside out.
*/
export function keyLight(
params: KuultoParams,
mouse: [number, number],
amount: number,
): [number, number, number] {
const swing = params.tilt * amount;
return normalize([
params.key[0] + mouse[0] * swing,
params.key[1] + mouse[1] * swing,
Math.max(params.key[2], 0.25),
]);
}
source · components/ui/kuulto-shader.tsexactly what this command copies
import { glsl } from "./atmospheres-glsl";
import { CREASES } from "./kuulto-field";
/** The gradient is taken across a fixed fraction of a fold rather than a pixel:
* sampling the height field a pixel apart would resolve the fbm's own grain and
* the sheet would come back sandpapered. */
const GRAD_EPS = 0.035;
export const kuultoFragmentShader = /* glsl */ `#version 300 es
precision highp float;
uniform float uTime;
uniform vec2 uResolution;
uniform float uScale;
uniform float uRelief;
uniform float uCrease;
uniform float uCreaseWidth;
uniform float uDrift;
uniform float uDrape;
uniform float uDrapeScale;
uniform float uSheen;
uniform float uGloss;
uniform float uWrap;
uniform float uContrast;
uniform float uPurity;
uniform vec3 uKey;
uniform vec3 uFill;
uniform vec3 uRim;
uniform float uGain;
uniform float uAlpha;
uniform float uAbsorb;
uniform vec3 uKeyColor;
uniform vec3 uFillColor;
uniform vec3 uRimColor;
out vec4 fragColor;
${glsl("fbm", "dither", "composite")}
/** One pleat. Odd in the signed distance to its axis, so the sheet lifts on one
* side of the crease and drops on the other the way real cloth does. A Gaussian
* bump would only ever make a welt. */
float pleat(vec2 p, float angle, vec2 centre, float width) {
vec2 axis = vec2(cos(angle), sin(angle));
float d = dot(p - centre, axis) / max(width, 1e-3);
return d * exp(-d * d);
}
/** The drape, as a height. Three pleats on slow lissajous paths, plus one gentle
* fbm so the cloth wanders and the eye can never trace a pleat back to a line. */
float height(vec2 p, float t) {
float h = 0.0;
for (int i = 0; i < ${CREASES}; i++) {
float fi = float(i);
// Golden-angle spacing: any rational fraction of a turn would let the pleats
// line up into a corrugation every few seconds.
float angle = 2.39996 * fi + t * uDrift * (0.6 + 0.2 * fi);
vec2 centre = vec2(
sin(t * uDrift * (1.3 + 0.4 * fi) + fi * 2.1),
cos(t * uDrift * (0.9 + 0.5 * fi) + fi * 1.7)
) * 1.15;
h += pleat(p, angle, centre, uCreaseWidth) * uCrease;
}
h += (fbm(vec3(p * uDrapeScale, t * 0.035), 3) - 0.5) * uDrape * 2.0;
return h;
}
/** Central differences. The sheet is a height field, so the normal is just the
* gradient stood up: a fold's whole appearance is decided here. */
vec3 surfaceNormal(vec2 p, float t) {
vec2 e = vec2(${GRAD_EPS.toFixed(3)}, 0.0);
float hx = height(p + e.xy, t) - height(p - e.xy, t);
float hy = height(p + e.yx, t) - height(p - e.yx, t);
vec2 grad = vec2(hx, hy) / (2.0 * e.x);
return normalize(vec3(-grad * uRelief, 1.0));
}
/** Wrapped Lambert raised to a contrast power. The wrap softens the terminator
* into cloth; the power drives everything that does not face a lamp down to
* black, which is where the ground between the folds comes from. */
float lambert(vec3 n, vec3 l) {
float d = (dot(n, l) + uWrap) / (1.0 + uWrap);
return pow(max(d, 0.0), uContrast);
}
/** Blinn-Phong against a viewer straight down the z axis. This is the sheen that
* a band function cannot have, and the whole reason the folds read as silk. */
float sheen(vec3 n, vec3 l) {
vec3 h = normalize(l + vec3(0.0, 0.0, 1.0));
return pow(max(dot(n, h), 0.0), uSheen);
}
vec3 lamp(vec3 n, vec3 dir, vec3 hue, float weight) {
return hue * (lambert(n, dir) * weight + sheen(n, dir) * uGloss);
}
void main() {
vec2 ndc = (2.0 * gl_FragCoord.xy - uResolution) / uResolution.y;
vec2 p = ndc * uScale;
vec3 n = surfaceNormal(p, uTime);
vec3 col = lamp(n, normalize(uKey), uKeyColor, 1.0)
+ lamp(n, normalize(uFill), uFillColor, 0.85)
+ lamp(n, normalize(uRim), uRimColor, 0.55);
col *= uGain;
// Tonemapped on luminance, not per channel, so a lit crest saturates toward its
// lamp instead of washing to white.
float lum = max(col.r, max(col.g, col.b));
float peak = 1.0 - exp(-lum);
vec3 tint = col / max(lum, 1e-4);
// A gamma on the normalised tint. Where two lamps overlap the mix drifts
// grey; this deepens the minor channels so the dominant lamp keeps its hue,
// and a pure lamp colour passes through untouched.
tint = pow(tint, vec3(uPurity));
vec3 rgb = dither(mix(tint, tint * 0.65, uAbsorb), gl_FragCoord.xy, 0.006);
float alpha = peak * uAlpha;
alpha = ditherAlpha(alpha, gl_FragCoord.xy, 0.004);
fragColor = composite(rgb, alpha);
}
`;
source · components/ui/kuulto-uniforms.tsexactly what this command copies
import {
setUniform,
type Uniforms,
} from "./atmospheres-gl";
import type { KuultoColors, KuultoParams } from "./kuulto-field";
export interface KuultoFrame {
time: number;
/** The key lamp direction after the pointer has swung it. */
key: [number, number, number];
alpha: number;
/** The house law: 1 stains the ground, 0 emits into it. */
absorb: number;
}
export function kuultoUniforms(
colors: KuultoColors,
params: KuultoParams,
): Uniforms {
return {
uTime: { value: 0 },
uResolution: { value: [1, 1] },
uScale: { value: params.scale },
uRelief: { value: params.relief },
uCrease: { value: params.crease },
uCreaseWidth: { value: params.creaseWidth },
uDrift: { value: params.drift },
uDrape: { value: params.drape },
uDrapeScale: { value: params.drapeScale },
uSheen: { value: params.sheen },
uGloss: { value: params.gloss },
uWrap: { value: params.wrap },
uContrast: { value: params.contrast },
uPurity: { value: params.purity },
uKey: { value: [...params.key] },
uFill: { value: [...params.fill] },
uRim: { value: [...params.rim] },
uGain: { value: params.gain },
uAlpha: { value: 1 },
uAbsorb: { value: 0 },
uKeyColor: { value: [...colors.key] },
uFillColor: { value: [...colors.fill] },
uRimColor: { value: [...colors.rim] },
};
}
export function setKuultoColors(u: Uniforms, colors: KuultoColors): void {
setUniform(u, "uKeyColor", [...colors.key]);
setUniform(u, "uFillColor", [...colors.fill]);
setUniform(u, "uRimColor", [...colors.rim]);
}
export function setKuultoParams(u: Uniforms, params: KuultoParams): void {
setUniform(u, "uScale", params.scale);
setUniform(u, "uRelief", params.relief);
setUniform(u, "uCrease", params.crease);
setUniform(u, "uCreaseWidth", params.creaseWidth);
setUniform(u, "uDrift", params.drift);
setUniform(u, "uDrape", params.drape);
setUniform(u, "uDrapeScale", params.drapeScale);
setUniform(u, "uSheen", params.sheen);
setUniform(u, "uGloss", params.gloss);
setUniform(u, "uWrap", params.wrap);
setUniform(u, "uContrast", params.contrast);
setUniform(u, "uPurity", params.purity);
setUniform(u, "uFill", [...params.fill]);
setUniform(u, "uRim", [...params.rim]);
setUniform(u, "uGain", params.gain);
}
export function setKuultoFrame(u: Uniforms, frame: KuultoFrame): void {
setUniform(u, "uTime", frame.time);
setUniform(u, "uKey", [...frame.key]);
setUniform(u, "uAlpha", frame.alpha);
setUniform(u, "uAbsorb", frame.absorb);
}
source · components/ui/kuulto.tsxexactly what this command copies
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import {
type BlendMode,
blendStyleFor,
blendUniform,
type Rgb,
resolveBlendMode,
resolveColor,
} from "./atmospheres-color";
import { hiddenOnGround } from "./atmospheres-ground";
import { useGlCanvas } from "./use-gl-canvas";
import {
useThemeVersion,
useTokenColors,
} from "./use-token-colors";
import {
approach,
type KuultoColors,
type KuultoParams,
keyLight,
POINTER_EASE,
resolveParams,
} from "./kuulto-field";
import { kuultoFragmentShader } from "./kuulto-shader";
import {
kuultoUniforms,
setKuultoColors,
setKuultoFrame,
setKuultoParams,
} from "./kuulto-uniforms";
const ROLES = ["accent", "accent-2", "accent-alt"] as const;
/** Far enough in that the pleats have travelled off their seed positions. */
const STILL_TIME = 22;
export interface KuultoProps extends React.HTMLAttributes<HTMLDivElement> {
/** Drift and drape rate multiplier. Defaults to 1. */
speed?: number;
/** When on, the cursor swings the key lamp and the folds re-catch it.
* Defaults to true. */
interactive?: boolean;
/** Overall opacity of the sheet, 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 lamp with a CSS colour. Omitted lamps read their token. */
colors?: { key?: string; fill?: string; rim?: string };
/** Escape hatch for the drape parameters, for tuning demos. */
params?: Partial<KuultoParams>;
children?: React.ReactNode;
}
export const Kuulto = React.forwardRef<HTMLDivElement, KuultoProps>(
(
{
speed = 1,
interactive = true,
opacity = 1,
mode,
colors,
params,
className,
children,
...props
},
forwardedRef,
) => {
const cKey = colors?.key;
const cFill = colors?.fill;
const cRim = colors?.rim;
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<KuultoParams>(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 lamps = React.useMemo<KuultoColors>(() => {
const lamp = (value: string | undefined, fallback: Rgb): Rgb =>
value ? resolveColor(value) : fallback;
return {
key: lamp(cKey, tokens.colors.accent),
fill: lamp(cFill, tokens.colors["accent-2"]),
rim: lamp(cRim, tokens.colors["accent-alt"]),
};
}, [cKey, cFill, cRim, tokens, themeVersion]);
const lampsRef = React.useRef(lamps);
lampsRef.current = lamps;
const blendRef = React.useRef(blend);
blendRef.current = blend;
const mouse = React.useRef<[number, number]>([0, 0]);
const canvas = useGlCanvas({
fragment: kuultoFragmentShader,
uniforms: () => kuultoUniforms(lampsRef.current, paramsRef.current),
enabled: !hiddenOnGround("kuulto", blend),
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,
);
}
setKuultoColors(u, lampsRef.current);
setKuultoParams(u, paramsRef.current);
setKuultoFrame(u, {
time: frame.time * speedRef.current,
key: keyLight(
paramsRef.current,
mouse.current,
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 lamps or the blend change.
React.useEffect(() => {
redraw();
}, [redraw, lamps, 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>
);
},
);
Kuulto.displayName = "Kuulto";