hyperText
A Text block/component from Magic UI — clean-room reimplemented for Domphy (see methodology). Call hyperText() with no arguments for a working demo, or edit the code below live.
Implementation notes
Per-character spans with two interval timers (fast scramble reassignment + slower left-to-right lock), hover-trigger-by-default with optional view-trigger, configurable duration/delay/character pool/tag, spaces preserved as non-animated gaps. Matches the spec's 'interval/frame-driven character substitution, not CSS keyframes' requirement. Character DOM refs are written to directly inside the loop (not via reactive State) since this is a continuous high-frequency effect, matching this package's numberTicker/dock convention.
Status: ported · Reference: Magic UI original
// magicui "Hyper Text" — clean-room reimplementation from the public
// behavior/visual spec only (no upstream source viewed or copied). Text
// whose characters flicker rapidly through random glyphs before locking
// into their true letters one at a time, left to right, so the word
// appears to "resolve" out of noise — a code-breaking/terminal-decrypt
// reveal. Default trigger is hover (replays every time), with an opt-in
// flag to instead (or additionally) trigger once on scroll-into-view.
//
// Each character is its own `<span>` so it can be swapped independently;
// spaces are rendered as plain, non-animated gaps. This is a JS-driven
// `textContent` swap loop — two `setInterval` timers per play (one fast
// tick that re-randomizes every not-yet-locked character, one slower tick
// that locks the next character in sequence) — not CSS keyframes or SVG,
// per the spec's "interval/frame-driven character substitution" note.
// Per-character DOM refs are captured in each character span's own
// `_onMount`/`_onRemove` rather than routed through reactive `State`, since
// this is a continuous high-frequency effect (same "write straight to the
// DOM node inside the loop" idiom `numberTicker`/`dock` use elsewhere in
// this package).
import type { DomphyElement, ElementNode, StyleObject } from "@domphy/core";
export interface HyperTextProps {
/** Text content to animate. Defaults to a short demo phrase. */
children?: string;
/** HTML tag the container renders as. Defaults to `"span"`. */
tag?: string;
/** Total milliseconds for the full scramble-to-resolve animation. Defaults to `800`. */
duration?: number;
/** Milliseconds to wait after the trigger fires before the scramble starts. Defaults to `0`. */
delay?: number;
/** Replays the scramble on every mouse hover. Defaults to `true`. */
hoverTrigger?: boolean;
/** Also (or instead) plays the scramble once, automatically, the first time the element scrolls
* into the viewport. Defaults to `false`. */
viewTrigger?: boolean;
/** Character pool randomly sampled while a character position is unresolved. Defaults to A-Z. */
characters?: string;
/** Passthrough style merged onto the container. */
style?: StyleObject;
}
const DEFAULT_CHARACTER_POOL = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
// How often (ms) unresolved characters are reassigned a fresh random glyph.
// Fast enough to read as a flicker, slow enough to stay legible per-frame.
const SCRAMBLE_TICK_MS = 40;
/**
* Text that scrambles through random characters before resolving,
* left-to-right, into its real content — a terminal-decrypt effect.
* Replays on hover by default; can also (or instead) auto-play once on
* scroll-into-view. Call with no arguments for a working demo.
*/
function hyperText(props: HyperTextProps = {}): DomphyElement {
const text = props.children ?? "Hover to Decode";
const tag = props.tag ?? "span";
const duration = props.duration ?? 800;
const delay = props.delay ?? 0;
const hoverTrigger = props.hoverTrigger ?? true;
const viewTrigger = props.viewTrigger ?? false;
const characterPool = props.characters ?? DEFAULT_CHARACTER_POOL;
const characters = Array.from(text);
const nonSpaceIndices = characters
.map((character, index) => index)
.filter((index) => characters[index] !== " " && characters[index].trim().length > 0);
const characterElementRefs: (HTMLElement | null)[] = new Array(characters.length).fill(null);
const characterSpans: DomphyElement<"span">[] = characters.map((character, index) => ({
span: character === " " ? " " : character,
_key: `character-${index}`,
style: { display: "inline-block" },
_onMount: (node: ElementNode) => {
characterElementRefs[index] = node.domElement as HTMLElement;
},
_onRemove: () => {
characterElementRefs[index] = null;
},
}));
const randomCharacterGlyph = () => characterPool.charAt(Math.floor(Math.random() * characterPool.length));
return {
[tag]: characterSpans,
style: { display: "inline-block", ...(props.style ?? {}) } as StyleObject,
_onMount: (node: ElementNode) => {
if (typeof window === "undefined") return;
const element = node.domElement as HTMLElement;
// Bare (non `window.`-qualified) timer functions: with both DOM and
// Node ambient globals in scope, `window.setInterval`'s return type
// resolves inconsistently against `ReturnType<typeof window.setInterval>`
// depending on call-site vs type-query context — the bare globals
// resolve to a single consistent type instead (same fix already
// applied to `sparklesText` elsewhere in this package).
let scrambleIntervalId: ReturnType<typeof setInterval> | null = null;
let lockIntervalId: ReturnType<typeof setInterval> | null = null;
let startTimeoutId: ReturnType<typeof setTimeout> | null = null;
const clearRunningTimers = () => {
if (scrambleIntervalId !== null) {
clearInterval(scrambleIntervalId);
scrambleIntervalId = null;
}
if (lockIntervalId !== null) {
clearInterval(lockIntervalId);
lockIntervalId = null;
}
};
const play = () => {
clearRunningTimers();
const totalSteps = nonSpaceIndices.length;
if (totalSteps === 0) return;
let resolvedSteps = 0;
const stepDurationMs = Math.max(duration / totalSteps, SCRAMBLE_TICK_MS);
const scrambleTick = () => {
for (let step = resolvedSteps; step < totalSteps; step += 1) {
const characterElement = characterElementRefs[nonSpaceIndices[step]];
if (characterElement) characterElement.textContent = randomCharacterGlyph();
}
};
const lockNextCharacter = () => {
if (resolvedSteps >= totalSteps) return;
const charIndex = nonSpaceIndices[resolvedSteps];
const characterElement = characterElementRefs[charIndex];
if (characterElement) characterElement.textContent = characters[charIndex];
resolvedSteps += 1;
if (resolvedSteps >= totalSteps) clearRunningTimers();
};
scrambleIntervalId = setInterval(scrambleTick, SCRAMBLE_TICK_MS);
lockIntervalId = setInterval(lockNextCharacter, stepDurationMs);
};
const trigger = () => {
if (startTimeoutId !== null) clearTimeout(startTimeoutId);
startTimeoutId = setTimeout(play, delay);
};
let intersectionObserver: IntersectionObserver | null = null;
if (viewTrigger) {
if (typeof IntersectionObserver !== "function") {
trigger();
} else {
intersectionObserver = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
trigger();
intersectionObserver?.disconnect();
intersectionObserver = null;
}
},
{ threshold: 0.2 },
);
intersectionObserver.observe(element);
}
}
const handleMouseEnter = () => trigger();
if (hoverTrigger) element.addEventListener("mouseenter", handleMouseEnter);
node.addHook("Remove", () => {
clearRunningTimers();
if (startTimeoutId !== null) clearTimeout(startTimeoutId);
if (hoverTrigger) element.removeEventListener("mouseenter", handleMouseEnter);
intersectionObserver?.disconnect();
});
},
// 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 `terminal.ts`'s typingLineElement()/fadeLineElement() document.
} as unknown as DomphyElement;
}
export { hyperText };