usva
uikuultosource: ok · hit
Kuulto
npx shadcn add "https://usva.build/r/kuulto.json"free
No selection tags yet — empty means no evidence, not lack of support.
Dependencies
Bundle weight · heavywebglanimationmotion
webglogl
Registry dependencies
https://usva.build/r/atmospheres-core.json
Files4 · source cached 10/15/2026, 5:37:30 AM
kuulto-field.ts
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),
]);
}
kuulto-shader.ts
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);
}
`;
kuulto-uniforms.ts
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);
}
kuulto.tsx
"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";