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.
Props
| Prop | Type | Description |
|---|---|---|
children | string | Text content. Defaults to a short demo phrase. |
tag | KineticTextTag | Semantic wrapping tag/heading level. Defaults to "h1". |
className | string | Extra class name merged onto the wrapper's native class attribute. |
style | StyleObject | Passthrough style merged onto the wrapper. |
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" — Domphy port of kinetic-text.tsx.
//
// Headline text rendered thin by default; hovering ripples a font-weight
// thickening wave through neighboring letters, simulating motion without
// any character ever actually moving.
//
// 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, mirroring upstream's CSS sibling chain
// (`hover:` / `has-[+span:hover]` / `[:hover+&]`): the hovered letter reaches
// 900, its ±1 neighbors 600, its ±2 neighbors 400, everything else the 300
// baseline. 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,
Listener,
StyleObject,
} from "@domphy/core";
import { themeColor, themeSize } 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 `"h1"`. */
tag?: KineticTextTag;
/** 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 = 300;
// Discrete weight ramp keyed by index-distance from the hovered letter,
// mirroring upstream's CSS sibling chain: hovered 900, ±1 600, ±2 400.
// Any distance past ±2 falls off the array and reverts to the 300 baseline.
const NEIGHBOR_WEIGHTS = [900, 600, 400];
// Upstream --hover-padding: calc(1em / 12) (~0.083em), applied to the hovered
// letter and both immediate ±1 neighbors.
const HOVER_PADDING = "calc(1em / 12)";
const STROKE_WIDTH_EM = 0.0208;
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 weight wave 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 ?? "h1";
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: {
// 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,
// Match upstream's transition list exactly: font-weight, stroke-color,
// and padding (each 0.4s). Stroke-width and shadow are intentionally
// not transitioned upstream.
transition:
"font-weight 0.4s, -webkit-text-stroke-color 0.4s, padding 0.4s",
willChange: "font-weight, -webkit-text-stroke-width, padding",
WebkitTextStrokeColor: "transparent",
WebkitTextStrokeWidth: `${STROKE_WIDTH_EM}em`,
},
_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]: [...characterSpans, srOnlyText],
style: {
// Upstream container is `flex flex-wrap` so every letter is a flex item
// that can wrap mid-word; spaces (rendered as above) hold their cell.
display: "flex",
flexWrap: "wrap",
// Headline-scale text is the entire premise of this effect (a large
// hover-thickening display phrase) — without an explicit size token
// it inherits whatever tiny ambient font-size the caller's context
// happens to have, which reads as plain unstyled body text.
fontSize: (listener: Listener) => themeSize(listener, "increase-6"),
// Declared explicitly (not just inherited) so it re-evaluates with the
// tone context, satisfying the doctor's missing-color contract for the
// reactive fontSize above.
color: (listener: Listener) => themeColor(listener, "shift-14"),
...(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;
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.webkitTextStrokeColor = "";
characterElement.style.webkitTextStrokeWidth = "";
continue;
}
const distance = Math.abs(index - hoveredIndex);
// Discrete weights (900 / 600 / 400) for the hovered letter and its
// ±1, ±2 neighbors; past ±2 the lookup is undefined and reverts to
// the 300 baseline.
const weight = NEIGHBOR_WEIGHTS[distance];
characterElement.style.fontWeight =
weight === undefined ? "" : String(weight);
// padding-inline nudges the hovered letter AND both immediate ±1
// neighbors apart (upstream hover: / has-[+span:hover] / [:hover+&]).
characterElement.style.paddingInline =
distance <= 1 ? HOVER_PADDING : "";
characterElement.style.webkitTextStrokeColor =
distance === 0 ? "currentColor" : "";
characterElement.style.webkitTextStrokeWidth =
distance === 0 ? `${STROKE_WIDTH_EM * 2}em` : "";
}
};
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 };