bottega

backgrounds

Starfield

A drifting starfield background layer with three depth layers at different speeds for a parallax effect. Token-colored dots (--foreground / --primary at varied alpha) twinkle via an opacity pulse and slowly drift in random directions. Far stars are tiny, faint, and slow; near stars are larger, brighter, and faster — a depth illusion with no Three.js or canvas. Geometry is randomised post-mount for hydration safety; an IntersectionObserver pauses the layer offscreen. Decorative (role=presentation, aria-hidden, pointer-events:none). Place sibling content at z-10.

starfield

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/starfield

Usage

Usagetsx
<Starfield />

Props

PropTypeDefaultDescription
starCountnumber180Total stars across all active layers. Default 180.
speednumber1Speed multiplier for drift and twinkle. 1 = default; >1 = faster.
layersnumber3Depth layers (1–3). More layers = stronger parallax illusion. Default 3.

Source

starfield.tsxtsx
"use client";

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

// Layer config ordered far → near.
// fraction: share of total star count; driftBase: drift animation base duration (s);
// driftPx: max drift distance in pixels (parallax depth cue).
const LAYER_CONFIG = [
  { minSize: 0.6, maxSize: 1.1, minAlpha: 18, maxAlpha: 48, driftBase: 70, driftPx: 8,  fraction: 0.50 },
  { minSize: 1.1, maxSize: 1.8, minAlpha: 38, maxAlpha: 74, driftBase: 40, driftPx: 14, fraction: 0.30 },
  { minSize: 1.7, maxSize: 3.2, minAlpha: 62, maxAlpha: 100, driftBase: 18, driftPx: 22, fraction: 0.20 },
] as const;

interface StarData {
  x: number;           // left in %
  y: number;           // top in %
  size: string;        // e.g. "1.2px"
  alpha: string;       // e.g. "54%" — fed into color-mix
  twinkleDur: string;  // e.g. "3.2s"
  twinkleDelay: string; // negative = skip ahead, e.g. "-1.8s"
  driftDur: string;    // e.g. "62s"
  driftX: string;      // e.g. "6px"
  driftY: string;      // e.g. "-4px"
  isPrimary: boolean;  // true → --primary accent; false → --foreground base
}

export interface StarfieldProps extends React.HTMLAttributes<HTMLDivElement> {
  /** Total stars across all active layers. Default 180. */
  starCount?: number;
  /** Speed multiplier for drift and twinkle. 1 = default; >1 = faster. */
  speed?: number;
  /** Depth layers (1–3). More layers = stronger parallax illusion. Default 3. */
  layers?: number;
}

/**
 * Drifting starfield background layer (client component).
 *
 * Three depth layers of token-colored dots (--foreground / --primary at varied
 * alpha) twinkle via an opacity pulse and drift slowly in random directions.
 * Far stars are tiny, faint, and slow; near stars are larger, brighter, and
 * faster — creating parallax depth without Three.js or canvas.
 *
 * Geometry is randomised after mount (hydration-safe; React #418-clean).
 * An IntersectionObserver pauses the layer when scrolled off-screen.
 *
 * Decorative: role="presentation" + aria-hidden; pointer-events:none.
 * Place sibling content at z-10 above this z-0 layer.
 * Reduced-motion: static star field (same dots, no twinkle or drift).
 */
