Domphy

glowingStars

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

Implementation notes

DOM-grid implementation (18x6 divs via CSS grid, per spec's domSketch) rather than canvas: each star owns its own reactive boolean State read by that star's own backgroundColor/boxShadow/transform, driven by (1) a setInterval idle burst that lights a random subset with staggered setTimeout delays then reverts after a hold period, and (2) pointerenter/pointerleave on the card that lights/unlights every star together. Fade uses a plain CSS transition (duration = glowDurationMs) rather than an explicit multi-keyframe animation, since only a two-state on/off toggle is needed. Corner icon button uses the package's own button() patch styled circular with a hand-drawn diagonal-arrow SVG glyph (no specific icon-library path data) rather than a named icon asset.

Status: ported · Reference: Aceternity UI original

// Aceternity UI "Glowing Stars" — clean-room reimplementation from the
// public behavior/visual spec only (no upstream source viewed or copied). A
// dark card background made of a compact grid of tiny dot "stars" that idly
// flicker a random handful at a time and burst into a unified glow together
// while the pointer hovers the card.
//
// Each dot owns its own reactive boolean `State` ("active"), read by that
// dot's own `style.backgroundColor`/`boxShadow`/`transform` functions — no
// canvas, no imperative DOM writes per frame. Two independent triggers flip
// those states: (1) an idle `setInterval` that, every few seconds, picks a
// small random subset of dot indices and turns each on with a staggered
// `setTimeout` delay, then off again after a hold period (skipped while
// hovering, so a stray idle burst can't fight the hover state); (2) a
// `pointerenter`/`pointerleave` pair on the card's own DOM element that turns
// every dot on together (near-zero stagger) while hovered, and off again on
// leave. The visual fade itself is a plain CSS `transition` on each dot — no
// WAAPI needed since only a two-state (on/off) toggle is required, not
// intermediate keyframes.

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

export interface GlowingStarsProps {
  /** Card content (title/description). Defaults to a small demo blurb. */
  children?: DomphyElement | DomphyElement[];
  /** Glyph for the corner icon button. Defaults to a diagonal arrow. */
  icon?: DomphyElement;
  /** Grid column count. Defaults to `18`. */
  columns?: number;
  /** Grid row count. Defaults to `6`. */
  rows?: number;
  /** Milliseconds between each idle ambient burst. Defaults to `3000`. */
  idleIntervalMs?: number;
  /** How many stars light up per idle burst. Defaults to `5`. */
  idleStarCount?: number;
  /** Per-star stagger within one idle burst, in ms. Defaults to `100`. */
  idleStaggerMs?: number;
  /** How long a lit star stays lit before fading back out, in ms (also the
   * CSS transition duration for the fade itself). Defaults to `2000`. */
  glowDurationMs?: number;
  /** Theme color family for the lit glow. Defaults to `"info"` (blue-tinted). */
  glowColor?: ThemeColor;
  /** Disables the hover-lights-everything trigger; the idle ambient burst
   * keeps running regardless. Defaults to `false`. */
  disableHover?: boolean;
  style?: StyleObject;
}

let glowingStarsInstanceCounter = 0;

/** Simple diagonal arrow glyph (no specific icon library's path data). */
function arrowGlyph(): DomphyElement {
  return {
    svg: [
      {
        path: null,
        d: "M7 17L17 7M17 7H9M17 7V15",
        fill: "none",
        stroke: "currentColor",
        strokeWidth: "2",
        strokeLinecap: "round",
        strokeLinejoin: "round",
      },
    ],
    viewBox: "0 0 24 24",
    xmlns: "http://www.w3.org/2000/svg",
    role: "img",
    ariaHidden: "true",
  } as DomphyElement;
}

/** Fisher-Yates partial shuffle — returns up to `count` distinct indices in `[0, total)`. */
function pickRandomIndices(total: number, count: number): number[] {
  const pool = Array.from({ length: total }, (_unused, index) => index);
  const picked = Math.min(Math.max(0, count), total);
  for (let index = pool.length - 1; index > pool.length - 1 - picked; index -= 1) {
    const swapIndex = Math.floor(Math.random() * (index + 1));
    const temp = pool[index];
    pool[index] = pool[swapIndex];
    pool[swapIndex] = temp;
  }
  return pool.slice(pool.length - picked);
}

function defaultGlowingStarsContent(): DomphyElement[] {
  return [
    { h3: "Glowing Stars", $: [heading()] } as DomphyElement,
    {
      p: "A quiet field of stars that flicker on their own — and light up together when you hover.",
      $: [paragraph({ color: "neutral" })],
    } as DomphyElement,
  ];
}

/**
 * A card background made of a compact grid of dot "stars" that idly flicker
 * a random handful every few seconds and burst into a unified glow together
 * on hover. Call with no arguments for a working demo — an 18×6 star grid
 * over a dark card with a title, description, and corner icon button.
 */
function glowingStars(props: GlowingStarsProps = {}): DomphyElement<"div"> {
  const instanceId = ++glowingStarsInstanceCounter;
  const columns = Math.max(1, Math.round(props.columns ?? 18));
  const rows = Math.max(1, Math.round(props.rows ?? 6));
  const idleIntervalMs = Math.max(200, props.idleIntervalMs ?? 3000);
  const idleStarCount = Math.max(0, Math.round(props.idleStarCount ?? 5));
  const idleStaggerMs = Math.max(0, props.idleStaggerMs ?? 100);
  const glowDurationMs = Math.max(100, props.glowDurationMs ?? 2000);
  const glowColor = props.glowColor ?? "info";
  const disableHover = props.disableHover ?? false;
  const icon = props.icon ?? arrowGlyph();

  const totalStars = columns * rows;
  const starActiveStates: State<boolean>[] = Array.from({ length: totalStars }, (_unused, index) =>
    toState(false, `glowing-star-${instanceId}-${index}`),
  );

  const starElements: DomphyElement[] = starActiveStates.map((activeState, index) => ({
    div: null,
    _key: `star-${instanceId}-${index}`,
    ariaHidden: "true",
    // Decorative dot with no text of its own — exempt from the missing-color
    // contract (mirrors meteors.ts's dot spans elsewhere in this package).
    // Also exempt from tone-background-inherit: a star's idle/lit color is
    // intentionally a fixed dim/bright pair, not a surface that should track
    // the ambient dataTone context (same reasoning as meteors.ts's dots).
    _doctorDisable: ["missing-color", "tone-background-inherit"],
    style: {
      width: themeSpacing(1.5),
      height: themeSpacing(1.5),
      borderRadius: (listener: Listener) => (activeState.get(listener) ? "999px" : "2px"),
      transform: (listener: Listener) => (activeState.get(listener) ? "scale(1.7)" : "scale(1)"),
      backgroundColor: (listener: Listener) =>
        activeState.get(listener)
          ? themeColor(listener, "shift-17", glowColor)
          : themeColor(listener, "shift-6"),
      boxShadow: (listener: Listener) =>
        activeState.get(listener)
          ? `0 0 ${themeSpacing(2.5)} ${themeColor(listener, "shift-11", glowColor)}`
          : "none",
      transition: `background-color ${glowDurationMs}ms ease, box-shadow ${glowDurationMs}ms ease, transform ${glowDurationMs}ms ease, border-radius ${glowDurationMs}ms ease`,
    } as StyleObject,
  } as DomphyElement));

  const starGrid: DomphyElement<"div"> = {
    div: starElements,
    ariaHidden: "true",
    style: {
      display: "grid",
      gridTemplateColumns: `repeat(${columns}, 1fr)`,
      gap: themeSpacing(1),
      marginBottom: themeSpacing(6),
    } as StyleObject,
  };

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

  const cornerButton: DomphyElement<"button"> = {
    button: [icon],
    type: "button",
    ariaLabel: "Open",
    $: [button({ color: "neutral" })],
    style: {
      position: "absolute",
      insetBlockEnd: themeSpacing(4),
      insetInlineEnd: themeSpacing(4),
      width: themeSpacing(9),
      height: themeSpacing(9),
      paddingBlock: 0,
      paddingInline: 0,
      borderRadius: "50%",
    } as StyleObject,
  } as DomphyElement<"button">;

  return {
    div: [
      starGrid,
      {
        div: contentChildren,
        style: { position: "relative", zIndex: 1, maxWidth: themeSpacing(72) },
      } as DomphyElement,
      cornerButton,
    ],
    dataTone: "shift-16",
    style: {
      position: "relative",
      overflow: "hidden",
      borderRadius: themeSpacing(4),
      padding: themeSpacing(6),
      maxWidth: themeSpacing(110),
      backgroundColor: (listener) => themeColor(listener, "inherit"),
      color: (listener) => themeColor(listener, "shift-9"),
      ...(props.style ?? {}),
    } as StyleObject,
    _onMount: (node: ElementNode) => {
      let hovering = false;
      let idleTimer: ReturnType<typeof setInterval> | null = null;
      const pendingTimeouts = new Set<ReturnType<typeof setTimeout>>();

      const scheduleTimeout = (callback: () => void, delay: number) => {
        const handle = setTimeout(() => {
          pendingTimeouts.delete(handle);
          callback();
        }, delay);
        pendingTimeouts.add(handle);
        return handle;
      };

      function igniteTemporarily(indices: number[], staggerMs: number, holdMs: number) {
        indices.forEach((starIndex, order) => {
          scheduleTimeout(() => {
            starActiveStates[starIndex].set(true);
            scheduleTimeout(() => {
              // A hover that started mid-burst owns the star's state instead —
              // don't let a queued idle "off" fight the hover-lit state.
              if (!hovering) starActiveStates[starIndex].set(false);
            }, holdMs);
          }, order * staggerMs);
        });
      }

      function idleBurst() {
        if (hovering) return;
        igniteTemporarily(pickRandomIndices(totalStars, idleStarCount), idleStaggerMs, glowDurationMs * 0.6);
      }

      idleBurst();
      idleTimer = setInterval(idleBurst, idleIntervalMs);

      let handlePointerEnter: (() => void) | null = null;
      let handlePointerLeave: (() => void) | null = null;
      const hostElement = node.domElement as HTMLElement | null;

      if (!disableHover && hostElement) {
        handlePointerEnter = () => {
          hovering = true;
          for (const activeState of starActiveStates) activeState.set(true);
        };
        handlePointerLeave = () => {
          hovering = false;
          for (const activeState of starActiveStates) activeState.set(false);
        };
        hostElement.addEventListener("pointerenter", handlePointerEnter);
        hostElement.addEventListener("pointerleave", handlePointerLeave);
      }

      node.addHook("Remove", () => {
        if (idleTimer !== null) clearInterval(idleTimer);
        for (const handle of pendingTimeouts) clearTimeout(handle);
        pendingTimeouts.clear();
        if (handlePointerEnter) hostElement?.removeEventListener("pointerenter", handlePointerEnter);
        if (handlePointerLeave) hostElement?.removeEventListener("pointerleave", handlePointerLeave);
      });
    },
  };
}

export { glowingStars };

← Back to Aceternity UI catalog