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-bgUsage
Usagetsx
<SpotlightBg />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| size | number | 0.65 | Spotlight diameter as a fraction of the container's shorter dimension (0–1). 0.65 fills a full-bleed section; smaller focuses the glow. |
| x | number | 0.5 | Horizontal anchor of the drift centre, as a fraction of width (0–1). |
| y | number | 0.35 | Vertical anchor of the drift centre, as a fraction of height (0–1). Default skews upward for hero use. |
| driftRange | number | 60 | Max pixel offset the glow travels from its anchor along the drift path. |
| intensity | number | 0.14 | Peak opacity of the --primary core stop (0–1). Clamped to 0.18 for AA. |
| blur | number | 120 | CSS blur (px) on the glow layer for edge softness. |
| asChild | boolean | false | Render 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