bottega

backgrounds

Conic Glow

An ambient rotating conic-gradient glow background layer. Token colour stops (--primary / --accent / --background) form a soft aurora that rotates slowly behind content. A radial-mask + 52 px blur dissolve the hard conic wedge into a tasteful glow field. Decorative (aria-hidden, pointer-events:none, z-0). Place sibling content at z-10. Reduced-motion users receive a static soft glow — same colours, zero motion.

conic-glow

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/conic-glow

Usage

Usagetsx
<ConicGlow />

Props

PropTypeDefaultDescription
speednumber20Seconds per full rotation. Larger = slower, more meditative. Default 20.
intensitynumber0.6Glow opacity 0–1. Controls how vivid the ambient colour field reads. Default 0.6.

Source

conic-glow.tsxtsx
"use client";

import { useEffect, useRef, useState } from "react";
import { useReducedMotion } from "motion/react";
import type React from "react";
import styles from "./conic-glow.module.css";

export interface ConicGlowProps extends React.HTMLAttributes<HTMLDivElement> {
  /** Seconds per full rotation. Larger = slower, more meditative. Default 20. */
  speed?: number;
  /** Glow opacity 0–1. Controls how vivid the ambient colour field reads. Default 0.6. */
  intensity?: number;
}

/**
 * Ambient rotating conic-gradient glow background layer.
 *
 * A large conic gradient (--primary / --accent / --background stops) is centered
 * behind content, rotating slowly behind a radial-mask + 52 px blur so it reads
 * as a soft aurora/glow, not a hard pinwheel. Token-driven: themes automatically.
 *
 * Decorative: root carries role="presentation" + aria-hidden + pointer-events:none.
 * Place sibling content at z-10 above this z-0 layer.
 * Reduced-motion users get a static (non-rotating) conic glow — same look, no motion.
 */
export function ConicGlow({
  speed = 20,
  intensity = 0.6,
  className = "",
  style,
  "aria-hidden": ariaHidden = true,
  ...props
}: ConicGlowProps) {
  // Mount-gate + null-safe reduce check prevents React #418 hydration mismatch.
  // Server + first client render are byte-identical (mounted=false → animation paused).
  const [mounted, setMounted] = useState(false);
  useEffect(() => {
    setMounted(true);
  }, []);

  const prefersReducedMotion = useReducedMotion();
  const reduce = prefersReducedMotion === null || prefersReducedMotion; // null => reduced
  const animate = mounted && !reduce;

  const glowRef = useRef<HTMLDivElement>(null);
  const [visible, setVisible] = useState(true);

  // Pause rotation when the layer is scrolled fully out of view (GPU savings).
  useEffect(() => {
    const el = glowRef.current;
    if (!el || typeof IntersectionObserver === "undefined") return;
    const io = new IntersectionObserver(
      ([entry]) => setVisible(entry.isIntersecting),
      { rootMargin: "0px" },
    );
    io.observe(el);
    return () => io.disconnect();
  }, []);

  return (
    <div
      role="presentation"
      aria-hidden={ariaHidden}
      className={[styles.root, className].filter(Boolean).join(" ")}
      style={style}
      {...props}
    >
      {/* Single glow element — always in DOM; animation VALUE (--cg-play) branches,
          not the DOM structure, so server + client first render stay byte-identical. */}
      <div
        ref={glowRef}
        className={styles.glow}
        style={
          {
            "--cg-speed": `${speed}s`,
            "--cg-intensity": intensity,
            // Drives animation-play-state via CSS custom prop — branches VALUES not DOM.
            "--cg-play": animate && visible ? "running" : "paused",
          } as React.CSSProperties
        }
      />
    </div>
  );
}

Dependencies

  • motion
  • @bottega/tokens