bottega

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-gradient

Usage

Usagetsx
<FlowingGradient />

Props

PropTypeDefaultDescription
speedenumslowDrift cycle duration. "slow" = 30 s, "normal" = 18 s, "fast" = 10 s. Default "slow" — ambient backdrops read better at glacial pace.
intensityenumsubtleBlob 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