Skip to content
Template Empire
Browse components

Caustic Light

A free animated caustics background rendered as a WebGL shader.

Caustic Light

Light that behaves like light

Refracted threads drift and interfere across the surface, computed per pixel and dithered so the falloff never bands.

Customise

1x
0.6
3
2.4
7
0.35
3
1

Code

<BgCaustics />

Caustic Light is a free animated caustics background for Next.js and React, styled with Tailwind and rendered as a single WebGL2 fragment shader with no dependencies. It drops in as an absolutely positioned fill layer inside any relative container, with your content layered above it.

The filaments are the zero crossings of a domain-warped noise field, narrowed by a power curve so the threads interfere and braid rather than pooling like blurred blobs. Colour is mixed in OKLab and dithered with interleaved gradient noise, so the falloff stays perceptually even and never bands on dark screens.

Install

Add the @te registry to your components.json once:

{
  "registries": {
    "@te": "https://templateempire.io/r/{name}.json"
  }
}

Then install the component:

npx shadcn@latest add @te/bg-caustics

Usage

"use client";

import { lazy, Suspense } from "react";

// Lazily loaded so the shader and its setup stay out of the initial bundle.
// React.lazy rather than next/dynamic, so this works in Vite and CRA too.
const BgCaustics = lazy(() =>
  import("@/components/bg-caustics").then((m) => ({ default: m.BgCaustics })),
);

export function Example() {
  return (
    <section className="relative isolate overflow-hidden">
      {/* The fallback is the same finished gradient the component paints, so
          a cold chunk shows the composition rather than a flat panel. The base
          is opaque and only the gradients are faded, exactly as the component
          does it. Fading both would change the surface when the chunk lands. */}
      <Suspense
        fallback={
          <div
            aria-hidden="true"
            className="pointer-events-none absolute inset-0"
            style={{ background: "var(--te-caustics-base, var(--background))" }}
          >
            <div
              className="absolute inset-0"
              style={{
                background:
                  "radial-gradient(120% 90% at 30% 20%, var(--te-caustics-tint, var(--primary)) 0%, transparent 55%), radial-gradient(90% 70% at 75% 80%, var(--te-caustics-tint, var(--primary)) 0%, transparent 60%)",
                opacity: 0.3,
              }}
            />
          </div>
        }
      >
        <BgCaustics />
      </Suspense>
      {/* A scrim under the text. Measured over this component's own demo,
          small text came out at 3.30:1 against the filaments, under the 4.5:1
          AA needs — and no single foreground colour fixes it, because the
          field is brighter than the surface in places and dimmer in others. */}
      <div
        aria-hidden="true"
        className="absolute inset-0 opacity-70 [background:radial-gradient(85%_75%_at_50%_50%,var(--background)_0%,var(--background)_45%,transparent_100%)]"
      />
      <div className="relative z-10">
        <h2 className="text-4xl font-semibold">Your content here</h2>
        <p className="text-muted-foreground">The caustic field drifts behind it.</p>
      </div>
    </section>
  );
}

Props

PropTypeDefaultDescription
speednumber1Drift speed multiplier, clamped to 0.25 to 2. Lower is calmer.
intensitynumber0.6Peak brightness of the filaments, clamped to 0 to 1. Lower it for a calmer field, but do not rely on it for contrast: measured over the demo, small text sat at 3.30:1 against the filaments at this default. Put text on a scrim rather than tuning the field beneath it.
scalenumber3Size of the caustic field, clamped to 1 to 8. Lower zooms in for a few broad filaments; higher packs the surface with finer ones.
warpnumber2.4Refraction strength, clamped to 0 to 6. How far the noise displaces the primary filament field: 0 removes that domain warping while the underlying noise remains irregular. Higher values give the tangled look of light through moving water.
sharpnessnumber7Filament sharpness, clamped to 2 to 16. The exponent the field is raised to, so it is strongly non-linear: low values give wide soft washes, high values give thin bright threads.
depthnumber0.35How much of the slower, larger second layer shows through, clamped to 0 to 1. At 0 only the filaments render.
octavesnumber3Noise octaves, clamped to 1 to 5 and rounded. Each octave is another gradient-noise sample per pixel per frame, so this is the one control here with a real cost: 5 does roughly five times the work of 1.
vignettenumber1Vignette strength, clamped to 0 to 1. At 1 it attenuates the field toward the edges; at 0 that edge falloff is disabled. It does not darken the centre or provide text contrast. Put overlaid text on a scrim.
classNamestringundefinedAdditional classes merged onto the root fill layer.

Customisation

Caustic Light exposes two component tokens, declared in its registry item: --te-caustics-tint for the colour of the light, and --te-caustics-base for the surface it falls on. Both default to theme tokens, so the field follows your light and dark themes rather than pinning a fixed surface. They are re-read whenever the theme changes and mixed in OKLab, so any CSS colour works, including oklch() and lab(). A translucent value is composited over the surface beneath it rather than having its alpha dropped, so a token like --surface lands on the real backdrop. One limit worth knowing: that surface is rebuilt from the background colours of the ancestors, so if a translucent base sits over an ancestor painting a gradient or an image, the result is approximate: it resolves against the solid colour behind that gradient. An opaque base is exact and is the default. Override them from :root, or on any ancestor, to retheme without touching the code:

:root {
  --te-caustics-tint: var(--color-accent-400, var(--ring));
  --te-caustics-base: var(--background);
}

Frequently asked questions

Is Caustic Light free for commercial use?
Yes. Caustic Light is MIT licensed, so you can use it in personal and commercial projects without attribution. It also adds nothing to your dependency tree: it is raw WebGL2, not three.js, because a fullscreen fragment shader needs no scene graph, camera or loaders and installing a 3D engine to draw one quad would cost every visitor a large download for no benefit.
Does it affect performance or accessibility?
The render loop stops whenever the layer scrolls offscreen or the browser tab is hidden, and resumes where it left off rather than restarting. The WebGL context is created only when the layer first approaches the viewport, device pixel ratio is clamped at 2, nothing is allocated per frame, and every resource is released on unmount. The layer is aria-hidden and ignores pointer events, so it cannot intercept clicks or reach the accessibility tree. Under prefers-reduced-motion a single still frame is drawn and the animation never starts. A static gradient holds the same box underneath, so there is no layout shift and that gradient is simply the final state wherever WebGL is unavailable or JavaScript never runs.
How do I change the light colour or the surface it falls on?
Override the CSS custom properties --te-caustics-tint and --te-caustics-base from :root or any ancestor. Both default to your theme tokens, so the field follows your light and dark themes rather than pinning a fixed surface, and both are re-read when the theme changes. Any CSS colour works, including oklch() and lab(), and the two are mixed in OKLab so the tint falls off perceptually evenly.

Related components

Want the full page this belongs to?Template Empire builds complete Next.js templates with this motion system throughout. Browse the paid templates that pair with these free components.Explore Template Empire templates