Domphy

canvasText

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

Implementation notes

Full behavioral port: a requestAnimationFrame loop draws several sine-driven bezier curves each frame, cycling through the colors palette, then clips to the exact glyph silhouette every frame via the canvas 2D 'destination-in' compositing trick (fillText with destination-in erases every pixel outside the glyphs — canvas has no native glyph-outline/Path2D API, so this is the standard substitute, and it matches the domSketch's own framing of the clip as derived from 'an offscreen measured/rendered copy of the same text'). Wrapper fontSize is driven through themeSize() (not a hardcoded px number) and resolved back via getComputedStyle for the canvas font string, so dataSize/dataDensity context is respected. Gated by IntersectionObserver so the loop pauses off-screen. An sr-only span carries the real accessible text since the visible glyphs are canvas pixels. Doctor-clean (0 diagnostics) and 3/3 tests pass.

Status: ported · Reference: Aceternity UI original

// Aceternity UI "Canvas Text" — clean-room reimplementation from the public
// behavior/visual spec only (no upstream source viewed or copied). Large
// display heading text whose letterforms are filled with several looping,
// hand-drawn-looking flowing curved lines painted on a canvas and clipped to
// the exact glyph shapes — colorful strokes visible only inside the letters,
// perfectly cut out to the text silhouette.
//
// Canvas 2D has no native "glyph outline as a path" API, so the clip is done
// with the classic two-pass compositing trick instead of a real Path2D
// outline: each frame the curves are drawn normally (`source-over`), then
// the context switches to `destination-in` and paints the text once more
// with `fillText` — `destination-in` keeps only pixels where BOTH the
// just-drawn curves AND the text glyphs are opaque, erasing everything
// outside the letters. The glyph metrics driving both the canvas font string
// and the wrapper's own box size are read once via `getComputedStyle`/
// `measureText` after mount — the wrapper's own CSS `fontSize`/`fontWeight`
// are set through the theme's `themeSize()` token (a large bold heading
// scale, not a hardcoded pixel value), so the component still respects
// `dataSize`/`dataDensity` context while canvas gets a concrete px number to
// draw with. A single shared `requestAnimationFrame` loop, gated by an
// `IntersectionObserver`, drives the undulation — the same perf idiom this
// package's `canvasRevealEffect()`/`flickeringGrid()` already use.

import type { DomphyElement, ElementNode, StyleObject } from "@domphy/core";
import { type ThemeColor, themeColorToken, themeSize } from "@domphy/theme";

export interface CanvasTextProps {
  /** Text content rendered as the clipped display heading. Defaults to `"Domphy"`. */
  text?: string;
  /** Theme color families cycled across the stack of flowing lines. Defaults to `["info", "primary", "secondary"]`. */
  colors?: ThemeColor[];
  /** Seconds for one full undulation cycle of the flowing curves. Defaults to `6`. */
  animationDuration?: number;
  /** Stroke thickness of each line, in canvas px. Defaults to `2`. */
  lineWidth?: number;
  /** Vertical spacing between adjacent lines, in canvas px. Defaults to `10`. */
  lineGap?: number;
  /** Amplitude of each line's waviness, in canvas px. Defaults to `8`. */
  curveIntensity?: number;
  /** Class name applied to an absolutely-positioned backdrop layer behind the
   * clipped canvas, so callers can supply their own light/dark page-matching
   * background. No backdrop element is rendered at all when omitted. */
  backgroundClassName?: string;
  /** Renders the wrapper `position: absolute` instead of `relative`, so it can
   * be stacked on top of other content. Defaults to `false`. */
  overlay?: boolean;
  /** Extra class name merged onto the wrapper's native `class` attribute. */
  className?: string;
  /** Passthrough style merged onto the wrapper. */
  style?: StyleObject;
}

const SR_ONLY_STYLE = {
  position: "absolute",
  width: "1px",
  height: "1px",
  padding: "0",
  margin: "-1px",
  overflow: "hidden",
  clip: "rect(0, 0, 0, 0)",
  whiteSpace: "nowrap",
  border: "0",
} as const;

const DEFAULT_COLORS: ThemeColor[] = ["info", "primary", "secondary"];
// How many bezier segments make up one flowing line across the canvas width —
// enough to read as a smooth hand-drawn curve without over-tessellating.
const SEGMENTS_PER_LINE = 8;

/**
 * Large display heading text filled with several looping, colorful flowing
 * curves painted on a canvas and clipped to the exact glyph shapes — an
 * ambient, hover-free effect that starts automatically on mount. Call with
 * no arguments for a working demo.
 */
function canvasText(props: CanvasTextProps = {}): DomphyElement<"div"> {
  const text = props.text ?? "Domphy";
  const colors = props.colors && props.colors.length > 0 ? props.colors : DEFAULT_COLORS;
  const animationDurationSeconds = Math.max(0.5, props.animationDuration ?? 6);
  const lineWidthPx = Math.max(0.5, props.lineWidth ?? 2);
  const lineGapPx = Math.max(2, props.lineGap ?? 10);
  const curveIntensityPx = Math.max(0, props.curveIntensity ?? 8);
  const overlay = props.overlay ?? false;

  const srOnlyText: DomphyElement<"span"> = { span: text, style: SR_ONLY_STYLE };

  const backdropLayer: DomphyElement<"div"> | null = props.backgroundClassName
    ? {
        div: null,
        ariaHidden: "true",
        class: props.backgroundClassName,
        style: { position: "absolute", inset: 0 } as StyleObject,
      }
    : null;

  const canvasElement = {
    canvas: null,
    ariaHidden: "true",
    // Decorative canvas with no text of its own — the sr-only span above
    // carries the accessible text; fill colors are resolved imperatively
    // below (canvas 2D has no themeColor() var() concept), mirroring
    // canvasRevealEffect.ts's own exemption for the same reason.
    _doctorDisable: "missing-color",
    style: {
      position: "absolute",
      inset: 0,
      width: "100%",
      height: "100%",
      pointerEvents: "none",
    } as StyleObject,
    _onMount: (node: ElementNode) => {
      const canvas = node.domElement as HTMLCanvasElement | null;
      const wrapperElement = canvas?.parentElement ?? null;
      if (!canvas || !wrapperElement || typeof window === "undefined") return;
      const context = canvas.getContext("2d");
      if (!context) return;

      const devicePixelRatio = Math.min(window.devicePixelRatio || 1, 2);

      const lineColors = colors.map((color) => {
        try {
          return themeColorToken(node, "shift-10", color);
        } catch {
          return "#3b82f6";
        }
      });

      const computedStyle = window.getComputedStyle(wrapperElement);
      const fontSizePx = Math.max(24, parseFloat(computedStyle.fontSize) || 96);
      const fontWeight = computedStyle.fontWeight || "800";
      const fontFamily = computedStyle.fontFamily || "sans-serif";
      const fontString = `${fontWeight} ${fontSizePx}px ${fontFamily}`;

      context.font = fontString;
      const metrics = context.measureText(text);
      const paddingPx = fontSizePx * 0.15;
      const ascent = metrics.actualBoundingBoxAscent || fontSizePx * 0.8;
      const descent = metrics.actualBoundingBoxDescent || fontSizePx * 0.25;
      const cssWidth = Math.max(1, metrics.width + paddingPx * 2);
      const cssHeight = Math.max(1, ascent + descent + paddingPx * 2);
      const textX = paddingPx;
      const textBaselineY = ascent + paddingPx;

      // Runtime-computed box, not knowable until the glyphs are measured —
      // written imperatively, same idiom canvasRevealEffect.ts/kineticText.ts
      // already use for measurement-driven sizing.
      wrapperElement.style.width = `${cssWidth}px`;
      wrapperElement.style.height = `${cssHeight}px`;
      canvas.style.width = `${cssWidth}px`;
      canvas.style.height = `${cssHeight}px`;
      canvas.width = Math.max(1, Math.floor(cssWidth * devicePixelRatio));
      canvas.height = Math.max(1, Math.floor(cssHeight * devicePixelRatio));
      context.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);
      context.textBaseline = "alphabetic";

      const lineCount = Math.max(1, Math.ceil(cssHeight / lineGapPx));
      const angularSpeed = (Math.PI * 2) / (animationDurationSeconds * 1000);

      let animationFrameId: number | null = null;
      let intersectionObserver: IntersectionObserver | null = null;

      function drawFrame(timeMs: number): void {
        // Belt-and-suspenders stop condition: some hosts (e.g. a test
        // harness that wipes the DOM directly instead of going through the
        // framework's removal lifecycle, or an environment without
        // `IntersectionObserver` to gate the loop) never fire the "Remove"
        // hook below. Bailing here once the canvas is detached prevents the
        // loop from leaking forever across unrelated later tests.
        if (!canvas!.isConnected) return;
        context!.clearRect(0, 0, cssWidth, cssHeight);
        context!.globalCompositeOperation = "source-over";

        const phase = timeMs * angularSpeed;
        for (let lineIndex = 0; lineIndex < lineCount; lineIndex += 1) {
          const baseY = lineIndex * lineGapPx + lineGapPx / 2;
          context!.beginPath();
          context!.strokeStyle = lineColors[lineIndex % lineColors.length] ?? lineColors[0];
          context!.lineWidth = lineWidthPx;
          context!.lineCap = "round";

          let previousX = 0;
          let previousY = baseY + Math.sin(phase + lineIndex * 0.7) * curveIntensityPx;
          context!.moveTo(previousX, previousY);
          for (let segment = 1; segment <= SEGMENTS_PER_LINE; segment += 1) {
            const x = (segment / SEGMENTS_PER_LINE) * cssWidth;
            const y = baseY + Math.sin(phase + segment * 0.9 + lineIndex * 0.7) * curveIntensityPx;
            const midX = (previousX + x) / 2;
            const midY = (previousY + y) / 2;
            context!.quadraticCurveTo(previousX, previousY, midX, midY);
            previousX = x;
            previousY = y;
          }
          context!.stroke();
        }

        // Erase every pixel that isn't also inside the glyph shapes — the
        // clip-to-text-silhouette step. See file header comment.
        context!.globalCompositeOperation = "destination-in";
        context!.fillStyle = "#000";
        context!.font = fontString;
        context!.fillText(text, textX, textBaselineY);
        context!.globalCompositeOperation = "source-over";

        animationFrameId = window.requestAnimationFrame(drawFrame);
      }

      function startLoop(): void {
        if (animationFrameId !== null) return;
        animationFrameId = window.requestAnimationFrame(drawFrame);
      }
      function stopLoop(): void {
        if (animationFrameId === null) return;
        window.cancelAnimationFrame(animationFrameId);
        animationFrameId = null;
      }

      if (typeof IntersectionObserver === "function") {
        intersectionObserver = new IntersectionObserver((entries) => {
          for (const entry of entries) {
            if (entry.isIntersecting) startLoop();
            else stopLoop();
          }
        });
        intersectionObserver.observe(wrapperElement);
      } else {
        startLoop();
      }

      node.addHook("Remove", () => {
        stopLoop();
        intersectionObserver?.disconnect();
      });
    },
  } as DomphyElement<"canvas">;

  return {
    div: [srOnlyText, ...(backdropLayer ? [backdropLayer] : []), canvasElement],
    class: props.className,
    // The doctor's `missing-color` heuristic flags any reactive style prop
    // that resolves to a `var(...)` reference without a paired `color` — it
    // can't distinguish a *size* token from a *color* token. This wrapper's
    // `fontSize` exists purely so _onMount can read the resolved px value
    // back via getComputedStyle for canvas metrics; no visible CSS-rendered
    // text actually uses it as typographic color context (the sr-only span
    // is visually clipped, and the visible glyphs are canvas pixels).
    _doctorDisable: "missing-color",
    style: {
      position: overlay ? "absolute" : "relative",
      display: "inline-block",
      // Function-form theme token, not a literal — themeSize()'s own return
      // value drives the resolved px number read back via getComputedStyle
      // in _onMount above, so canvas sizing stays tied to dataSize context.
      fontSize: (listener) => themeSize(listener, "increase-7"),
      fontWeight: () => "800",
      ...(props.style ?? {}),
    } as StyleObject,
  } as DomphyElement<"div">;
}

export { canvasText };

← Back to Aceternity UI catalog