Backgrounds · BG-04
Chromatic Leaks
CSS vars · rAF · Scroll for docs ↓
Colour from every corner — centre stays clear
Four transparent radial-gradient layers (pattern-craft style) are anchored to each corner of the composition. Each radial drifts on its own Lissajous path and breathes out-of-phase from the others. Mouse movement creates a parallax: all four corners shift gently toward the cursor, making the colour field feel alive. Centre remains fully transparent.
Procedure
Execute one of the following in a project that already uses Tailwind CSS and the shadcn CLI.
npx shadcn@latest add https://ui.philippekam.dev/r/chromatic-leaks.jsonnpm install clsx tailwind-mergeSingle-item JSON installs require kam-utils — registry items declare registryDependencies for cn().
Props
| Name | Type | Default | Notes |
|---|---|---|---|
| color1 | string | '#7c3aed' | Top-left corner radial colour. |
| color2 | string | '#f97316' | Bottom-right corner radial colour. |
| color3 | string | '#0ea5e9' | Top-right corner radial colour. |
| color4 | string | '#10b981' | Bottom-left corner radial colour. |
| size | number | 110 | Gradient ellipse size as % of container. |
| clearStop | number | 42 | Transparent zone radius — larger = more of the centre is clear. |
| drift | number | 8 | Autonomous drift radius per radial in percent. |
| speed | number | 0.4 | Animation speed multiplier. |
| breathe | boolean | true | Each corner radial's clearStop oscillates independently. |
| breatheAmplitude | number | 10 | Breathing amplitude in percentage points. |
| mouseReact | boolean | true | All four radials shift gently toward the cursor. |
| className | string | — | Optional class on the root element. |
Peers
Dependencies
Peer packages this component expects alongside the copied source in your app.
Components import `@/lib/utils` for `cn()`. Point `@` at your `src` folder (this repo sets it in `tsconfig.json` and `astro.config.mjs`). Some items also need `kam-containment` from the registry (`lib/containment.ts`) for viewport pause + motion queries. The shadcn installer adds `kam-utils` (and containment when listed) via registryDependencies when you use the commands above.
reacttailwindcssclsxtailwind-mergekam-containment (registry lib — copy `lib/containment.ts`)
Usage
npx shadcn@latest add https://ui.philippekam.dev/r/chromatic-leaks.jsonimport { ChromaticLeaks } from "@/components/ui/chromatic-leaks";
// As a transparent overlay — four corners leak colour, centre stays clear.
export function Hero() {
return (
<section className="relative min-h-[60vh] w-full overflow-hidden bg-zinc-950">
<ChromaticLeaks
color1="#7c3aed"
color2="#f97316"
color3="#0ea5e9"
color4="#10b981"
clearStop={42}
drift={8}
/>
{/* Content sits above — centre is transparent so it remains readable */}
<div className="relative z-10 flex h-full items-center justify-center p-8">
<h1 className="text-4xl font-bold text-white">Your heading here</h1>
</div>
</section>
);
}
// Two-colour variant — opposite corners for a split-diagonal feel
export function SplitHero() {
return (
<section className="relative min-h-[60vh] w-full overflow-hidden bg-zinc-950">
<ChromaticLeaks color1="#6366f1" color2="#ec4899" color3="transparent" color4="transparent" clearStop={50} />
<div className="relative z-10 p-8">Content</div>
</section>
);
}Source
'use client';
import { useEffect, useRef } from 'react';
import { cn } from '@/lib/utils';
import { mergeRefs, resolvePerformanceQuality, useAutoPerformanceQuality, useInView, usePrefersReducedMotion, type PerformanceQuality } from '@/lib/containment';
// ─── Types ────────────────────────────────────────────────────────────────────
export type ChromaticLeaksProps = {
/** Top-left corner radial color. */
color1?: string;
/** Bottom-right corner radial color. */
color2?: string;
/** Top-right corner radial color. */
color3?: string;
/** Bottom-left corner radial color. */
color4?: string;
/** Gradient ellipse size as % of container. @default 110 */
size?: number;
/**
* Transparent zone radius (0–100). Larger = more of the center is clear.
* @default 42
*/
clearStop?: number;
/** Autonomous drift radius for each radial in percent. @default 8 */
drift?: number;
/** Animation speed multiplier. @default 0.4 */
speed?: number;
/** Color zones breathe — clearStop oscillates independently per radial. @default true */
breathe?: boolean;
/** Breathing amplitude in percentage points. @default 10 */
breatheAmplitude?: number;
/**
* Focal points follow mouse (react-bits style smooth lerp).
* Each corner has a different attraction strength, creating a parallax effect.
* @default true
*/
mouseReact?: boolean;
quality?: PerformanceQuality;
className?: string;
/** React 19: passed as a plain prop — no forwardRef needed. */
ref?: React.Ref<HTMLDivElement>;
};
// ─── Constants ────────────────────────────────────────────────────────────────
/** Base corner positions for the 4 radials [x%, y%]. */
const BASES: [number, number][] = [
[7, 7], // color1 — top-left
[93, 93], // color2 — bottom-right
[93, 7], // color3 — top-right
[7, 93], // color4 — bottom-left
];
/** Lissajous frequency multipliers per radial (makes each wander differently). */
const FREQ = [[1.0, 0.73], [0.9, 0.81], [0.7, 1.1], [1.3, 0.65]] as const;
/** Phase offsets so the four radials are never in sync. */
const PHASE = [0, Math.PI, Math.PI * 0.6, Math.PI * 1.4] as const;
/** Mouse attraction scale per corner (near corners attract more strongly). */
const MOUSE_SCALE = [0.09, 0.09, 0.09, 0.09] as const;
// ─── Component ────────────────────────────────────────────────────────────────
/**
* Four animated corner radials — each leaks colour from its corner while leaving
* the centre of the composition transparent.
*
* react-bits animation model (same as RadiantVeil):
* 1. **Lissajous drift** — each radial wanders on its own independent path.
* 2. **Breathe** — each radial's clearStop oscillates out-of-phase.
* 3. **Mouse parallax** — all four corners drift gently toward the cursor,
* creating a field-of-light following effect.
*
* Always-on rAF (CSS var updates cost ~0 CPU). No inView gate.
* Reduced-motion: static gradients, no rAF.
*
* React 19: `ref` is a plain prop — no `forwardRef` wrapper.
*/
export function ChromaticLeaks({
ref,
className,
color1 = '#7c3aed',
color2 = '#f97316',
color3 = '#0ea5e9',
color4 = '#10b981',
size = 110,
clearStop = 42,
drift = 8,
speed = 0.4,
breathe = true,
breatheAmplitude = 10,
mouseReact = true,
quality = 'auto',
}: ChromaticLeaksProps) {
const reducedMotion = usePrefersReducedMotion();
const autoQuality = useAutoPerformanceQuality();
const resolvedQuality = resolvePerformanceQuality(quality, autoQuality);
const { ref: inViewRef, inView } = useInView();
const rootRef = useRef<HTMLDivElement>(null);
const loopControlsRef = useRef<{ start: () => void; stop: () => void } | null>(null);
const speedScale = resolvedQuality === 'low' ? 0.7 : resolvedQuality === 'medium' ? 0.85 : 1;
const motionScale = resolvedQuality === 'low' ? 0.6 : resolvedQuality === 'medium' ? 0.8 : 1;
const colors = [color1, color2, color3, color4];
useEffect(() => {
const el = rootRef.current;
if (!el) return;
// Seed static values immediately — no flash before rAF fires
BASES.forEach(([bx, by], i) => {
el.style.setProperty(`--cl${i}x`, `${bx}%`);
el.style.setProperty(`--cl${i}y`, `${by}%`);
el.style.setProperty(`--cl${i}s`, `${clearStop}%`);
});
if (reducedMotion) return;
const mouse = { x: 50, y: 50 };
const lerped = BASES.map(([bx, by]) => ({ x: bx, y: by }));
function onMouseMove(e: MouseEvent) {
const rect = el!.getBoundingClientRect();
mouse.x = ((e.clientX - rect.left) / rect.width) * 100;
mouse.y = ((e.clientY - rect.top) / rect.height) * 100;
}
if (mouseReact) window.addEventListener('mousemove', onMouseMove, { passive: true });
let rafId: number | null = null;
const t0 = performance.now();
function update(now: number) {
rafId = null;
if (!inView) return;
startLoop();
const t = (now - t0) * 0.001 * speed * speedScale;
BASES.forEach(([bx, by], i) => {
const [fx, fy] = FREQ[i]!;
const ph = PHASE[i]!;
// ① Autonomous Lissajous drift per radial
const dx = Math.sin(t * fx + ph) * drift * motionScale;
const dy = Math.cos(t * fy + ph) * drift * motionScale;
// ② Mouse parallax — gentle bias toward cursor, each corner independently
if (mouseReact) {
const ms = MOUSE_SCALE[i]!;
const tx = bx + (mouse.x - 50) * ms + dx;
const ty = by + (mouse.y - 50) * ms + dy;
lerped[i]!.x += 0.03 * (tx - lerped[i]!.x);
lerped[i]!.y += 0.03 * (ty - lerped[i]!.y);
el!.style.setProperty(`--cl${i}x`, `${lerped[i]!.x}%`);
el!.style.setProperty(`--cl${i}y`, `${lerped[i]!.y}%`);
} else {
el!.style.setProperty(`--cl${i}x`, `${bx + dx}%`);
el!.style.setProperty(`--cl${i}y`, `${by + dy}%`);
}
// ③ Independent breathing with per-radial phase offset
if (breathe) {
const cs = clearStop + Math.sin(t * 0.7 + ph * 0.5) * breatheAmplitude * motionScale;
el!.style.setProperty(`--cl${i}s`, `${Math.max(5, cs)}%`);
}
});
}
function stopLoop() {
if (rafId !== null) {
cancelAnimationFrame(rafId);
rafId = null;
}
}
function startLoop() {
if (rafId === null) {
rafId = requestAnimationFrame(update);
}
}
loopControlsRef.current = { start: startLoop, stop: stopLoop };
if (inView) startLoop();
return () => {
stopLoop();
loopControlsRef.current = null;
if (mouseReact) window.removeEventListener('mousemove', onMouseMove);
};
}, [reducedMotion, drift, speed, breathe, breatheAmplitude, clearStop, mouseReact, motionScale, speedScale]);
useEffect(() => {
const controls = loopControlsRef.current;
if (!controls) return;
if (inView) controls.start();
else controls.stop();
}, [inView]);
// Colors are prop-literals in the gradient string; positions animate via CSS vars.
const bgLayers = colors.map((color, i) =>
`radial-gradient(${size}% ${size}% at var(--cl${i}x, ${BASES[i]![0]}%) var(--cl${i}y, ${BASES[i]![1]}%), transparent var(--cl${i}s, ${clearStop}%), ${color} 100%)`
);
return (
<div
ref={mergeRefs(ref, inViewRef, rootRef)}
className={cn('pointer-events-none absolute inset-0', className)}
style={{ background: bgLayers.join(', ') }}
aria-hidden
/>
);
}