Domphy

shootingStars

A Backgrounds block/component from Aceternity UI — clean-room reimplemented for Domphy (see methodology). Call shootingStars() with no arguments for a working demo, or edit the code below live.

Implementation notes

Stationary field is a single inline SVG of randomly-placed circles (per spec's domSketch) with one shared @keyframes twinkle pulse reused across differently-timed animation values, so only twinklingProbability's fraction of stars twinkle at any time. Shooting stars are spawned via a setTimeout chain into a reactive State<Entry[]> list (same shape as this package's animatedList.ts) and travel via the motion() WAAPI patch, with x/y expressed in vmax units (mirrors meteors.ts) so the diagonal reads consistently regardless of container aspect ratio; a matching setTimeout removes each entry once its travel animation finishes. Default trail/head colors are theme roles (info/secondary) rather than the research note's literal hex (#2EB9DF/#9E00FF), since Domphy's design system forbids raw hex/rgb color literals in style objects. Minor graceful-degradation edge case: in a hypothetical browser without the Web Animations API, a spawned star would render as a static bright dot for its travel duration rather than visibly streaking (it is still cleanly removed on schedule) - WAAPI is universally supported in evergreen browsers so this is not expected to matter in practice.

Status: ported · Reference: Aceternity UI original

// Aceternity UI "Shooting Stars and Stars Background" — clean-room
// reimplementation from the public behavior/visual spec only (no upstream
// source viewed or copied). A full-bleed dark starfield: many small
// stationary stars twinkle gently and asynchronously, while periodic
// "shooting stars" streak diagonally across the view on a randomized
// interval, each a bright head with a tapering gradient trail that fades out
// near the end of its path.
//
// The stationary field is a single inline SVG of `count` circles at random
// positions, drawn once at generation time (no runtime cost) — a shared
// `@keyframes` opacity/scale pulse (declared once on the outer container's
// style, the same "one keyframes block, many differently-timed `animation`
// values" idiom `meteors.ts` uses elsewhere in this package) plays on each
// twinkling circle with its own randomized duration/delay, so only a subset
// pulses at any moment and none of them are in lockstep. `twinklingProbability`
// controls what fraction of stars get that `animation` at all; the rest sit at
// a fixed, slightly dimmer opacity.
//
// Shooting stars are spawned by a `setTimeout` chain (each spawn schedules the
// next one after a fresh randomized delay) into a reactive `State<Entry[]>`
// list — the same "timer pushes into reactive state" shape `animatedList.ts`
// uses for its notification feed. Each entry is a small head dot (glow via
// `boxShadow`) with a nested, statically-rotated gradient trail bar, animated
// along its travel vector via this package's `motion()` patch (`x`/`y` in
// `vmax` units so the diagonal reads consistently regardless of the
// container's aspect ratio, mirroring `meteors.ts`'s own `vmax` travel
// distance) fading `opacity` to `0` as it completes; a matching `setTimeout`
// removes the entry from the list once the travel animation finishes.

import type { DomphyElement, ElementNode, Listener, State, StyleObject } from "@domphy/core";
import { hashString, toState } from "@domphy/core";
import { heading, motion, paragraph } from "@domphy/ui";
import { type ThemeColor, themeColor, themeSpacing } from "@domphy/theme";

export interface ShootingStarsProps {
  /** Foreground content layered above the starfield. Defaults to a small demo heading. */
  children?: DomphyElement | DomphyElement[];
  /** Stationary background star count. Defaults to `120`. */
  starCount?: number;
  /** Fraction (0–1) of background stars that twinkle; the rest stay at a fixed dim opacity. Defaults to `0.7`. */
  twinklingProbability?: number;
  /** Minimum twinkle cycle duration, in seconds. Defaults to `0.5`. */
  minTwinkleSpeed?: number;
  /** Maximum twinkle cycle duration, in seconds. Defaults to `1`. */
  maxTwinkleSpeed?: number;
  /** Background star diameter, in px. Defaults to `2`. */
  starSize?: number;
  /** Theme color family for the stationary stars. Defaults to `"neutral"`. */
  starColor?: ThemeColor;
  /** Theme color family for the shooting star's tail (the far end of the trail). Defaults to `"info"` (cyan). */
  trailColor?: ThemeColor;
  /** Theme color family for the shooting star's head/near end of the trail. Defaults to `"secondary"` (violet). */
  headColor?: ThemeColor;
  /** Minimum ms between one shooting star spawning and the next. Defaults to `4200`. */
  minSpawnDelayMs?: number;
  /** Maximum ms between one shooting star spawning and the next. Defaults to `8700`. */
  maxSpawnDelayMs?: number;
  /** Minimum shooting-star travel duration, in ms (higher = slower). Defaults to `1200`. */
  minTravelMs?: number;
  /** Maximum shooting-star travel duration, in ms. Defaults to `2600`. */
  maxTravelMs?: number;
  style?: StyleObject;
}

interface BackgroundStar {
  key: string;
  leftPercent: number;
  topPercent: number;
  twinkles: boolean;
  durationSeconds: number;
  delaySeconds: number;
}

interface ShootingStarEntry {
  key: string;
  leftPercent: number;
  topPercent: number;
  travelXVmax: number;
  travelYVmax: number;
  trailAngleDeg: number;
  travelMs: number;
}

function randomBetween(min: number, max: number): number {
  return min + Math.random() * (max - min);
}

function buildBackgroundStars(
  count: number,
  twinklingProbability: number,
  minTwinkleSpeed: number,
  maxTwinkleSpeed: number,
  instanceId: number,
): BackgroundStar[] {
  return Array.from({ length: count }, (_unused, index) => ({
    key: `bg-star-${instanceId}-${index}`,
    leftPercent: Math.random() * 100,
    topPercent: Math.random() * 100,
    twinkles: Math.random() < twinklingProbability,
    durationSeconds: randomBetween(minTwinkleSpeed, maxTwinkleSpeed),
    delaySeconds: Math.random() * maxTwinkleSpeed,
  }));
}

function defaultShootingStarsContent(): DomphyElement[] {
  return [
    { h2: "Shooting Stars", $: [heading()] } as DomphyElement,
    {
      p: "A quiet starfield with the occasional streak crossing the sky.",
      $: [paragraph()],
    } as DomphyElement,
  ];
}

let shootingStarsInstanceCounter = 0;

/**
 * A full-bleed dark starfield backdrop: many small twinkling stationary
 * stars, plus periodic shooting stars streaking diagonally across the view
 * on a randomized interval. Call with no arguments for a working demo.
 */
function shootingStars(props: ShootingStarsProps = {}): DomphyElement<"div"> {
  const instanceId = ++shootingStarsInstanceCounter;
  const starCount = Math.max(0, Math.round(props.starCount ?? 120));
  const twinklingProbability = Math.min(1, Math.max(0, props.twinklingProbability ?? 0.7));
  const minTwinkleSpeed = Math.max(0.1, props.minTwinkleSpeed ?? 0.5);
  const maxTwinkleSpeed = Math.max(minTwinkleSpeed, props.maxTwinkleSpeed ?? 1);
  const starSize = props.starSize ?? 2;
  const starColor = props.starColor ?? "neutral";
  const trailColor = props.trailColor ?? "info";
  const headColor = props.headColor ?? "secondary";
  const minSpawnDelayMs = Math.max(50, props.minSpawnDelayMs ?? 4200);
  const maxSpawnDelayMs = Math.max(minSpawnDelayMs, props.maxSpawnDelayMs ?? 8700);
  const minTravelMs = Math.max(200, props.minTravelMs ?? 1200);
  const maxTravelMs = Math.max(minTravelMs, props.maxTravelMs ?? 2600);

  const contentChildren = props.children
    ? Array.isArray(props.children)
      ? props.children
      : [props.children]
    : defaultShootingStarsContent();

  const twinkleAnimationName = `shooting-stars-twinkle-${hashString(`${instanceId}`)}`;
  const twinkleKeyframes = {
    "0%": { opacity: 0.2, transform: "scale(1)" },
    "50%": { opacity: 1, transform: "scale(1.3)" },
    "100%": { opacity: 0.2, transform: "scale(1)" },
  };

  const backgroundStars = buildBackgroundStars(
    starCount,
    twinklingProbability,
    minTwinkleSpeed,
    maxTwinkleSpeed,
    instanceId,
  );

  const backgroundStarCircles: DomphyElement[] = backgroundStars.map((star) => ({
    circle: null,
    _key: star.key,
    cx: `${star.leftPercent}%`,
    cy: `${star.topPercent}%`,
    r: String(starSize / 2),
    fill: (listener: Listener) => themeColor(listener, "shift-13", starColor),
    style: {
      opacity: star.twinkles ? undefined : 0.35,
      animation: star.twinkles
        ? `${twinkleAnimationName} ${star.durationSeconds.toFixed(2)}s ease-in-out ${star.delaySeconds.toFixed(2)}s infinite`
        : undefined,
      transformOrigin: "center",
      transformBox: "fill-box",
    } as StyleObject,
  } as DomphyElement));

  const backgroundLayer: DomphyElement<"svg"> = {
    svg: backgroundStarCircles,
    ariaHidden: "true",
    // Decorative starfield with no text of its own — exempt from the
    // missing-color contract (mirrors meteors.ts's dot spans elsewhere).
    _doctorDisable: "missing-color",
    viewBox: "0 0 100 100",
    preserveAspectRatio: "none",
    xmlns: "http://www.w3.org/2000/svg",
    style: { position: "absolute", inset: 0, width: "100%", height: "100%" } as StyleObject,
  } as DomphyElement<"svg">;

  const shootingStarEntries: State<ShootingStarEntry[]> = toState([], `shooting-star-entries-${instanceId}`);

  function shootingStarElement(entry: ShootingStarEntry): DomphyElement<"span"> {
    return {
      span: [
        {
          span: null,
          ariaHidden: "true",
          _doctorDisable: "missing-color",
          style: {
            position: "absolute",
            top: "50%",
            right: "50%",
            width: themeSpacing(20),
            height: themeSpacing(0.5),
            transformOrigin: "right center",
            transform: `translateY(-50%) rotate(${entry.trailAngleDeg.toFixed(1)}deg)`,
            backgroundImage: (listener) =>
              `linear-gradient(to left, ${themeColor(listener, "shift-13", headColor)}, ${themeColor(listener, "shift-9", trailColor)}, transparent)`,
          } as StyleObject,
        } as DomphyElement,
      ],
      _key: entry.key,
      ariaHidden: "true",
      // Decorative head dot with no text of its own — exempt from the
      // missing-color contract. Also exempt from tone-background-inherit: a
      // shooting star's head is intentionally a fixed bright accent, not a
      // surface that should track the ambient dataTone context (same
      // reasoning as meteors.ts's dots elsewhere in this package).
      _doctorDisable: ["missing-color", "tone-background-inherit"],
      style: {
        position: "absolute",
        left: `${entry.leftPercent}%`,
        top: `${entry.topPercent}%`,
        width: themeSpacing(1.25),
        height: themeSpacing(1.25),
        borderRadius: "50%",
        backgroundColor: (listener) => themeColor(listener, "shift-17", headColor),
        boxShadow: (listener) =>
          `0 0 ${themeSpacing(2)} ${themeColor(listener, "shift-13", headColor)}`,
      } as StyleObject,
      $: [
        motion({
          initial: { x: 0, y: 0, opacity: 1 },
          animate: {
            x: `${entry.travelXVmax.toFixed(2)}vmax`,
            y: `${entry.travelYVmax.toFixed(2)}vmax`,
            opacity: 0,
          },
          transition: { duration: entry.travelMs, easing: "linear" },
        }),
      ],
    } as DomphyElement<"span">;
  }

  return {
    div: [
      backgroundLayer,
      {
        div: (listener) => shootingStarEntries.get(listener).map(shootingStarElement),
        ariaHidden: "true",
        style: { position: "absolute", inset: 0 } as StyleObject,
      } as DomphyElement,
      { div: contentChildren, style: { position: "relative", zIndex: 1 } } as DomphyElement,
    ],
    dataTone: "shift-17",
    style: {
      position: "relative",
      overflow: "hidden",
      borderRadius: themeSpacing(4),
      padding: themeSpacing(8),
      minHeight: themeSpacing(80),
      backgroundColor: (listener) => themeColor(listener, "inherit"),
      color: (listener) => themeColor(listener, "shift-9"),
      [`@keyframes ${twinkleAnimationName}`]: twinkleKeyframes,
      ...(props.style ?? {}),
    } as StyleObject,
    _onMount: (node: ElementNode) => {
      let spawnTimeoutHandle: ReturnType<typeof setTimeout> | null = null;
      let insertCount = 0;
      const cleanupTimeouts = new Set<ReturnType<typeof setTimeout>>();

      function spawnShootingStar() {
        insertCount += 1;
        const travelAngleRad = randomBetween(20, 55) * (Math.PI / 180);
        const travelDistanceVmax = randomBetween(60, 95);
        const travelMs = Math.round(randomBetween(minTravelMs, maxTravelMs));
        const entry: ShootingStarEntry = {
          key: `shooting-star-${instanceId}-${insertCount}`,
          leftPercent: Math.random() * 60,
          topPercent: Math.random() * 40,
          travelXVmax: Math.cos(travelAngleRad) * travelDistanceVmax,
          travelYVmax: Math.sin(travelAngleRad) * travelDistanceVmax,
          trailAngleDeg: (travelAngleRad * 180) / Math.PI + 180,
          travelMs,
        };
        shootingStarEntries.set([...shootingStarEntries.get(), entry]);

        const removeHandle = setTimeout(() => {
          cleanupTimeouts.delete(removeHandle);
          shootingStarEntries.set(shootingStarEntries.get().filter((item) => item.key !== entry.key));
        }, travelMs);
        cleanupTimeouts.add(removeHandle);
      }

      function scheduleNextSpawn() {
        spawnTimeoutHandle = setTimeout(() => {
          spawnShootingStar();
          scheduleNextSpawn();
        }, randomBetween(minSpawnDelayMs, maxSpawnDelayMs));
      }

      scheduleNextSpawn();

      node.addHook("Remove", () => {
        if (spawnTimeoutHandle !== null) clearTimeout(spawnTimeoutHandle);
        for (const handle of cleanupTimeouts) clearTimeout(handle);
        cleanupTimeouts.clear();
      });
    },
  };
}

export { shootingStars };

← Back to Aceternity UI catalog