export function Starfield({
  starCount = 180,
  speed = 1,
  layers = 3,
  className = "",
  style,
  "aria-hidden": ariaHidden = true,
  ...props
}: StarfieldProps) {
  // Contract mount-gate + null-safe reduce check (avoids React #418 + flash).
  const [mounted, setMounted] = useState(false);
  useEffect(() => {
    setMounted(true);
  }, []);

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

  const rootRef = useRef<HTMLDivElement>(null);
  const [stars, setStars] = useState<StarData[]>([]);
  const [playing, setPlaying] = useState(true);

  // Generate randomised star geometry post-mount (hydration-safe).
  useEffect(() => {
    if (!mounted) return;

    const s = Math.max(0.1, speed); // guard divide-by-zero
    const clampedLayers = Math.max(1, Math.min(3, layers));
    const activeLayers = LAYER_CONFIG.slice(0, clampedLayers);

    // Normalise fractions so starCount is always honoured regardless of layer count.
    const totalFraction = activeLayers.reduce((sum, l) => sum + l.fraction, 0);

    const next: StarData[] = [];
    activeLayers.forEach((cfg) => {
      const count = Math.round(starCount * (cfg.fraction / totalFraction));
      for (let i = 0; i < count; i++) {
        const isPrimary = Math.random() < 0.18; // ~18% warm accent stars (hero points)
        const size = Math.random() * (cfg.maxSize - cfg.minSize) + cfg.minSize;
        const alpha = Math.round(Math.random() * (cfg.maxAlpha - cfg.minAlpha) + cfg.minAlpha);
        // Twinkle: 2–6 s per cycle, scaled by speed. Negative delay skips into cycle.
        const twinkleDur = (Math.random() * 4 + 2) / s;
        const twinkleDelay = -(Math.random() * twinkleDur); // stagger phase at t=0
        // Drift: longer for far layers (slow) than near (fast), scaled by speed.
        const driftDur = (Math.random() * cfg.driftBase * 0.5 + cfg.driftBase * 0.75) / s;
        // Random drift direction per star, bounded to cfg.driftPx radius.
        const angle = Math.random() * Math.PI * 2;
        const dist = Math.random() * cfg.driftPx;
        const driftX = Math.cos(angle) * dist;
        const driftY = Math.sin(angle) * dist;

        next.push({
          x: Math.random() * 100,
          y: Math.random() * 100,
          size: `${size.toFixed(2)}px`,
          alpha: `${alpha}%`,
          twinkleDur: `${twinkleDur.toFixed(2)}s`,
          twinkleDelay: `${twinkleDelay.toFixed(2)}s`,
          driftDur: `${driftDur.toFixed(1)}s`,
          driftX: `${driftX.toFixed(2)}px`,
          driftY: `${driftY.toFixed(2)}px`,
          isPrimary,
        });
      }
    });

    setStars(next);
  }, [mounted, starCount, layers, speed]);

  // Pause animations when scrolled out of view to save GPU.
  useEffect(() => {
    if (!animate) return;
    const el = rootRef.current;
    if (!el || typeof IntersectionObserver === "undefined") return;
    const io = new IntersectionObserver(
      ([entry]) => setPlaying(entry.isIntersecting),
      { rootMargin: "0px" },
    );
    io.observe(el);
    return () => io.disconnect();
  }, [animate]);

  return (
    <div
      ref={rootRef}
      role="presentation"
      aria-hidden={ariaHidden}
      className={[styles.root, className].filter(Boolean).join(" ")}
      style={style}
      {...props}
    >
      {/* Render only after mount — SSR + first client paint sees nothing (hydration-safe). */}
      {mounted &&
        stars.map((star, i) => (
          <span
            key={i}
            className={[
              styles.star,
              star.isPrimary ? styles.starPrimary : styles.starFg,
              animate ? styles.starAnimated : "",
            ]
              .filter(Boolean)
              .join(" ")}
            style={
              {
                left: `${star.x}%`,
                top: `${star.y}%`,
                "--star-size": star.size,
                "--star-alpha": star.alpha,
                "--star-twinkle-dur": star.twinkleDur,
                "--star-twinkle-delay": star.twinkleDelay,
                "--star-drift-dur": star.driftDur,
                "--star-drift-x": star.driftX,
                "--star-drift-y": star.driftY,
                // IntersectionObserver pauses all animations when off-screen.
                "--star-play": playing ? "running" : "paused",
              } as React.CSSProperties
            }
          />
        ))}
    </div>
  );
}

Dependencies

  • motion
  • @bottega/tokens