backgrounds
Flowing Gradient
An animated ambient colour wash: three large overlapping radial-gradient blobs (--primary × 2, --accent × 1) drift across the layer via animated background-position on a 200%-canvas. Blob 1 (primary) orbits clockwise, blob 2 (accent) counter-clockwise — their colour interference creates a living wash that breathes and morphs. Clearly distinct from the static gradient-mesh: the whole surface flows rather than floating discrete orbs. Decorative: aria-hidden, pointer-events-none, fills its positioned parent.
flowing-gradient
Install
1. Register the namespace (once per project):
json
// components.json — register the @bottega namespace once
{
"registries": {
"@bottega": { "url": "https://bottega.ariacode.ca/r/{name}.json" }
}
}2. Add the component:
bash
npx shadcn add @bottega/flowing-gradientUsage
Usagetsx
<FlowingGradient />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| speed | enum | slow | Drift cycle duration. "slow" = 30 s, "normal" = 18 s, "fast" = 10 s. Default "slow" — ambient backdrops read better at glacial pace. |
| intensity | enum | subtle | Blob opacity: subtle (15%), medium (28%), vivid (48%). Default "subtle" — the light-on-light / dark-on-dark threshold is low; subtle stays legible on both themes. Increase for hero sections with dark parents. |
Source
flowing-gradient.tsxtsx
"use client";
import { useEffect, useRef, useState } from "react";
import { useReducedMotion } from "motion/react";
import type React from "react";
import styles from "./flowing-gradient.module.css";
export interface FlowingGradientProps
extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> {
/**
* Drift cycle duration. "slow" = 30 s, "normal" = 18 s, "fast" = 10 s.
* Default "slow" — ambient backdrops read better at glacial pace.
*/
speed?: "slow" | "normal" | "fast";
/**
* Blob opacity: subtle (15%), medium (28%), vivid (48%).
* Default "subtle" — the light-on-light / dark-on-dark threshold is low; subtle
* stays legible on both themes. Increase for hero sections with dark parents.
*/
intensity?: "subtle" | "medium" | "vivid";
}
// Bespoke ambient timings in seconds — NOT the --duration UI token (~300 ms).
const SPEED_SECONDS: Record<NonNullable<FlowingGradientProps["speed"]>, number> = {
slow: 30,
normal: 18,
fast: 10,
};
// Blob opacity as a CSS <percentage> consumed by color-mix() inside the module.
const INTENSITY_PCT: Record<NonNullable<FlowingGradientProps["intensity"]>, number> = {
subtle: 15,
medium: 28,
vivid: 48,
};
/**
* FlowingGradient — animated ambient colour-wash background layer.
*
* Three large overlapping radial-gradient blobs live on a 200% background
* canvas. Animating `background-position` independently per layer shifts each
* blob's centre across the element:
* blob-centre = ((100 − X)%, (100 − Y)%) when bg-pos = (X%, Y%)
*
* Blob 1 (--primary) orbits clockwise; blob 2 (--accent) counter-clockwise.
* Their colour interference produces the living wash; blob 3 (--primary, softer)
* oscillates through the centre zone for depth.
*
* Mechanically distinct from gradient-mesh (child divs + filter:blur +
* transform keyframes). Visually: the WHOLE surface breathes, not discrete orbs.
*
* Decorative contract: role="presentation" + aria-hidden="true" +
* pointer-events:none. Place sibling content at z-10. Reduced-motion users
* see the static rest-position gradient (no drift).
*/
export function FlowingGradient({
speed = "slow",
intensity = "subtle",
className = "",
style,
...props
}: FlowingGradientProps) {
// Contract mount-gate + null-safe reduce. null => treat as reduced (avoids #418).
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const prefersReducedMotion = useReducedMotion();
const reduce = prefersReducedMotion === null || prefersReducedMotion;
const animate = mounted && !reduce;
const rootRef = useRef<HTMLDivElement>(null);
const [inView, setInView] = useState(true);
// Pause the background-position animation when fully offscreen to save paint cost.
useEffect(() => {
if (!animate) return;
const el = rootRef.current;
if (!el || typeof IntersectionObserver === "undefined") return;
const io = new IntersectionObserver(
([entry]) => setInView(entry.isIntersecting),
{ rootMargin: "0px" },
);
io.observe(el);
return () => io.disconnect();
}, [animate]);
return (
<div
{...props}
ref={rootRef}
role="presentation"
aria-hidden="true"
className={[styles.root, animate ? styles.flowing : "", className]
.filter(Boolean)
.join(" ")}
style={
{
"--fg-dur": `${SPEED_SECONDS[speed]}s`,
"--fg-pct": `${INTENSITY_PCT[intensity]}%`,
// Pause offscreen; undefined omits the property when unneeded.
animationPlayState: animate && !inView ? "paused" : undefined,
...style,
} as React.CSSProperties
}
/>
);
}
Dependencies
- motion
- @bottega/tokens