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 usva

layer

atmospheres

intensity

room · the whole environment. nothing competes with it

composition

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 normally

a11y

the canvas is aria-hidden and pointer-events-none · reduced motion paints one still frame · no WebGL2 and it never mountsjest-axe

dependencies

ogl · atmospheres-core from the same package
live demo · try it out

thrown, not printed

furrows turning on a wheel at about ninety seconds a revolution

customize
speedwheel and drift rate
1
opacitysurface strength, 0 to 1
1
modeauto lets the ground decide clay or metal
advanced24
light.xkey azimuth, left to right
-0.72
light.ykey azimuth, up to down
0.5
light.zgrazing height, keep it low
0.34
light.colorthe key colour
origin · xwheel origin, forced off-frame
-1.05
origin · ywheel origin, y up
0.78
spinradians per second about the origin
0.07
furrowFreqfurrows per unit radius
16
warpAmtradial warp in furrow spacings, 2 to 4.5
2.8
warpFreqspatial frequency of the warp noise
2.1
breakAmthow readily a furrow breaks
0.22
ridgeShapeprofile exponent, above 1 pinches the crest
1.9
depthfurrow depth
0.03
slopegain on the height gradient
1
microScalegrain frequency
180
microAmtgrain on the normal only
0.34
crackScalecraquelure cell frequency
90
crackAmtcraquelure strength
0.5
aocrevice occlusion strength
0.9
roughOren-Nayar roughness, 0 to 1
0.9
ambientambient fill
0.42
keykey light strength
0.78
drifthow fast the noise fields evolve on their own
0.012
ditherdither amplitude against banding
0.006
usagecopy
import { Kynnos } from "usva/atmospheres/kynnos";

<Kynnos
  light={{ direction: [-0.72, 0.5, 0.34] }}
  className="min-h-svh"
>
  <Hero />
</Kynnos>

props

proptypedefaultnotes
childrenReactNoderendered above the surface, in normal flow.
speednumber1wheel 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.
opacitynumber1overall opacity, 0 to 1.
paramsPartial<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.jsoncopy
source · components/ui/kynnos-field.tsexactly what this command copiescopy
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 copiescopy
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 copiescopy
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 copiescopy
"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";