docs / atmospheres / kynnos
Kynnös
freshly turned earth, still spinning on a wheel far slower than you first notice. one low raking light does all of it, and the ground keeps no shine of its own. swap the theme and the same furrows read as clay, or as brushed metal.
intensity · roomrsc · client
provenance
authored in usvalayer
atmospheresintensity
room · the whole environment. nothing competes with itcomposition
the ground under a hero or a full page region, children in normal flowtune the key with light; it changes more than any params tweaknot a panel fill. it is the room, never a texture inside a cardno blend mode on top: unlike the emissive atmospheres it composites normallya11y
the canvas isaria-hidden and pointer-events-none · reduced motion paints one still frame · no WebGL2 and it never mountsjest-axedependencies
ogl · atmospheres-core from the same packagelive demo · try it out
thrown, not printed
furrows turning on a wheel at about ninety seconds a revolution
customize
speedwheel and drift rate
opacitysurface strength, 0 to 1
modeauto lets the ground decide clay or metal
advanced24
light.xkey azimuth, left to right
light.ykey azimuth, up to down
light.zgrazing height, keep it low
light.colorthe key colour
origin · xwheel origin, forced off-frame
origin · ywheel origin, y up
spinradians per second about the origin
furrowFreqfurrows per unit radius
warpAmtradial warp in furrow spacings, 2 to 4.5
warpFreqspatial frequency of the warp noise
breakAmthow readily a furrow breaks
ridgeShapeprofile exponent, above 1 pinches the crest
depthfurrow depth
slopegain on the height gradient
microScalegrain frequency
microAmtgrain on the normal only
crackScalecraquelure cell frequency
crackAmtcraquelure strength
aocrevice occlusion strength
roughOren-Nayar roughness, 0 to 1
ambientambient fill
keykey light strength
drifthow fast the noise fields evolve on their own
ditherdither amplitude against banding
usage
import { Kynnos } from "usva/atmospheres/kynnos";
<Kynnos
light={{ direction: [-0.72, 0.5, 0.34] }}
className="min-h-svh"
>
<Hero />
</Kynnos>props
| prop | type | default | notes |
|---|---|---|---|
| children | ReactNode | — | rendered above the surface, in normal flow. |
| speed | number | 1 | wheel and drift rate multiplier. 1 is about ninety seconds per revolution. |
| mode | "emissive" | "absorptive" | — | overrides the material. by default the ground decides: light clay on a light theme, brushed metal on a dark one. |
| light | { direction?; color? } | — | the key light. keep direction z low or the grooves stop throwing shadows. colour defaults to the theme: warm daylight on clay, the accent on metal. |
| opacity | number | 1 | overall opacity, 0 to 1. |
| params | Partial<KynnosParams> | — | the surface escape hatch: origin, spin, furrowFreq, warpAmt, breakAmt, depth, microAmt, crackAmt and friends. origin is pushed off-frame and warpAmt is clamped to roughly 2 to 4.5 furrow spacings, so the rings can never read as a vinyl record. |
get it
npx shadcn add https://usva.build/r/kynnos.jsonsource · components/ui/kynnos-field.tsexactly what this command copies
import type { BlendMode, Rgb } from "./atmospheres-color";
export interface KynnosParams {
/** Wheel origin in normalized units, y up, relative to the container centre. */
origin: [number, number];
/** Radians per second. One revolution per ~10min at the default. */
spin: number;
/** Furrows per unit radius. */
furrowFreq: number;
/** Radial domain warp, measured in furrow spacings, not in radius units. */
warpAmt: number;
/** Spatial frequency of the warp noise. */
warpFreq: number;
/** How readily a furrow thins out and breaks, 0..0.5. */
breakAmt: number;
/** Ridge profile exponent. Above 1 the floor broadens and the crest pinches. */
ridgeShape: number;
/** Furrow depth in normalized units. */
depth: number;
/** Gain on the height gradient before the normal is built. */
slope: number;
/** Grain frequency. */
microScale: number;
/** Grain strength. Perturbs the normal only, never the height. */
microAmt: number;
/** Craquelure cell frequency. */
crackScale: number;
/** Craquelure strength. */
crackAmt: number;
/** Crevice occlusion strength. */
ao: number;
/** Oren-Nayar roughness. Clay is near-Lambertian and then some. */
rough: number;
/** Ambient fill. */
ambient: number;
/** Key light strength. */
key: number;
/** How fast the noise fields evolve on their own, apart from the wheel. */
drift: number;
/** Dither amplitude. Banding is visible on light grounds. */
dither: number;
}
export const KYNNOS_DEFAULTS: KynnosParams = {
origin: [0.62, 1],
spin: 0.01,
furrowFreq: 10,
warpAmt: 4.4,
warpFreq: 2.1,
breakAmt: 0.1,
ridgeShape: 1.5,
depth: 0.06,
slope: 2,
microScale: 190,
microAmt: 0.38,
crackScale: 70,
crackAmt: 0.05,
ao: 0.75,
rough: 1,
ambient: 0.5,
key: 1.05,
drift: 0.028,
dither: 0,
};
/**
* Concentric circles are one bad parameter from a vinyl record, so the two
* defences are floored here rather than left to whoever tunes the props next.
*
* The warp is in furrow spacings and the shader's warp field is normalized to
* about [-1, 1], so a peak displacement of MIN_WARP_TURNS grooves is what the
* number actually buys. Below ~2 the rings merely wobble and stay legible as
* rings. Well above ~4 the warp gradient overtakes the radial one, grooves fold
* back on themselves and the surface turns to mush.
*/
export const MIN_WARP_TURNS = 2;
export const MAX_WARP_TURNS = 4.5;
/**
* The wheel origin has to sit outside the frame or its convergence point is
* visible, and a visible convergence point is the vinyl label. Whichever axis is
* the shorter one spans exactly [-0.5, 0.5] in shader units, so forcing both
* components past that puts the origin off-frame at every aspect ratio.
*/
export const MIN_ORIGIN_OFFSET = 0.62;
function offFrame(v: number): number {
const sign = v < 0 ? -1 : 1;
return sign * Math.max(Math.abs(v), MIN_ORIGIN_OFFSET);
}
export function resolveParams(
overrides: Partial<KynnosParams> = {},
): KynnosParams {
const merged = { ...KYNNOS_DEFAULTS, ...overrides };
return {
...merged,
origin: [offFrame(merged.origin[0]), offFrame(merged.origin[1])],
furrowFreq: Math.max(merged.furrowFreq, 1),
warpAmt: Math.min(Math.max(merged.warpAmt, MIN_WARP_TURNS), MAX_WARP_TURNS),
ridgeShape: Math.max(merged.ridgeShape, 1),
breakAmt: Math.min(Math.max(merged.breakAmt, 0), 0.5),
rough: Math.min(Math.max(merged.rough, 0), 1),
};
}
/** The furrow profile: 0 in the floor, 1 on the crest. */
export function ridge(x: number, shape: number): number {
const phase = x - Math.floor(x);
const tri = 1 - Math.abs(phase * 2 - 1);
return tri ** shape;
}
/** Non-linear luma, matching the clamp the shader applies in sRGB space. */
export function luma([r, g, b]: Rgb): number {
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
function mix(a: Rgb, b: Rgb, t: number): Rgb {
return [
a[0] + (b[0] - a[0]) * t,
a[1] + (b[1] - a[1]) * t,
a[2] + (b[2] - a[2]) * t,
];
}
export type KynnosRole =
| "bg"
| "surface"
| "surface-2"
| "ink"
| "accent"
| "accent-alt";
export const KYNNOS_ROLES = [
"bg",
"surface",
"surface-2",
"ink",
"accent",
"accent-alt",
] as const;
export interface KynnosColors {
/** The clay itself, the value most of the frame sits at. */
body: Rgb;
/** The dry pale crest. The brightest pixel the atmosphere is allowed. */
ridge: Rgb;
/** The warm crescent inside the groove. */
shadow: Rgb;
/** Key light colour. Only the dark-ground material tints by it. */
key: Rgb;
/** Hard ceiling on output luma. */
maxLum: number;
}
/**
* The theme swap flips the lighting model, not the shader. On a light ground the
* clay is pigment: nothing brighter than surface-2, nothing darker than an
* occlusion shadow. On a dark ground the same relief becomes brushed metal, lit
* by a grazing accent key that only the crests catch.
*/
export function buildColors(
roles: Record<KynnosRole, Rgb>,
mode: BlendMode,
keyColor?: Rgb,
): KynnosColors {
if (mode === "absorptive") {
const warm = mix(roles["accent-alt"], roles.ink, 0.55);
const shadow = mix(mix(roles.bg, warm, 0.62), roles.accent, 0.12);
const ridge = roles["surface-2"];
return {
body: mix(roles.bg, roles.surface, 0.35),
ridge,
shadow,
key: keyColor ?? ridge,
maxLum: luma(ridge),
};
}
return {
body: roles.bg,
ridge: mix(roles.bg, roles.accent, 0.9),
shadow: mix(roles.bg, [0, 0, 0], 0.45),
key: keyColor ?? roles.accent,
maxLum: 1,
};
}
/** A raking key. Low z is what makes a 2cm groove throw a shadow. */
export const DEFAULT_LIGHT: [number, number, number] = [-0.72, 0.5, 0.54];
source · components/ui/kynnos-shader.tsexactly what this command copies
import { glsl } from "./atmospheres-glsl";
/**
* kynnos is a lit heightfield, not a volume: one clay surface seen from above,
* turning on a wheel far slower than you first notice. It reflects, it never
* emits. There is no specular term anywhere below and there must never be one:
* a lobe of any kind turns fired clay into wet glaze, and matte is the brief.
*/
export const kynnosFragmentShader = /* glsl */ `#version 300 es
precision highp float;
uniform float uTime;
uniform vec2 uResolution;
uniform vec2 uOrigin;
uniform float uSpin;
uniform float uFurrowFreq;
uniform float uWarpAmt;
uniform float uWarpFreq;
uniform float uBreakAmt;
uniform float uRidgeShape;
uniform float uDepth;
uniform float uSlope;
uniform float uMicroScale;
uniform float uMicroAmt;
uniform float uCrackScale;
uniform float uCrackAmt;
uniform float uAo;
uniform float uRough;
uniform float uAmbient;
uniform float uKey;
uniform float uDrift;
uniform float uDither;
uniform float uAlpha;
uniform float uAbsorb;
uniform float uMaxLum;
uniform vec3 uLightDir;
uniform vec3 uBody;
uniform vec3 uRidge;
uniform vec3 uShadow;
uniform vec3 uKeyColor;
out vec4 fragColor;
${glsl("fbm", "worley", "dither", "composite")}
const vec3 VIEW = vec3(0.0, 0.0, 1.0);
float ridgeProfile(float x) {
float tri = 1.0 - abs(fract(x) * 2.0 - 1.0);
return pow(tri, uRidgeShape);
}
/* fbm2 sums halving amplitudes from 0.5, so 3 octaves land in [0, 0.875] and 2
in [0, 0.75]. Recentre and rescale, or the warp is a third of the amplitude
the parameter claims and the rings survive it. */
float signedFbm3(vec2 p) {
return (fbm2(p, 3) - 0.4375) / 0.4375;
}
float signedFbm2(vec2 p) {
return (fbm2(p, 2) - 0.375) / 0.375;
}
float lumaOf(vec3 c) {
return dot(c, vec3(0.2126, 0.7152, 0.0722));
}
/* Oren-Nayar: retroreflective, so the surface stays flat and powdery at grazing
angles instead of rolling off like plastic. Diffuse only, by law. */
float orenNayar(vec3 n, vec3 l, float rough) {
float s2 = rough * rough;
float a = 1.0 - 0.5 * s2 / (s2 + 0.33);
float b = 0.45 * s2 / (s2 + 0.09);
float ndl = dot(n, l);
float ndv = dot(n, VIEW);
float thetaL = acos(clamp(ndl, -1.0, 1.0));
float thetaV = acos(clamp(ndv, -1.0, 1.0));
float alpha = max(thetaL, thetaV);
float beta = min(thetaL, thetaV);
vec3 lp = l - n * ndl;
vec3 vp = VIEW - n * ndv;
float cosPhi = 0.0;
if (length(lp) > 1e-4 && length(vp) > 1e-4) {
cosPhi = max(dot(normalize(lp), normalize(vp)), 0.0);
}
return max(ndl, 0.0) * (a + b * cosPhi * sin(alpha) * tan(min(beta, 1.5)));
}
void main() {
vec2 frag = gl_FragCoord.xy;
float unit = max(min(uResolution.x, uResolution.y), 1.0);
vec2 p = (frag - 0.5 * uResolution) / unit;
vec2 q = p - uOrigin;
float r = length(q);
float spin = uTime * uSpin;
float cs = cos(spin);
float sn = sin(spin);
vec2 qr = vec2(cs * q.x - sn * q.y, sn * q.x + cs * q.y);
/* The named risk. Unwarped concentric furrows are a vinyl record, and the
warp is what stops the rings reading as rings. It is load-bearing, and so is
the off-frame origin: both are floored in resolveParams. uWarpAmt counts
furrow spacings, so the displacement is several grooves and neighbouring
furrows genuinely cross each other's old radius rather than wobbling in
lockstep. The broad term shoves whole arcs off the circle; the finer term
undulates them at the scale of a few grooves. */
float w1 = signedFbm3(qr * uWarpFreq + vec2(0.0, uTime * uDrift));
float w2 = signedFbm2(qr * uWarpFreq * 2.7 + vec2(19.7, -4.3));
float spacing = 1.0 / uFurrowFreq;
float rw = r + uWarpAmt * spacing * (w1 + 0.22 * w2);
float breakField = fbm2(qr * 1.6 + vec2(7.3, uTime * uDrift * 0.6), 3);
float alive = smoothstep(0.5 - uBreakAmt, 0.5 + uBreakAmt, breakField + 0.14);
float thick = 0.55 + 0.9 * fbm2(qr * 0.85 + vec2(3.1, 11.9), 2);
float x = rw * uFurrowFreq;
float profile = ridgeProfile(x);
float h = uDepth * thick * alive * profile;
float e = 0.22;
float lap = ridgeProfile(x + e) - 2.0 * profile + ridgeProfile(x - e);
float cavity = pow(1.0 - profile, 1.6);
float occ = uAo * alive * thick * (0.7 * cavity + 0.5 * clamp(lap * 6.0, 0.0, 1.0));
float ao = 1.0 - clamp(occ, 0.0, 0.88);
float px = 1.0 / unit;
vec2 grad = vec2(dFdx(h), dFdy(h)) / px;
vec3 n = normalize(vec3(-grad * uSlope, 1.0));
/* Grain perturbs the normal and never the height. Fold it into h instead and
the occlusion term eats it, and the clay reads as moulded latex, not grog. */
vec2 gp = qr * uMicroScale;
float g0 = vnoise2(gp);
vec2 dg = vec2(
vnoise2(gp + vec2(0.6, 0.0)) - g0,
vnoise2(gp + vec2(0.0, 0.6)) - g0
);
n = normalize(n + vec3(dg * uMicroAmt, 0.0));
// halkeama: drying cracks, and only where clay pools, so only in the floors.
float crack = (1.0 - smoothstep(0.0, 0.06, craquelure(qr * uCrackScale)))
* (1.0 - smoothstep(0.15, 0.6, profile));
vec2 dc = vec2(dFdx(crack), dFdy(crack)) / px;
n = normalize(n - vec3(dc * uCrackAmt * 0.02, 0.0));
vec3 l = normalize(uLightDir);
float diff = orenNayar(n, l, uRough);
float lit = (uAmbient + uKey * diff) * ao * (1.0 - 0.35 * crack * uCrackAmt);
vec3 pigment = mix(uShadow, uBody, smoothstep(0.05, 0.62, lit));
pigment = mix(pigment, uRidge, smoothstep(0.62, 1.05, lit));
/* Dark ground: same relief, different material. A grazing key that only the
crests catch, furrows falling back to the ground. Brushed metal, not clay. */
float caught = pow(clamp(diff, 0.0, 1.0), 5.0);
float crest = profile * profile * profile * profile * profile;
float grazing = 1.0 - abs(n.z);
grazing *= grazing;
float ridgeCatch = crest * grazing * caught;
vec3 metal = uBody + uKeyColor * ((caught * uKey * 1.15 + ridgeCatch * 0.55) * ao)
+ (uShadow - uBody) * (1.0 - ao);
vec3 col = mix(metal, pigment, uAbsorb);
float lum = lumaOf(col);
if (lum > uMaxLum) col *= uMaxLum / max(lum, 1e-4);
col = dither(col, frag, uDither);
fragColor = composite(col, uAlpha);
}
`;
source · components/ui/kynnos-uniforms.tsexactly what this command copies
import {
setUniform,
type Uniforms,
} from "./atmospheres-gl";
import type { KynnosColors, KynnosParams } from "./kynnos-field";
export interface KynnosFrame {
time: number;
alpha: number;
absorb: number;
light: [number, number, number];
}
export function kynnosUniforms(
colors: KynnosColors,
params: KynnosParams,
light: [number, number, number],
): Uniforms {
return {
uTime: { value: 0 },
uResolution: { value: [1, 1] },
uOrigin: { value: [params.origin[0], params.origin[1]] },
uSpin: { value: params.spin },
uFurrowFreq: { value: params.furrowFreq },
uWarpAmt: { value: params.warpAmt },
uWarpFreq: { value: params.warpFreq },
uBreakAmt: { value: params.breakAmt },
uRidgeShape: { value: params.ridgeShape },
uDepth: { value: params.depth },
uSlope: { value: params.slope },
uMicroScale: { value: params.microScale },
uMicroAmt: { value: params.microAmt },
uCrackScale: { value: params.crackScale },
uCrackAmt: { value: params.crackAmt },
uAo: { value: params.ao },
uRough: { value: params.rough },
uAmbient: { value: params.ambient },
uKey: { value: params.key },
uDrift: { value: params.drift },
uDither: { value: params.dither },
uAlpha: { value: 1 },
uAbsorb: { value: 1 },
uMaxLum: { value: colors.maxLum },
uLightDir: { value: [light[0], light[1], light[2]] },
uBody: { value: colors.body },
uRidge: { value: colors.ridge },
uShadow: { value: colors.shadow },
uKeyColor: { value: colors.key },
};
}
export function setKynnosColors(u: Uniforms, colors: KynnosColors): void {
setUniform(u, "uBody", colors.body);
setUniform(u, "uRidge", colors.ridge);
setUniform(u, "uShadow", colors.shadow);
setUniform(u, "uKeyColor", colors.key);
setUniform(u, "uMaxLum", colors.maxLum);
}
export function setKynnosParams(u: Uniforms, params: KynnosParams): void {
setUniform(u, "uOrigin", [params.origin[0], params.origin[1]]);
setUniform(u, "uSpin", params.spin);
setUniform(u, "uFurrowFreq", params.furrowFreq);
setUniform(u, "uWarpAmt", params.warpAmt);
setUniform(u, "uWarpFreq", params.warpFreq);
setUniform(u, "uBreakAmt", params.breakAmt);
setUniform(u, "uRidgeShape", params.ridgeShape);
setUniform(u, "uDepth", params.depth);
setUniform(u, "uSlope", params.slope);
setUniform(u, "uMicroScale", params.microScale);
setUniform(u, "uMicroAmt", params.microAmt);
setUniform(u, "uCrackScale", params.crackScale);
setUniform(u, "uCrackAmt", params.crackAmt);
setUniform(u, "uAo", params.ao);
setUniform(u, "uRough", params.rough);
setUniform(u, "uAmbient", params.ambient);
setUniform(u, "uKey", params.key);
setUniform(u, "uDrift", params.drift);
setUniform(u, "uDither", params.dither);
}
export function setKynnosFrame(u: Uniforms, frame: KynnosFrame): void {
setUniform(u, "uTime", frame.time);
setUniform(u, "uAlpha", frame.alpha);
setUniform(u, "uAbsorb", frame.absorb);
setUniform(u, "uLightDir", [frame.light[0], frame.light[1], frame.light[2]]);
}
source · components/ui/kynnos.tsxexactly what this command copies
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import {
type BlendMode,
blendUniform,
resolveBlendMode,
resolveColor,
} from "./atmospheres-color";
import { useGlCanvas } from "./use-gl-canvas";
import { useTokenColors } from "./use-token-colors";
import {
buildColors,
DEFAULT_LIGHT,
KYNNOS_ROLES,
type KynnosParams,
resolveParams,
} from "./kynnos-field";
import { kynnosFragmentShader } from "./kynnos-shader";
import {
kynnosUniforms,
setKynnosColors,
setKynnosFrame,
setKynnosParams,
} from "./kynnos-uniforms";
export interface KynnosLight {
/** Raking is the point: keep z low or the grooves stop throwing shadows. */
direction?: [number, number, number];
/** Defaults to the theme: a warm daylight on clay, the accent on metal. */
color?: string;
}
export interface KynnosProps extends React.HTMLAttributes<HTMLDivElement> {
/** Wheel and drift rate multiplier. Defaults to 1, about 10min per revolution. */
speed?: number;
/** Overrides the material. Defaults from the ground: light clay, dark metal. */
mode?: BlendMode;
/** The lighting dial. This is what a theme swap actually changes. */
light?: KynnosLight;
/** Overall opacity, 0..1. Defaults to 1. */
opacity?: number;
/** Escape hatch for the field parameters, for tuning demos. */
params?: Partial<KynnosParams>;
children?: React.ReactNode;
}
/** Enough seconds in that the still frame lands on structure, not on seed noise. */
const STILL_TIME = 14;
export const Kynnos = React.forwardRef<HTMLDivElement, KynnosProps>(
(
{
speed = 1,
mode,
light,
opacity = 1,
params,
className,
children,
...props
},
forwardedRef,
) => {
const lightColor = light?.color;
const direction = light?.direction;
const speedRef = React.useRef(speed);
speedRef.current = speed;
const opacityRef = React.useRef(opacity);
opacityRef.current = opacity;
const scopeRef = React.useRef<HTMLDivElement | null>(null);
const tokens = useTokenColors(KYNNOS_ROLES, { scopeRef });
const blend = resolveBlendMode(mode, tokens.bg);
const colors = React.useMemo(
() =>
buildColors(
tokens.colors,
blend,
lightColor ? resolveColor(lightColor) : undefined,
),
[tokens.colors, blend, lightColor],
);
const colorsRef = React.useRef(colors);
colorsRef.current = colors;
const resolved = resolveParams(params);
const paramsRef = React.useRef(resolved);
paramsRef.current = resolved;
const lightDir = direction ?? DEFAULT_LIGHT;
const lightRef = React.useRef(lightDir);
lightRef.current = lightDir;
const absorbRef = React.useRef(blendUniform(blend));
absorbRef.current = blendUniform(blend);
const canvas = useGlCanvas({
fragment: kynnosFragmentShader,
stillTime: STILL_TIME,
maxDpr: 1.5,
uniforms: () =>
kynnosUniforms(colorsRef.current, paramsRef.current, lightRef.current),
onFrame: (u, frame) => {
setKynnosColors(u, colorsRef.current);
setKynnosParams(u, paramsRef.current);
setKynnosFrame(u, {
time: frame.time * speedRef.current,
alpha: opacityRef.current,
absorb: absorbRef.current,
light: lightRef.current,
});
},
});
const { redraw } = canvas;
// biome-ignore lint/correctness/useExhaustiveDependencies: the still frame must repaint when the material or its light changes.
React.useEffect(() => {
redraw();
}, [redraw, colors, lightDir, 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-material={blend === "absorptive" ? "clay" : "metal"}
className={cn("relative isolate overflow-hidden", className)}
{...props}
>
{on ? (
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 -z-10"
>
{/* No mix-blend-mode here, unlike the emissive atmospheres. A multiply
canvas can only darken, and a dry ridge has to sit above bg, up
to surface-2. kynnos is the ground rather than a stain on it, so it
composites normally and the mode flips the lighting model. */}
<canvas ref={canvas.canvasRef} className="block h-full w-full" />
</div>
) : null}
{children}
</div>
);
},
);
Kynnos.displayName = "Kynnos";