Domphy

backgroundLines

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

Implementation notes

Pure-CSS traveling-dash technique: each of the (default 40) scattered, randomly-angled/colored quadratic-curve paths gets its own analytically-approximated arc length (sampled/summed at generation time, no DOM measurement) driving a per-path stroke-dasharray + per-path @keyframes stroke-dashoffset animation, so a short dash travels each path and loops seamlessly, staggered per path — matching the spec's confirmed-via-screenshot 'isolated traveling dash, not a full drawn curve' look. Palette is exposed as a broad 9-role ThemeColor set (this theme's built-in 'rainbow-ish' families) rather than literal hex, per doctor rules. Note: SVG's pathLength normalization attribute was deliberately avoided after discovering it isn't in this framework's small curated CamelAttributes allowlist (would silently render as the wrong-cased, non-functional path-length) — the analytic-arc-length + per-path-keyframes approach sidesteps that gap entirely and was verified to compile/render correctly.

Status: ported · Reference: Aceternity UI original

// Aceternity UI "Background Lines" — clean-room reimplementation from the
// public behavior/visual spec only (no upstream source viewed or copied). A
// light hero backdrop scattered with dozens of thin, individually colored
// line strokes whose visible dash continuously travels along its own path,
// reading as ambient, low-key, confetti-like motion behind centered content.
//
// Pure CSS, no JS animation loop: each generated quadratic-bezier path's own
// arc length is approximated analytically (sampling points along the curve
// and summing segment distances — the same "compute path geometry with plain
// math, no DOM measurement" idiom `googleGeminiEffect.ts` uses for its own
// generated ribbons) rather than measured via `SVGPathElement.getTotalLength()`
// at mount time — these are static generated paths, not scroll-tied dynamic
// content like `tracingBeam.ts`'s beam, so the length is knowable up front.
// A short `stroke-dasharray` fraction of that length plus a *per-path*
// `@keyframes` block that shifts `stroke-dashoffset` by exactly one full
// dash+gap period produces a "traveling dash" that loops perfectly
// regardless of the path's own on-screen shape. Each path gets its own
// randomized duration/delay (relative to the shared `svgOptions.duration`
// base) so dozens of dashes travel independently rather than in lockstep —
// the "differently-timed `animation` values" idiom `meteors.ts` and
// `shootingStars.ts` already use elsewhere in this package, generalized here
// to a differently-*shaped* keyframe per element since every path's own
// pattern period differs (not just its timing).

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

export interface BackgroundLinesSvgOptions {
  /** Seconds per full dash-travel cycle of a path, before per-path randomization. Defaults to `10`. */
  duration?: number;
}

export interface BackgroundLinesProps {
  /** Content layered above the line scatter. Defaults to a small demo heading/subtext. */
  children?: DomphyElement | DomphyElement[];
  /** Number of scattered line strokes. Defaults to `40`. */
  lineCount?: number;
  /** Theme color families cycled across the scattered strokes. Defaults to a broad 9-role palette spanning this theme's built-in families. */
  colors?: ThemeColor[];
  /** SVG animation tuning. */
  svgOptions?: BackgroundLinesSvgOptions;
  /** Passthrough style merged onto the outer container. */
  style?: StyleObject;
}

const RAINBOW_PALETTE: ThemeColor[] = [
  "primary",
  "secondary",
  "info",
  "success",
  "warning",
  "attention",
  "error",
  "danger",
  "highlight",
];

interface QuadraticPoint {
  x: number;
  y: number;
}

interface LineSegment {
  key: string;
  d: string;
  arcLength: number;
  color: ThemeColor;
  durationSeconds: number;
  delaySeconds: number;
}

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

function quadraticPointAt(start: QuadraticPoint, control: QuadraticPoint, end: QuadraticPoint, t: number): QuadraticPoint {
  const oneMinusT = 1 - t;
  return {
    x: oneMinusT * oneMinusT * start.x + 2 * oneMinusT * t * control.x + t * t * end.x,
    y: oneMinusT * oneMinusT * start.y + 2 * oneMinusT * t * control.y + t * t * end.y,
  };
}

/** Approximates a quadratic bezier's arc length by summing distances between sampled points — no DOM measurement needed. */
function approximateQuadraticArcLength(start: QuadraticPoint, control: QuadraticPoint, end: QuadraticPoint, sampleCount = 16): number {
  let previous = start;
  let total = 0;
  for (let sampleIndex = 1; sampleIndex <= sampleCount; sampleIndex += 1) {
    const point = quadraticPointAt(start, control, end, sampleIndex / sampleCount);
    total += Math.hypot(point.x - previous.x, point.y - previous.y);
    previous = point;
  }
  return total;
}

