bottega

backgrounds

Spotlight Background

A soft radial spotlight that sits behind dark section content and slowly drifts on a coprime Lissajous path (14s × 19s → a 266s period that never visibly repeats). The glow is brand-tinted — its core is --primary blended at low opacity over the section, its halo a neutral --muted mid-tone — so it inherits the site's hue with no hardcoded color. On mount it fades and scales up from below with a spring-like ease-out for physical weight, gated by an IntersectionObserver so it only animates in view. Purely decorative (aria-hidden, pointer-events-none); asChild lets it host its own section as a layout primitive.

spotlight-bg

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/spotlight-bg

Usage

Usagetsx
<SpotlightBg />

Props

PropTypeDefaultDescription
sizenumber0.65Spotlight diameter as a fraction of the container's shorter dimension (0–1). 0.65 fills a full-bleed section; smaller focuses the glow.
xnumber0.5Horizontal anchor of the drift centre, as a fraction of width (0–1).
ynumber0.35Vertical anchor of the drift centre, as a fraction of height (0–1). Default skews upward for hero use.
driftRangenumber60Max pixel offset the glow travels from its anchor along the drift path.
intensitynumber0.14Peak opacity of the --primary core stop (0–1). Clamped to 0.18 for AA.
blurnumber120CSS blur (px) on the glow layer for edge softness.
asChildbooleanfalseRender children inside the same element (Radix Slot pattern) so the spotlight is a layout primitive, not a sibling div.

Source

spotlight-bg.tsxtsx
"use client";

import * as React from "react";
import { useReducedMotion } from "motion/react";
import styles from "./spotlight-bg.module.css";

/**
 * Contrast-safe ceiling for `intensity`. --primary over --background above this
 * can sink foreground text below WCAG AA; the value is clamped here so the
 * spotlight stays ambient decoration, never a contrast hazard. (a11y note.)
 */
const INTENSITY_CAP = 0.18;

export interface SpotlightBgProps extends React.HTMLAttributes<HTMLDivElement> {
  /** Spotlight diameter as a fraction of the container's shorter dimension
   *  (0–1). 0.65 fills a full-bleed section; smaller focuses the glow. */
  size?: number;
  /** Horizontal anchor of the drift centre, as a fraction of width (0–1). */
  x?: number;
  /** Vertical anchor of the drift centre, as a fraction of height (0–1).
   *  Default skews upward for hero use. */
  y?: number;
  /** Max pixel offset the glow travels from its anchor along the drift path. */
  driftRange?: number;
  /** Peak opacity of the --primary core stop (0–1). Clamped to 0.18 for AA. */
  intensity?: number;
  /** CSS blur (px) on the glow layer for edge softness. */
  blur?: number;
  /** Render children inside the same element (Radix Slot pattern) so the
   *  spotlight is a layout primitive, not a sibling div. */
  asChild?: boolean;
}

/** Minimal asChild Slot — merges root props onto the single child without a
 *  wrapper DOM node. No external dependency needed for this one merge. */
function mergeProps(
  child: React.ReactElement<Record<string, unknown>>,
  slotProps: Record<string, unknown>,
): Record<string, unknown> {
  const childProps = child.props;
  const merged: Record<string, unknown> = { ...slotProps, ...childProps };
  const childClass = childProps.className as string | undefined;
  const slotClass = slotProps.className as string | undefined;
  merged.className = [slotClass, childClass].filter(Boolean).join(" ");
  const childStyle = childProps.style as React.CSSProperties | undefined;
  const slotStyle = slotProps.style as React.CSSProperties | undefined;
  merged.style = { ...slotStyle, ...childStyle };
  return merged;
}

