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 usva

layer

atmospheres

intensity

room · the whole environment. nothing competes with it

composition

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 texture

a11y

the canvas sits in an aria-hidden wrapper behind the content · reduced motion paints one static frame · no WebGL2, no canvasjest-axe

dependencies

ogl · atmospheres-core from the same package
live 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
1
opacitysheet strength, 0 to 1
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
3
creasedepth of the pleats
1.15
glossspecular weight, silk vs paper
1
advanced20
scalefold size, smaller pulls the drape back
1.2
creaseWidthpleat width, wide is satin, narrow is foil
1
drifthow far the pleats travel
0.06
drapeweight of the organic wander over the pleats
0.45
drapeScalesize of that wander
0.35
sheenspecular exponent, high is a tight glint
36
wraplambert wrap, 0 hard terminator to 1 bled round
0.08
contrastpower on the wrapped diffuse, high drives to black
4.5
puritygamma on the tint, above 1 keeps lamps from greying
1.9
key · xkey lamp direction, x in eye space
-0.8
key · ykey lamp direction, y in eye space
0.45
key · zkey lamp direction, z toward the eye
0.3
fill · xfill lamp direction, x in eye space
0.85
fill · yfill lamp direction, y in eye space
-0.35
fill · zfill lamp direction, z toward the eye
0.26
rim · xrim lamp direction, x in eye space
0.1
rim · yrim lamp direction, y in eye space
0.95
rim · zrim lamp direction, z toward the eye
0.22
tilthow far the pointer swings the key light
0.55
gainoverall brightness of the lit sheet
1.35
usagecopy
import { Kuulto } from "usva/atmospheres/kuulto";

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

props

proptypedefaultnotes
childrenReactNoderendered above the drape, in normal flow.
speednumber1drift and drape rate multiplier.
interactivebooleantruethe cursor swings the key lamp and the folds re-catch it.
opacitynumber1overall 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.
paramsPartial<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.jsoncopy
source · components/ui/kuulto-field.tsexactly what this command copiescopy
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 copiescopy
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 copiescopy
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 copiescopy
"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";