Domphy

kineticText

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

Implementation notes

One span per character (index-distance falloff, not CSS sibling selectors, per the spec's own clean-room guidance) plus a visually-hidden sr-only duplicate of the full text (aurora-text's sr-only-text pattern) since the decorative letter spans are aria-hidden. Pointer tracking mirrors this package's own dock.ts idiom: rAF-throttled pointermove finds the nearest letter and writes font-weight/padding-inline/text-shadow imperatively per letter (continuous high-frequency effect, matching dock.ts's own exemption from the declarative style object). Declarative resting weight uses the (l)=>value function-form escape hatch (thin baseline is the entire premise, no patch expresses it). Skips attaching hover listeners when matchMedia('(hover: hover)') reports no hover capability, per spec. tag is caller-configurable (h1..h6/p/div/span).

Status: ported · Reference: Magic UI original

// Magic UI "Kinetic Text" — clean-room reimplementation.
//
// Headline text rendered thin by default; hovering ripples a font-weight
// thickening wave through neighboring letters, simulating motion without
// any character ever actually moving. Implemented purely from the block's
// public functional/visual spec — no upstream Magic UI source was viewed
// or copied.
//
// Each character is its own `<span>` (spaces preserved as ` ` so
// line-wrapping still behaves), and a `pointermove`-driven, rAF-throttled
// loop (same "capture DOM refs, throttle via rAF, write style imperatively"
// idiom as this package's own `dock.ts` icon-magnification effect) finds
// the letter nearest the pointer and writes each letter's `font-weight`
// directly on its own DOM node — an *index*-distance falloff (not a pixel
// one), per the spec's own clean-room guidance, since no CSS
// sibling-selector chain can express "N steps out" generically for
// arbitrary text. These are continuous, high-frequency imperative writes
// (not part of the declarative `style` object the doctor's static analyzer
// walks) — the same exemption `dock.ts`'s `ref.element.style.transform`
// writes rely on. The declarative resting style only ever sets a *thin*
// weight through a `(l) => value` function form (the doctor only flags a
// literal typography value), matching the `wordRotate`/`numberTicker`
// escape hatch used elsewhere in this package.
//
// A visually-hidden duplicate of the full text is rendered alongside the
// decorative, `aria-hidden` per-letter spans, so screen readers announce
// the real string once — the same sr-only-text + aria-hidden-decoration
// pattern `auroraText` uses in this package.

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

export type KineticTextTag = "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "p" | "div" | "span";

export interface KineticTextProps {
  /** Text content. Defaults to a short demo phrase. */
  children?: string;
  /** Semantic wrapping tag/heading level. Defaults to `"h2"`. */
  tag?: KineticTextTag;
  /** Accent color family used for the hovered letter's faint glow. Defaults to `"primary"`. */
  accentColor?: ThemeColor;
  /** Extra class name merged onto the wrapper's native `class` attribute. */
  className?: string;
  /** Passthrough style merged onto the wrapper. */
  style?: StyleObject;
}

const DEFAULT_TEXT = "Kinetic Type In Motion";
const BASE_WEIGHT = 200;
const PEAK_WEIGHT = 900;
// How many letters out from the hovered one the weight bump still reaches,
// tapering back to the thin baseline — "2+ falloff steps" per the spec.
const FALLOFF_RADIUS = 4;

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;

/**
 * Headline text whose letters thicken in a smooth weight gradient centered
 * on the pointer as it hovers across them — no character ever moves. Static
 * (thin) on touch devices with no hover capability. Call with no arguments
 * for a working demo phrase.
 */