export function SpotlightBg({
  size = 0.65,
  x = 0.5,
  y = 0.35,
  driftRange = 60,
  intensity = 0.14,
  blur = 120,
  asChild = false,
  className = "",
  style,
  children,
  ...props
}: SpotlightBgProps) {
  const prefersReducedMotion = useReducedMotion();

  // Contract mount-gate (avoids React #418 + reduced-motion flash): server +
  // first client render are the static base; the animated layer turns on only
  // post-mount for non-reduced users who have also scrolled it into view.
  const [mounted, setMounted] = React.useState(false);
  React.useEffect(() => {
    setMounted(true);
  }, []);

  const reduce = prefersReducedMotion === null || prefersReducedMotion; // null => reduced

  // in-view gate: skip the IntersectionObserver entirely for reduced-motion
  // users (no invisible background work, no animation to trigger).
  const rootRef = React.useRef<HTMLDivElement | null>(null);
  const [inView, setInView] = React.useState(false);
  React.useEffect(() => {
    if (reduce) return;
    const el = rootRef.current;
    if (!el || typeof IntersectionObserver === "undefined") {
      setInView(true);
      return;
    }
    const io = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          setInView(true);
          io.disconnect();
        }
      },
      { threshold: 0.01 },
    );
    io.observe(el);
    return () => io.disconnect();
  }, [reduce]);

  const animate = mounted && !reduce && inView;

  const safeIntensity = Math.min(Math.max(intensity, 0), INTENSITY_CAP);
  const diameter = `${Math.round(size * 100)}%`;

  // Core: --primary at intensity. Halo: --muted at 0.06 to widen the glow with
  // a neutral mid-tone. color-mix keeps the stops token-driven (no hardcoded
  // color) while letting opacity ride the prop.
  const coreLayer =
    "radial-gradient(circle at center, " +
    `color-mix(in oklch, var(--primary) ${Math.round(safeIntensity * 100)}%, transparent), ` +
    "transparent 70%)";
  const haloLayer =
    "radial-gradient(circle at center, " +
    "color-mix(in oklch, var(--muted) 6%, transparent), " +
    "transparent 72%)";

  // Custom props consumed by the CSS module for drift speed/distance + mount ease.
  const cssVars = {
    "--spotlight-drift": `${driftRange}px`,
  } as React.CSSProperties;

  const positioner: React.CSSProperties = {
    position: "absolute",
    left: `${x * 100}%`,
    top: `${y * 100}%`,
    width: diameter,
    aspectRatio: "1 / 1",
    filter: `blur(${blur}px)`,
  };

  // The glow uses one element per transform so no two `animation`/`transform`
  // values collide: .glow owns centering + the mount choreography, an inner
  // .driftX layer rides the X-axis loop (14s), and a .driftY layer inside it
  // rides the Y-axis loop (19s). Three independent transforms, three elements.
  const glow = (
    <div
      key="spotlight-glow"
      aria-hidden="true"
      className={[styles.glow, animate ? styles.mount : ""]
        .filter(Boolean)
        .join(" ")}
      style={positioner}
    >
      <div className={animate ? styles.driftX : styles.driftLayer}>
        <div className={animate ? styles.driftY : styles.driftLayer}>
          {/* Outer neutral halo widens the glow. */}
          <div style={{ position: "absolute", inset: 0, backgroundImage: haloLayer }} />
          {/* Inner brand core, screen-blended for additive light physics. */}
          <div
            style={{
              position: "absolute",
              inset: 0,
              backgroundImage: coreLayer,
              mixBlendMode: "screen",
            }}
          />
        </div>
      </div>
    </div>
  );

  // asChild: host the spotlight on the caller's element (a layout primitive,
  // not a sibling). The glow + caller content live inside that single node.
  if (asChild && React.isValidElement(children)) {
    const child = children as React.ReactElement<Record<string, unknown>>;
    const slotProps: Record<string, unknown> = {
      ref: rootRef,
      className: [styles.root, className].filter(Boolean).join(" "),
      style: { ...cssVars, ...style },
      ...props,
    };
    return React.cloneElement(child, mergeProps(child, slotProps), [
      glow,
      child.props.children as React.ReactNode,
    ]);
  }

  // The root may host caller `children`, so it must NOT be aria-hidden — only
  // the decorative glow layer carries aria-hidden. The root itself stays a
  // transparent, pointer-events-none positioning wrapper.
  return (
    <div
      ref={rootRef}
      className={[styles.root, className].filter(Boolean).join(" ")}
      style={{ ...cssVars, ...style }}
      {...props}
    >
      {glow}
      {children}
    </div>
  );
}

Dependencies

  • motion
  • @bottega/tokens