Domphy

tracingBeam

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

Implementation notes

Static procedurally generated S-curve SVG path, measured via getTotalLength() (wrapped in try/catch for non-layout test runtimes), revealed top-down with the standard stroke-dasharray/stroke-dashoffset technique. Scroll progress is scoped to the content wrapper's own bounding rect (not document height), per the spec's research note. A critically-damped spring-damper integrator (reusing the same physics smoothCursor.ts already implements in this package) chases the raw scroll fraction, giving the leading edge a slight overshoot-and-settle on fast scrolls. Reference's literal #18CCFC/#6344F5/#AE48FF hex stops replaced with cycling ThemeColor roles (default info/primary/secondary). Cross-sibling DOM refs (svg column vs. content column) are wired via a mutual-registration + guarded trySetup() pattern rather than DOM querying, since this package's client render() fires _onMount top-down (a parent's hook can fire before a later sibling subtree even exists).

Status: ported · Reference: Aceternity UI original

// Aceternity UI "Tracing Beam" — clean-room reimplementation from the public
// behavior/visual spec only (no upstream source viewed or copied). A thin
// vertical gradient beam running alongside long-form content, whose colored
// segment grows downward in step with how far the reader has scrolled past
// the content block.
//
// The path itself is a gentle, static S-curve (a handful of cubic-bezier
// segments oscillating left-right) sampled once and measured with the real
// SVG `getTotalLength()`; the standard "draw a line as you scroll" technique
// — a single full-length `stroke-dasharray` plus a `stroke-dashoffset` that
// shrinks toward `0` as progress grows — reveals the gradient path from the
// top down (the same dash-offset idiom `animatedCircularProgressBar()` uses
// for its ring, applied to a line instead of a circle). Progress is *not*
// raw scroll pixels: a `scroll`/`resize` listener recomputes
// `contentElement.getBoundingClientRect()` on every tick, scoping the 0-1
// fraction to how far the content wrapper itself has scrolled through the
// viewport (matching the spec's "scoped to the content wrapper, not the
// whole page" research note) rather than document height. A
// critically-damped spring (the same spring-damper integrator
// `smoothCursor()` already uses in this package) chases that raw fraction,
// so fast scrolling makes the beam's leading edge overshoot slightly past
// the target and settle back — the "springy, elastic" leading-edge stretch
// the spec describes.
//
// The `<svg>` column and the content column are DOM siblings, but Domphy's
// client `render()` fires `_onMount` top-down (a parent's hook runs before
// its later siblings even exist — see `ElementNode.render`), so the svg's
// own `_onMount` cannot assume the content element already exists. Both
// sides instead register themselves into shared closure variables and each
// calls a guarded `trySetup()` once both are present, independent of which
// one happens to mount first (the same `registerNode`-pair idiom
// `animatedBeam()` uses for its node badges).

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

export interface TracingBeamProps {
  /** Long-form content rendered beside the beam. Defaults to a small demo article. */
  children?: DomphyElement | DomphyElement[];
  /** Horizontal wiggle amplitude of the path's S-curve, in SVG viewBox units (the lane is 20 units wide). Defaults to `6`. */
  curvature?: number;
  /** Theme color roles for the traveled gradient segment, sampled across three stops. Defaults to `["info", "primary", "secondary"]`. */
  gradientColors?: ThemeColor[];
  /** Toggles the small circular marker node at the top of the path. Defaults to `true`. */
  showMarker?: boolean;
  /** Passthrough style merged onto the outer wrapper. */
  style?: StyleObject;
}

const LANE_WIDTH = 20;
const LANE_CENTER = LANE_WIDTH / 2;
const SEGMENT_HEIGHT = 120;

interface SpringState {
  displayed: number;
  velocity: number;
}

/** Builds a gentle S-curve `d` string oscillating across the lane, one cubic-bezier segment per `SEGMENT_HEIGHT` of height. */
function buildTracingPath(height: number, curvature: number): string {
  const segmentCount = Math.max(1, Math.ceil(height / SEGMENT_HEIGHT));
  let d = `M${LANE_CENTER} 0`;
  let previousY = 0;
  for (let index = 0; index < segmentCount; index += 1) {
    const nextY = Math.min(height, previousY + SEGMENT_HEIGHT);
    const direction = index % 2 === 0 ? 1 : -1;
    const controlOneY = previousY + (nextY - previousY) * 0.33;
    const controlTwoY = previousY + (nextY - previousY) * 0.66;
    const swingX = LANE_CENTER + direction * curvature;
    d += ` C${swingX} ${controlOneY.toFixed(1)}, ${swingX} ${controlTwoY.toFixed(1)}, ${LANE_CENTER} ${nextY.toFixed(1)}`;
    previousY = nextY;
  }
  return d;
}

function defaultTracingContent(): DomphyElement[] {
  return [
    { h2: "Tracing Beam", $: [heading()] } as DomphyElement,
    {
      p: "The beam beside this column fills in as you scroll past it, with a springy leading edge on fast scrolls.",
      $: [paragraph()],
    } as DomphyElement,
    {
      p: "Keep scrolling — the colored segment tracks how far this content block has moved through the viewport, not raw page height.",
      $: [paragraph()],
    } as DomphyElement,
  ];
}

/**
 * A thin vertical gradient beam beside long-form content that traces
 * downward in step with scroll progress through the content block, with a
 * springy, slightly elastic leading edge on fast scrolls. Call with no
 * arguments for a working demo — a beam beside a small placeholder article.
 */
function tracingBeam(props: TracingBeamProps = {}): DomphyElement<"div"> {
  const curvature = props.curvature ?? 6;
  const gradientColors = props.gradientColors && props.gradientColors.length > 0 ? props.gradientColors : (["info", "primary", "secondary"] as ThemeColor[]);
  const showMarker = props.showMarker ?? true;

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

  const gradientId = "domphy-tracing-beam-gradient";

  // `<stop>` is a paint-server node, not text — exempt from the missing-color
  // contract (mirrors animatedBeam.ts's own gradient stops).
  const gradientStops: DomphyElement[] = [
    { stop: null, offset: "0%", style: { stopColor: (listener) => themeColor(listener, "shift-9", gradientColors[0]) } as StyleObject, _doctorDisable: "missing-color" } as DomphyElement,
    {
      stop: null,
      offset: "50%",
      style: { stopColor: (listener) => themeColor(listener, "shift-9", gradientColors[1 % gradientColors.length]) } as StyleObject,
      _doctorDisable: "missing-color",
    } as DomphyElement,
    {
      stop: null,
      offset: "100%",
      style: { stopColor: (listener) => themeColor(listener, "shift-9", gradientColors[2 % gradientColors.length]) } as StyleObject,
      _doctorDisable: "missing-color",
    } as DomphyElement,
  ];

  let svgElement: SVGSVGElement | null = null;
  let basePathElement: SVGPathElement | null = null;
  let traveledPathElement: SVGPathElement | null = null;
  let contentElement: HTMLElement | null = null;
  let removeTeardown: (() => void) | null = null;

  function trySetup(): void {
    if (!svgElement || !basePathElement || !traveledPathElement || !contentElement) return;
    if (removeTeardown || typeof window === "undefined") return;

    const spring: SpringState = { displayed: 0, velocity: 0 };
    const stiffness = 210;
    const damping = 26;
    const restDelta = 0.0015;

    let totalLength = 0;
    let animationFrameId: number | null = null;
    let resizeObserver: ResizeObserver | null = null;
    let lastFrameTime = 0;

    function rebuildPath(): void {
      const height = Math.max(1, contentElement!.getBoundingClientRect().height);
      svgElement!.setAttribute("viewBox", `0 0 ${LANE_WIDTH} ${height}`);
      const d = buildTracingPath(height, curvature);
      basePathElement!.setAttribute("d", d);
      traveledPathElement!.setAttribute("d", d);
      // jsdom/non-layout runtimes don't implement SVG geometry methods — fall
      // back to the sampled height so the component still renders/animates.
      try {
        totalLength = traveledPathElement!.getTotalLength();
      } catch {
        totalLength = height;
      }
      traveledPathElement!.setAttribute("stroke-dasharray", String(totalLength));
      paintProgress();
    }

    function rawProgress(): number {
      const rect = contentElement!.getBoundingClientRect();
      const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
      if (rect.height <= viewportHeight) {
        const entered = viewportHeight - rect.top;
        return Math.min(1, Math.max(0, entered / rect.height));
      }
      const scrollable = Math.max(1, rect.height - viewportHeight);
      return Math.min(1, Math.max(0, -rect.top / scrollable));
    }

    function paintProgress(): void {
      const offset = totalLength * (1 - spring.displayed);
      traveledPathElement!.setAttribute("stroke-dashoffset", String(offset));
    }

    function step(time: number): void {
      const deltaSeconds = Math.min((time - lastFrameTime) / 1000, 1 / 30);
      lastFrameTime = time;
      const target = rawProgress();

      // Spring-damper: force = -stiffness * displacement - damping * velocity
      // (the same integrator `smoothCursor()` uses for its trailing glyph).
      const acceleration = -stiffness * (spring.displayed - target) - damping * spring.velocity;
      spring.velocity += acceleration * deltaSeconds;
      spring.displayed = Math.min(1.15, Math.max(-0.05, spring.displayed + spring.velocity * deltaSeconds));

      paintProgress();

      const settled = Math.abs(target - spring.displayed) < restDelta && Math.abs(spring.velocity) < restDelta;
      animationFrameId = settled ? null : window.requestAnimationFrame(step);
    }

    function ensureLoopRunning(): void {
      if (animationFrameId === null) {
        lastFrameTime = performance.now();
        animationFrameId = window.requestAnimationFrame(step);
      }
    }

    rebuildPath();

    if (typeof ResizeObserver !== "undefined") {
      resizeObserver = new ResizeObserver(() => rebuildPath());
      resizeObserver.observe(contentElement);
    }

    const onScrollOrResize = () => ensureLoopRunning();
    window.addEventListener("scroll", onScrollOrResize, { passive: true });
    window.addEventListener("resize", onScrollOrResize, { passive: true });

    removeTeardown = () => {
      if (animationFrameId !== null) window.cancelAnimationFrame(animationFrameId);
      resizeObserver?.disconnect();
      window.removeEventListener("scroll", onScrollOrResize);
      window.removeEventListener("resize", onScrollOrResize);
      removeTeardown = null;
    };
  }

  const beamSvg: DomphyElement = {
    svg: [
      { defs: [{ linearGradient: gradientStops, id: gradientId, x1: "0", y1: "0", x2: "0", y2: "1" } as DomphyElement] } as DomphyElement,
      {
        path: null,
        d: "",
        fill: "none",
        strokeWidth: "2",
        strokeLinecap: "round",
        _key: "base-path",
        ariaHidden: "true",
        // Decorative background stroke with no text of its own — exempt from
        // the missing-color contract (mirrors dotPattern.ts's gradient stops).
        _doctorDisable: "missing-color",
        _onMount: (node: ElementNode) => {
          basePathElement = node.domElement as unknown as SVGPathElement;
          trySetup();
        },
        _onRemove: () => {
          basePathElement = null;
        },
        style: { stroke: (listener) => themeColor(listener, "shift-3") } as StyleObject,
      } as DomphyElement,
      {
        path: null,
        d: "",
        fill: "none",
        stroke: `url(#${gradientId})`,
        strokeWidth: "2",
        strokeLinecap: "round",
        _key: "traveled-path",
        _onMount: (node: ElementNode) => {
          traveledPathElement = node.domElement as unknown as SVGPathElement;
          trySetup();
        },
        _onRemove: () => {
          traveledPathElement = null;
        },
      } as DomphyElement,
      ...(showMarker
        ? [
            {
              circle: null,
              cx: String(LANE_CENTER),
              cy: "6",
              r: "5",
              _key: "marker",
              ariaHidden: "true",
              style: { fill: (listener) => themeColor(listener, "shift-9", gradientColors[0]) } as StyleObject,
              _doctorDisable: "missing-color",
            } as DomphyElement,
          ]
        : []),
    ],
    viewBox: `0 0 ${LANE_WIDTH} 1`,
    preserveAspectRatio: "none",
    ariaHidden: "true",
    _onMount: (node: ElementNode) => {
      svgElement = node.domElement as unknown as SVGSVGElement;
      trySetup();
    },
    _onRemove: () => {
      svgElement = null;
      removeTeardown?.();
    },
    style: { display: "block", width: "100%", height: "100%" } as StyleObject,
  } as DomphyElement;

  return {
    div: [
      {
        div: [beamSvg],
        style: {
          position: "sticky",
          top: themeSpacing(4),
          width: themeSpacing(LANE_WIDTH),
          height: "100%",
          alignSelf: "stretch",
          flexShrink: 0,
        } as StyleObject,
      } as DomphyElement,
      {
        div: contentChildren,
        _onMount: (node: ElementNode) => {
          contentElement = node.domElement as HTMLElement;
          trySetup();
        },
        _onRemove: () => {
          contentElement = null;
          removeTeardown?.();
        },
        style: { flex: 1, minWidth: 0 } as StyleObject,
      } as DomphyElement,
    ],
    style: {
      position: "relative",
      display: "flex",
      alignItems: "flex-start",
      gap: themeSpacing(4),
      ...(props.style ?? {}),
    } as StyleObject,
  };
}

export { tracingBeam };

← Back to Aceternity UI catalog