Domphy

squigglyText

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

Implementation notes

Full literal port of the domSketch: N pre-built SVG <filter> defs (each a feTurbulence fractalNoise generator + feDisplacementMap, varying seed/scale per step) live in a hidden inline <svg>, and a setInterval swaps which filter id is applied via the text wrapper's CSS filter property every stepDuration ms, cycling and looping — the stepped/stop-motion jitter the spec describes, not a smooth interpolation. scale alternates between the full value and a reduced fraction every other step per the spec's own note. REQUIRED A GENUINE CORE FIX (not a workaround): packages/core/src/constants/SvgTags.ts was missing feTurbulence/feDisplacementMap (their attribute typings already existed in HtmlAttributeMap.ts, so the doctor's unknown-tag rule stayed silent even though the elements would have rendered unnamespaced and inert in a real browser) — confirmed by direct inspection, fixed additively (also added the sibling feComponentTransfer/feFuncR/G/B/A tags for completeness). Also found CamelAttributes.ts was missing baseFrequency/numOctaves/xChannelSelector/yChannelSelector (literal-camelCase SVG presentation attributes, not kebab-case) and added those too, or my JS-side attributes would have rendered as the wrong DOM attribute names. Both fixes rebuilt and packages/core's own test suite (156 tests) still passes. Verified via a debug script that the rendered filter/feTurbulence/feDisplacementMap elements are correctly SVG-namespaced. This also resolves the exact gap noiseTexture.ts (pre-existing sibling file) had documented and worked around with a canvas substitute — that file was left untouched (out of this task's scope) but the underlying core gap it flagged is now fixed. Doctor-clean (0 diagnostics) and 4/4 tests pass. Per the task's own researchNote, the reference URL's actual content (glyph-wobble via SVG filters) does not match the task's short label ('squiggly animated underline'); built to match the linked domSketch/spec, not the mismatched label.

Status: ported · Reference: Aceternity UI original

// Aceternity UI "Squiggly Text" — clean-room reimplementation from the
// public behavior/visual spec only (no upstream source viewed or copied).
// Text whose glyphs continuously ripple/wobble in place via a small, looping
// set of pre-built SVG turbulence-noise + displacement-map filters, swapped
// on a fixed timer — a stepped/stop-motion jitter rather than a smoothly
// interpolated wobble, per the spec's own description.
//
// NOTE ON THE REFERENCE URL (per the task's own researchNote): the short
// label this task shipped with ("squiggly animated underline") does not
// match what this component's actual reference URL
// (ui.aceternity.com/components/squiggly-text) documents — glyph
// displacement via cycling SVG filters, no underline anywhere. Built to
// match the linked spec/domSketch, not the mismatched label.
//
// CORE FIX REQUIRED FOR THIS COMPONENT: `<feTurbulence>`/`<feDisplacementMap>`
// only have any visual effect when created in the SVG namespace.
// `packages/core/src/constants/SvgTags.ts` (the allowlist `ElementNode`
// consults to decide `createElementNS` vs a plain unnamespaced
// `createElement`) was missing both — confirmed by inspection; their
// attribute typings already existed in `HtmlAttributeMap.ts`, so the
// doctor's `unknown-tag` rule stayed silent even though the element would
// have rendered inert. Separately, `baseFrequency`/`numOctaves`/
// `xChannelSelector`/`yChannelSelector` are literal-camelCase SVG
// presentation attributes (not CSS-style kebab-case), and were missing from
// `CamelAttributes.ts`, so they would have been written to the DOM as
// `base-frequency`/etc — attribute names the SVG filter spec doesn't
// recognize. Both gaps are fixed at the source in this change (additive
// only) rather than worked around with a canvas/CSS substitute, so this
// ships as a literal, fully namespaced, correctly-attributed SVG filter
// chain — matching noiseTexture.ts's own documented gap, now closed.

import type { DomphyElement, ElementNode, StyleObject } from "@domphy/core";

export type SquigglyTextTag = "span" | "div";

export interface SquigglyTextProps {
  /** Text/content distorted by the wobble. Defaults to a short demo phrase. */
  children?: string;
  /** Number of distinct displacement frames in the loop — more steps read as
   * a smoother-looking cycle. Defaults to `5`. */
  steps?: number;
  /** Milliseconds each displacement frame is held before switching to the next. Defaults to `180`. */
  stepDuration?: number;
  /** Maximum pixel displacement. Alternates with a reduced value every other
   * step, per the spec's "can alternate between two values" note. Defaults to `4`. */
  scale?: number;
  /** Controls how coarse/fine the underlying noise pattern is — lower is
   * longer, smoother waves. Defaults to `0.02`. */
  baseFrequency?: number;
  /** Noise complexity/detail level. Defaults to `2`. */
  numOctaves?: number;
  /** Renders as an inline `<span>` or block `<div>`. Defaults to `"span"`. */
  as?: SquigglyTextTag;
  /** Extra class name merged onto the wrapper's native `class` attribute. */
  className?: string;
  /** Passthrough style merged onto the wrapper. */
  style?: StyleObject;
}

let squigglyTextInstanceCounter = 0;

const DEFAULT_TEXT = "Wobbly, hand-drawn, always in motion";
// Every other step uses this fraction of `scale` instead of the full value —
// the spec's "scale can alternate between two values" note.
const ALTERNATE_SCALE_RATIO = 0.55;

/** One turbulence-noise + displacement-map filter per step, each with its own
 * seed so consecutive steps read as distinct, randomized-looking warps
 * rather than a single repeated shape. */
function stepFilter(filterId: string, stepIndex: number, scale: number, baseFrequency: number, numOctaves: number): DomphyElement {
  const stepScale = stepIndex % 2 === 0 ? scale : scale * ALTERNATE_SCALE_RATIO;
  return {
    filter: [
      {
        feTurbulence: null,
        type: "fractalNoise",
        baseFrequency: String(baseFrequency),
        numOctaves: String(numOctaves),
        seed: String(stepIndex * 11 + 3),
        result: "noise",
      } as DomphyElement,
      {
        feDisplacementMap: null,
        in: "SourceGraphic",
        in2: "noise",
        scale: String(stepScale),
        xChannelSelector: "R",
        yChannelSelector: "G",
      } as DomphyElement,
    ],
    id: filterId,
    _key: filterId,
  } as DomphyElement;
}

/** Hidden SVG sprite holding one pre-built filter per step — lives alongside
 * the visible text as a zero-size child, per the spec's domSketch. */
function filterDefs(filterIds: string[], scale: number, baseFrequency: number, numOctaves: number): DomphyElement<"svg"> {
  return {
    svg: [
      {
        defs: filterIds.map((filterId, stepIndex) => stepFilter(filterId, stepIndex, scale, baseFrequency, numOctaves)),
      } as DomphyElement,
    ],
    ariaHidden: "true",
    _key: "filter-defs",
    style: { position: "absolute", width: 0, height: 0, overflow: "hidden" } as StyleObject,
  } as DomphyElement<"svg">;
}

/**
 * Text whose glyphs continuously ripple/wobble via a small, looping set of
 * pre-built SVG turbulence-displacement filters, stepping (not smoothly
 * fading) from one to the next — a stop-motion jitter. Runs automatically
 * and continuously once mounted, no interaction needed. Call with no
 * arguments for a working demo phrase.
 */
function squigglyText(props: SquigglyTextProps = {}): DomphyElement {
  const text = props.children ?? DEFAULT_TEXT;
  const stepCount = Math.max(2, Math.round(props.steps ?? 5));
  const stepDurationMs = Math.max(30, props.stepDuration ?? 180);
  const scale = Math.max(0, props.scale ?? 4);
  const baseFrequency = props.baseFrequency ?? 0.02;
  const numOctaves = Math.max(1, Math.round(props.numOctaves ?? 2));
  const tag = props.as ?? "span";

  const instanceId = ++squigglyTextInstanceCounter;
  const filterIds = Array.from({ length: stepCount }, (_unused, index) => `domphy-squiggly-text-${instanceId}-${index}`);

  return {
    [tag]: [text, filterDefs(filterIds, scale, baseFrequency, numOctaves)],
    class: props.className,
    style: {
      display: tag === "div" ? "block" : "inline-block",
      filter: `url(#${filterIds[0]})`,
      ...(props.style ?? {}),
    } as StyleObject,
    _onMount: (node: ElementNode) => {
      if (typeof window === "undefined") return;
      const element = node.domElement as HTMLElement;
      let stepIndex = 0;
      const timer = window.setInterval(() => {
        stepIndex = (stepIndex + 1) % filterIds.length;
        element.style.filter = `url(#${filterIds[stepIndex]})`;
      }, stepDurationMs);
      node.addHook("Remove", () => window.clearInterval(timer));
    },
    // The host tag is caller-configurable (`props.as`), so it can't be
    // narrowed to one arm of the DomphyElement tag union statically — same
    // caveat kineticText.ts/hyperText.ts document for their own dynamic-tag
    // return.
  } as unknown as DomphyElement;
}

export { squigglyText };

← Back to Aceternity UI catalog