function kineticText(props: KineticTextProps = {}): DomphyElement {
  const text = props.children ?? DEFAULT_TEXT;
  const tag = props.tag ?? "h2";
  const accentColor = props.accentColor ?? "primary";

  const characters = Array.from(text);
  const characterElementRefs: (HTMLElement | null)[] = new Array(characters.length).fill(null);

  const characterSpans: DomphyElement<"span">[] = characters.map((character, index) => ({
    span: character === " " ? " " : character,
    _key: `character-${index}`,
    ariaHidden: "true",
    style: {
      display: "inline-block",
      // Function-form escape hatch (see file header) — the thin resting
      // weight is the entire premise of this component, not something a
      // typography patch can express.
      fontWeight: () => BASE_WEIGHT,
      transition: "font-weight 260ms ease, padding-inline 260ms ease, text-shadow 260ms ease",
      willChange: "font-weight",
    },
    _onMount: (node: ElementNode) => {
      characterElementRefs[index] = node.domElement as HTMLElement;
    },
    _onRemove: () => {
      characterElementRefs[index] = null;
    },
  }));

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

  return {
    [tag]: [srOnlyText, ...characterSpans],
    style: { display: "inline-block", ...(props.style ?? {}) } as StyleObject,
    class: props.className,
    _onMount: (node: ElementNode) => {
      if (typeof window === "undefined") return;
      const element = node.domElement as HTMLElement;

      const supportsHover =
        typeof window.matchMedia !== "function" || window.matchMedia("(hover: hover)").matches;
      if (!supportsHover) return;

      const accentColorToken = (() => {
        try {
          return themeColorToken(node, "shift-9", accentColor);
        } catch {
          return null;
        }
      })();

      let animationFrame: number | null = null;
      let hoveredIndex: number | null = null;

      const applyWeights = () => {
        animationFrame = null;
        for (let index = 0; index < characterElementRefs.length; index += 1) {
          const characterElement = characterElementRefs[index];
          if (!characterElement) continue;
          if (hoveredIndex === null) {
            characterElement.style.fontWeight = "";
            characterElement.style.paddingInline = "";
            characterElement.style.textShadow = "";
            continue;
          }
          const distance = Math.abs(index - hoveredIndex);
          const falloff = Math.max(0, 1 - distance / FALLOFF_RADIUS);
          const weight = Math.round(BASE_WEIGHT + (PEAK_WEIGHT - BASE_WEIGHT) * falloff * falloff);
          characterElement.style.fontWeight = String(weight);
          characterElement.style.paddingInline = distance === 0 ? "0.04em" : "";
          characterElement.style.textShadow =
            distance === 0 && accentColorToken ? `0 0 0.08em ${accentColorToken}` : "";
        }
      };

      const scheduleUpdate = () => {
        if (animationFrame === null) animationFrame = window.requestAnimationFrame(applyWeights);
      };

      const handlePointerMove = (event: PointerEvent) => {
        let closestIndex: number | null = null;
        let closestDistance = Number.POSITIVE_INFINITY;
        for (let index = 0; index < characterElementRefs.length; index += 1) {
          const characterElement = characterElementRefs[index];
          if (!characterElement) continue;
          const rect = characterElement.getBoundingClientRect();
          const center = rect.left + rect.width / 2;
          const distance = Math.abs(event.clientX - center);
          if (distance < closestDistance) {
            closestDistance = distance;
            closestIndex = index;
          }
        }
        hoveredIndex = closestIndex;
        scheduleUpdate();
      };

      const handlePointerLeave = () => {
        hoveredIndex = null;
        scheduleUpdate();
      };

      element.addEventListener("pointermove", handlePointerMove);
      element.addEventListener("pointerleave", handlePointerLeave);

      node.addHook("Remove", () => {
        element.removeEventListener("pointermove", handlePointerMove);
        element.removeEventListener("pointerleave", handlePointerLeave);
        if (animationFrame !== null) window.cancelAnimationFrame(animationFrame);
      });
    },
    // The host tag is caller-configurable (`props.tag`), so it can't be
    // narrowed to one arm of the DomphyElement tag union statically — same
    // caveat `hyperText.ts` documents for its own dynamic-tag return.
  } as unknown as DomphyElement;
}

export { kineticText };

← Back to Magic UI catalog