/** A short, gently curved stroke at a random position/angle within a 0-100 viewBox. */
function buildScatteredLine(index: number, colors: ThemeColor[], baseDuration: number): LineSegment {
  const start: QuadraticPoint = { x: Math.random() * 100, y: Math.random() * 100 };
  const angleRad = Math.random() * Math.PI * 2;
  const length = randomBetween(8, 20);
  const end: QuadraticPoint = {
    x: start.x + Math.cos(angleRad) * length,
    y: start.y + Math.sin(angleRad) * length,
  };
  // Small perpendicular bow through the midpoint keeps the stroke a gentle
  // curve rather than a perfectly straight segment.
  const control: QuadraticPoint = {
    x: (start.x + end.x) / 2 - Math.sin(angleRad) * randomBetween(-3, 3),
    y: (start.y + end.y) / 2 + Math.cos(angleRad) * randomBetween(-3, 3),
  };

  return {
    key: `background-line-${index}`,
    d: `M${start.x.toFixed(1)} ${start.y.toFixed(1)} Q${control.x.toFixed(1)} ${control.y.toFixed(1)}, ${end.x.toFixed(1)} ${end.y.toFixed(1)}`,
    arcLength: Math.max(1, approximateQuadraticArcLength(start, control, end)),
    color: colors[index % colors.length],
    durationSeconds: baseDuration * randomBetween(0.6, 1.4),
    delaySeconds: randomBetween(0, baseDuration),
  };
}

function defaultLinesContent(): DomphyElement[] {
  return [
    { h2: "Background Lines", $: [heading()] } as DomphyElement,
    {
      p: "Dozens of scattered strokes, each with its own dash quietly traveling its path.",
      $: [paragraph()],
    } as DomphyElement,
  ];
}

let backgroundLinesInstanceCounter = 0;

/**
 * A light hero backdrop scattered with dozens of thin, colored line strokes
 * whose visible dash continuously travels along its own path — a pure-CSS,
 * ambient confetti-like scatter. Call with no arguments for a working demo —
 * 40 scattered strokes in a 9-role rainbow palette behind a heading.
 */
function backgroundLines(props: BackgroundLinesProps = {}): DomphyElement<"div"> {
  const instanceId = ++backgroundLinesInstanceCounter;
  const lineCount = Math.max(1, Math.round(props.lineCount ?? 40));
  const colors = props.colors && props.colors.length > 0 ? props.colors : RAINBOW_PALETTE;
  const baseDuration = Math.max(0.5, props.svgOptions?.duration ?? 10);

  const lines = Array.from({ length: lineCount }, (_unused, index) => buildScatteredLine(index, colors, baseDuration));

  // Per-path @keyframes, merged onto the shared <svg>'s style object — each
  // path's pattern period (dash + gap) equals its own arc length, so a
  // shared block can't be reused the way `meteors.ts` reuses one keyframe
  // for many identically-shaped meteors.
  const keyframesByName: Record<string, unknown> = {};

  const lineElements: DomphyElement[] = lines.map((line) => {
    const dash = line.arcLength * 0.06;
    const gap = line.arcLength * 0.94;
    const animationName = `background-line-travel-${hashString(`${instanceId}-${line.key}`)}`;
    keyframesByName[`@keyframes ${animationName}`] = {
      "0%": { strokeDashoffset: 0 },
      "100%": { strokeDashoffset: -line.arcLength },
    };

    return {
      path: null,
      _key: line.key,
      d: line.d,
      fill: "none",
      strokeWidth: "0.6",
      strokeLinecap: "round",
      // Dash is 6% of this path's own approximated arc length, 94% gap —
      // animating the offset by exactly one dash+gap period loops seamlessly.
      style: {
        strokeDasharray: `${dash.toFixed(2)} ${gap.toFixed(2)}`,
        animation: `${animationName} ${line.durationSeconds.toFixed(2)}s linear ${line.delaySeconds.toFixed(2)}s infinite`,
        stroke: (listener: Listener) => themeColor(listener, "shift-9", line.color),
      } as StyleObject,
      // Decorative stroke with no text of its own — exempt from the
      // missing-color contract (mirrors backgroundBeams.ts's own paths).
      _doctorDisable: "missing-color",
    } as DomphyElement;
  });

  const svgLayer: DomphyElement<"svg"> = {
    svg: lineElements,
    viewBox: "0 0 100 100",
    preserveAspectRatio: "none",
    ariaHidden: "true",
    style: {
      position: "absolute",
      inset: 0,
      width: "100%",
      height: "100%",
      pointerEvents: "none",
      ...keyframesByName,
    } as StyleObject,
  } as DomphyElement<"svg">;

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

  return {
    div: [svgLayer, { div: contentChildren, style: { position: "relative", zIndex: 1 } } as DomphyElement],
    dataTone: "shift-1",
    style: {
      position: "relative",
      overflow: "hidden",
      borderRadius: themeSpacing(4),
      padding: themeSpacing(10),
      minHeight: themeSpacing(80),
      backgroundColor: (listener) => themeColor(listener, "inherit"),
      color: (listener) => themeColor(listener, "shift-9"),
      ...(props.style ?? {}),
    } as StyleObject,
  };
}

export { backgroundLines };

← Back to Aceternity UI catalog