Kinetic Type
TypographyPer-glyph variable-font response to pointer proximity — axes swell near the cursor and relax away from it, continuously and reversibly. Glyph boxes are pinned to their resting widths so animating `wght` can't shift neighbours, stale the cached centres, or re-wrap the line; centres are measured once and only re-read on resize or font swap. The accessibility tree reads one clean string.
Type that answers the cursor.
Every glyph measures its own distance from the pointer, springs independently, and resolves to a font-variation-settings string. The boxes are pinned to their resting widths, so nothing reflows and no cached centre ever goes stale — the letters swell in place instead of shoving each other along the line.
Move across the text — zero React re-renders
Push peak weight to 900 and drag across a word: the line holds its wrapping exactly. Without the pinned boxes, that is the setting where it starts to jitter and re-wrap under the cursor.
"use client";
import * as React from "react";
import {
motion,
useMotionValue,
useReducedMotion,
useSpring,
useTransform,
type MotionValue,
type SpringOptions,
} from "motion/react";
import { cn } from "@/lib/utils";
/**
* KineticType — per-glyph variable-font response to pointer proximity.
*
* Each glyph's distance from the cursor drives a 0→1 intensity, springs, and
* lands as a `font-variation-settings` string. Continuous and reversible —
* unlike a one-shot scroll reveal, there is no "played" state to reset.
*
* ## The advance-width problem, and how it's resolved here
*
* Animating `wght` changes a glyph's advance width. Neighbours shift, the
* cached centres go stale, the falloff reads the wrong distance, and the line
* jitters and re-wraps under the cursor. Three resolutions exist — pin the
* per-glyph widths, drive from unshifted positions, or pick an axis that
* doesn't affect advance width.
*
* **This component pins the widths**, because it is the only one of the three
* that fixes *both* failure modes with one mechanism:
*
* 1. Each glyph is an `inline-block` whose `width` is pinned to the width it
* measured at its base axis values. Pinning to the *measured natural*
* width is layout-neutral — nothing moves at rest.
* 2. Because the box no longer resizes, the cached centres stay valid for
* the entire interaction, and there is no reflow at all while animating.
* 3. `text-align: center` inside each box means a swelling glyph overflows
* symmetrically, so it grows *in place* instead of shoving its
* neighbours. That reads as the intended effect rather than as a bug.
*
* The cost is kerning: an `inline-block` per glyph is an atomic inline box, so
* kerning pairs no longer apply. That cost is inherent to per-glyph splitting
* and is not specific to the pinning.
*
* ## Measuring
*
* Centres and widths are measured **once**, into an array, in a single
* read-then-write pass (all `getBoundingClientRect()` calls first, all style
* writes after — reading and writing alternately would thrash layout). Never
* per frame. Re-measurement is triggered only by:
*
* - `ResizeObserver` on the container (a re-wrap moves every centre), guarded
* on an actual width change so pinning can't feed back into the observer;
* - `document.fonts.ready`, because metrics measured against a fallback font
* are wrong the moment the real font swaps in.
*
* Per pointer move there is exactly **one** `getBoundingClientRect()` — the
* container's — not one per glyph.
*
* ## Off the render path
*
* The pointer is two motion values; each glyph derives its own intensity,
* spring, and settings string from them. Nothing here calls `setState`, so
* moving the cursor across a paragraph re-renders zero React components. Only
* glyphs whose value actually changed write to the DOM: the derived string is
* quantised to two decimals, so identical strings are dropped by the motion
* value before they reach `style`.
*
* ## Accessibility
*
* The glyph tree is entirely `aria-hidden`, and the real string is rendered
* once in a visually-hidden node. That is deliberately *not* `aria-label` on
* the wrapper: ARIA prohibits naming `role="paragraph"` and other generic
* roles, so an `aria-label` there is silently dropped by some screen readers
* and the text would vanish from the accessibility tree entirely. A real text
* node always works.
*/
export interface KineticAxis {
/** OpenType axis tag, e.g. `"wght"`, `"wdth"`, `"slnt"`. */
tag: string;
/** Value at rest, and the value every glyph holds under reduced motion. */
from: number;
/** Value for a glyph directly under the pointer. */
to: number;
}
type KineticTag = "p" | "span" | "div" | "h1" | "h2" | "h3" | "h4";
/**
* Default falloff — a raised cosine. Its derivative is zero at both ends, so
* neither the peak nor the radius boundary shows a crease as the cursor
* crosses it. A linear ramp visibly kinks at the edge.
*/
function defaultFalloff(distance: number, radius: number): number {
if (distance >= radius) return 0;
return (1 + Math.cos((distance / radius) * Math.PI)) / 2;
}
const REST = { x: Infinity, y: Infinity };
export interface KineticTypeProps {
/** The string to render. Split per glyph; words stay unbreakable. */
text: string;
/** Axes to drive. Defaults to `wght` 300 → 800. */
axes?: KineticAxis[];
/** Radius of influence in pixels. */
radius?: number;
/** Distance + radius → intensity in 0…1. Defaults to a raised cosine. */
falloff?: (distance: number, radius: number) => number;
/** Per-glyph spring. Snappy by default — type should feel responsive. */
spring?: SpringOptions;
/** Element to render as. */
as?: KineticTag;
className?: string;
style?: React.CSSProperties;
}
const DEFAULT_AXES: KineticAxis[] = [{ tag: "wght", from: 300, to: 800 }];
export function KineticType({
text,
axes = DEFAULT_AXES,
radius = 120,
falloff = defaultFalloff,
spring = { stiffness: 280, damping: 28, mass: 0.5 },
as = "p",
className,
style,
}: KineticTypeProps) {
const reduce = useReducedMotion();
const containerRef = React.useRef<HTMLElement>(null);
const glyphRefs = React.useRef<(HTMLSpanElement | null)[]>([]);
const centersRef = React.useRef<{ x: number; y: number }[]>([]);
const lastWidthRef = React.useRef(-1);
const pointerX = useMotionValue(REST.x);
const pointerY = useMotionValue(REST.y);
// Split into words (kept unbreakable) and the whitespace between them, with
// a flat glyph index so every glyph can find its own cached centre.
const { words, glyphCount } = React.useMemo(() => {
const tokens = text.split(/(\s+)/).filter((t) => t.length > 0);
let index = 0;
const out = tokens.map((token) => {
if (/^\s+$/.test(token)) return { space: token, glyphs: [] as const };
// Spread by code point, so surrogate pairs stay whole.
const glyphs = [...token].map((char) => ({ char, index: index++ }));
return { space: null, glyphs };
});
return { words: out, glyphCount: index };
}, [text]);
/**
* One pass: un-pin, read every box, then write every pin. Reads are batched
* ahead of writes so the browser does one layout, not one per glyph.
*/
const measure = React.useCallback(() => {
const container = containerRef.current;
if (!container) return;
const els = glyphRefs.current;
for (const el of els) if (el) el.style.width = "";
const containerRect = container.getBoundingClientRect();
const widths: number[] = new Array(els.length);
const centers: { x: number; y: number }[] = new Array(els.length);
for (let i = 0; i < els.length; i++) {
const el = els[i];
if (!el) {
widths[i] = 0;
centers[i] = REST;
continue;
}
const r = el.getBoundingClientRect();
widths[i] = r.width;
centers[i] = {
x: r.left - containerRect.left + r.width / 2,
y: r.top - containerRect.top + r.height / 2,
};
}
for (let i = 0; i < els.length; i++) {
const el = els[i];
if (el && widths[i] > 0) el.style.width = `${widths[i]}px`;
}
centersRef.current = centers;
lastWidthRef.current = containerRect.width;
}, []);
React.useEffect(() => {
const container = containerRef.current;
if (!container) return;
// New text means new glyphs: drop refs the old string left behind, and
// invalidate the width guard so the observer's first fire actually
// measures instead of short-circuiting on an unchanged container width.
glyphRefs.current.length = glyphCount;
lastWidthRef.current = -1;
const ro = new ResizeObserver((entries) => {
const width =
entries[0]?.borderBoxSize?.[0]?.inlineSize ?? container.offsetWidth;
// Pinning is layout-neutral, but guard anyway: a re-measure triggered by
// our own writes would be an observer feedback loop.
if (Math.abs(width - lastWidthRef.current) < 0.5) return;
measure();
});
ro.observe(container);
// Metrics read against a fallback font are wrong once the real one swaps.
let cancelled = false;
document.fonts?.ready.then(() => {
if (!cancelled) {
lastWidthRef.current = -1;
measure();
}
});
return () => {
cancelled = true;
ro.disconnect();
};
}, [measure, glyphCount]);
const track = React.useCallback(
(e: React.PointerEvent<HTMLElement>) => {
// The only rect read per move, and it's the container's — never a glyph's.
const rect = e.currentTarget.getBoundingClientRect();
pointerX.set(e.clientX - rect.left);
pointerY.set(e.clientY - rect.top);
},
[pointerX, pointerY],
);
const release = React.useCallback(() => {
pointerX.set(REST.x);
pointerY.set(REST.y);
}, [pointerX, pointerY]);
const baseSettings = React.useMemo(
() => axes.map((a) => `"${a.tag}" ${a.from}`).join(", "),
[axes],
);
const Tag = as;
return (
<Tag
ref={containerRef as React.Ref<never>}
data-slot="kinetic-type"
onPointerMove={track}
onPointerDown={track}
onPointerLeave={release}
onPointerCancel={release}
className={cn("relative touch-pan-y", className)}
style={{ fontVariationSettings: baseSettings, ...style }}
>
<span className="sr-only">{text}</span>
<span aria-hidden data-slot="kinetic-type-glyphs">
{words.map((token, i) =>
token.space !== null ? (
<React.Fragment key={`s${i}`}>{token.space}</React.Fragment>
) : (
<span key={`w${i}`} className="inline-block whitespace-nowrap">
{token.glyphs.map((glyph) => (
<Glyph
key={glyph.index}
char={glyph.char}
register={(el) => {
glyphRefs.current[glyph.index] = el;
}}
centers={centersRef}
index={glyph.index}
pointerX={pointerX}
pointerY={pointerY}
axes={axes}
radius={radius}
falloff={falloff}
spring={spring}
reduce={Boolean(reduce)}
/>
))}
</span>
),
)}
</span>
</Tag>
);
}
interface GlyphProps {
char: string;
index: number;
register: (el: HTMLSpanElement | null) => void;
centers: React.RefObject<{ x: number; y: number }[]>;
pointerX: MotionValue<number>;
pointerY: MotionValue<number>;
axes: KineticAxis[];
radius: number;
falloff: (distance: number, radius: number) => number;
spring: SpringOptions;
reduce: boolean;
}
function Glyph({
char,
index,
register,
centers,
pointerX,
pointerY,
axes,
radius,
falloff,
spring,
reduce,
}: GlyphProps) {
// Distance is read from the *cached* centre — the whole point of pinning the
// widths is that this number never goes stale mid-interaction.
const intensity = useTransform([pointerX, pointerY], ([x, y]: number[]) => {
if (reduce) return 0;
const center = centers.current[index];
if (!center) return 0;
const distance = Math.hypot(x - center.x, y - center.y);
if (!Number.isFinite(distance)) return 0;
const t = falloff(distance, radius);
return t < 0 ? 0 : t > 1 ? 1 : t;
});
const eased = useSpring(intensity, spring);
// Quantised to 2dp so glyphs that round to the same settings string never
// reach `style` — the motion value drops identical values.
const settings = useTransform(eased, (t) =>
axes
.map((a) => `"${a.tag}" ${(a.from + (a.to - a.from) * t).toFixed(2)}`)
.join(", "),
);
return (
<motion.span
ref={register}
data-slot="kinetic-type-glyph"
className="inline-block text-center"
style={{ fontVariationSettings: settings }}
>
{char}
</motion.span>
);
}Installation
npx shadcn@latest add https://ui.saumyarex.xyz/r/kinetic-type.json1. Install dependencies
npm install motion clsx tailwind-merge2. Copy the source into your project
"use client";
import * as React from "react";
import {
motion,
useMotionValue,
useReducedMotion,
useSpring,
useTransform,
type MotionValue,
type SpringOptions,
} from "motion/react";
import { cn } from "@/lib/utils";
/**
* KineticType — per-glyph variable-font response to pointer proximity.
*
* Each glyph's distance from the cursor drives a 0→1 intensity, springs, and
* lands as a `font-variation-settings` string. Continuous and reversible —
* unlike a one-shot scroll reveal, there is no "played" state to reset.
*
* ## The advance-width problem, and how it's resolved here
*
* Animating `wght` changes a glyph's advance width. Neighbours shift, the
* cached centres go stale, the falloff reads the wrong distance, and the line
* jitters and re-wraps under the cursor. Three resolutions exist — pin the
* per-glyph widths, drive from unshifted positions, or pick an axis that
* doesn't affect advance width.
*
* **This component pins the widths**, because it is the only one of the three
* that fixes *both* failure modes with one mechanism:
*
* 1. Each glyph is an `inline-block` whose `width` is pinned to the width it
* measured at its base axis values. Pinning to the *measured natural*
* width is layout-neutral — nothing moves at rest.
* 2. Because the box no longer resizes, the cached centres stay valid for
* the entire interaction, and there is no reflow at all while animating.
* 3. `text-align: center` inside each box means a swelling glyph overflows
* symmetrically, so it grows *in place* instead of shoving its
* neighbours. That reads as the intended effect rather than as a bug.
*
* The cost is kerning: an `inline-block` per glyph is an atomic inline box, so
* kerning pairs no longer apply. That cost is inherent to per-glyph splitting
* and is not specific to the pinning.
*
* ## Measuring
*
* Centres and widths are measured **once**, into an array, in a single
* read-then-write pass (all `getBoundingClientRect()` calls first, all style
* writes after — reading and writing alternately would thrash layout). Never
* per frame. Re-measurement is triggered only by:
*
* - `ResizeObserver` on the container (a re-wrap moves every centre), guarded
* on an actual width change so pinning can't feed back into the observer;
* - `document.fonts.ready`, because metrics measured against a fallback font
* are wrong the moment the real font swaps in.
*
* Per pointer move there is exactly **one** `getBoundingClientRect()` — the
* container's — not one per glyph.
*
* ## Off the render path
*
* The pointer is two motion values; each glyph derives its own intensity,
* spring, and settings string from them. Nothing here calls `setState`, so
* moving the cursor across a paragraph re-renders zero React components. Only
* glyphs whose value actually changed write to the DOM: the derived string is
* quantised to two decimals, so identical strings are dropped by the motion
* value before they reach `style`.
*
* ## Accessibility
*
* The glyph tree is entirely `aria-hidden`, and the real string is rendered
* once in a visually-hidden node. That is deliberately *not* `aria-label` on
* the wrapper: ARIA prohibits naming `role="paragraph"` and other generic
* roles, so an `aria-label` there is silently dropped by some screen readers
* and the text would vanish from the accessibility tree entirely. A real text
* node always works.
*/
export interface KineticAxis {
/** OpenType axis tag, e.g. `"wght"`, `"wdth"`, `"slnt"`. */
tag: string;
/** Value at rest, and the value every glyph holds under reduced motion. */
from: number;
/** Value for a glyph directly under the pointer. */
to: number;
}
type KineticTag = "p" | "span" | "div" | "h1" | "h2" | "h3" | "h4";
/**
* Default falloff — a raised cosine. Its derivative is zero at both ends, so
* neither the peak nor the radius boundary shows a crease as the cursor
* crosses it. A linear ramp visibly kinks at the edge.
*/
function defaultFalloff(distance: number, radius: number): number {
if (distance >= radius) return 0;
return (1 + Math.cos((distance / radius) * Math.PI)) / 2;
}
const REST = { x: Infinity, y: Infinity };
export interface KineticTypeProps {
/** The string to render. Split per glyph; words stay unbreakable. */
text: string;
/** Axes to drive. Defaults to `wght` 300 → 800. */
axes?: KineticAxis[];
/** Radius of influence in pixels. */
radius?: number;
/** Distance + radius → intensity in 0…1. Defaults to a raised cosine. */
falloff?: (distance: number, radius: number) => number;
/** Per-glyph spring. Snappy by default — type should feel responsive. */
spring?: SpringOptions;
/** Element to render as. */
as?: KineticTag;
className?: string;
style?: React.CSSProperties;
}
const DEFAULT_AXES: KineticAxis[] = [{ tag: "wght", from: 300, to: 800 }];
export function KineticType({
text,
axes = DEFAULT_AXES,
radius = 120,
falloff = defaultFalloff,
spring = { stiffness: 280, damping: 28, mass: 0.5 },
as = "p",
className,
style,
}: KineticTypeProps) {
const reduce = useReducedMotion();
const containerRef = React.useRef<HTMLElement>(null);
const glyphRefs = React.useRef<(HTMLSpanElement | null)[]>([]);
const centersRef = React.useRef<{ x: number; y: number }[]>([]);
const lastWidthRef = React.useRef(-1);
const pointerX = useMotionValue(REST.x);
const pointerY = useMotionValue(REST.y);
// Split into words (kept unbreakable) and the whitespace between them, with
// a flat glyph index so every glyph can find its own cached centre.
const { words, glyphCount } = React.useMemo(() => {
const tokens = text.split(/(\s+)/).filter((t) => t.length > 0);
let index = 0;
const out = tokens.map((token) => {
if (/^\s+$/.test(token)) return { space: token, glyphs: [] as const };
// Spread by code point, so surrogate pairs stay whole.
const glyphs = [...token].map((char) => ({ char, index: index++ }));
return { space: null, glyphs };
});
return { words: out, glyphCount: index };
}, [text]);
/**
* One pass: un-pin, read every box, then write every pin. Reads are batched
* ahead of writes so the browser does one layout, not one per glyph.
*/
const measure = React.useCallback(() => {
const container = containerRef.current;
if (!container) return;
const els = glyphRefs.current;
for (const el of els) if (el) el.style.width = "";
const containerRect = container.getBoundingClientRect();
const widths: number[] = new Array(els.length);
const centers: { x: number; y: number }[] = new Array(els.length);
for (let i = 0; i < els.length; i++) {
const el = els[i];
if (!el) {
widths[i] = 0;
centers[i] = REST;
continue;
}
const r = el.getBoundingClientRect();
widths[i] = r.width;
centers[i] = {
x: r.left - containerRect.left + r.width / 2,
y: r.top - containerRect.top + r.height / 2,
};
}
for (let i = 0; i < els.length; i++) {
const el = els[i];
if (el && widths[i] > 0) el.style.width = `${widths[i]}px`;
}
centersRef.current = centers;
lastWidthRef.current = containerRect.width;
}, []);
React.useEffect(() => {
const container = containerRef.current;
if (!container) return;
// New text means new glyphs: drop refs the old string left behind, and
// invalidate the width guard so the observer's first fire actually
// measures instead of short-circuiting on an unchanged container width.
glyphRefs.current.length = glyphCount;
lastWidthRef.current = -1;
const ro = new ResizeObserver((entries) => {
const width =
entries[0]?.borderBoxSize?.[0]?.inlineSize ?? container.offsetWidth;
// Pinning is layout-neutral, but guard anyway: a re-measure triggered by
// our own writes would be an observer feedback loop.
if (Math.abs(width - lastWidthRef.current) < 0.5) return;
measure();
});
ro.observe(container);
// Metrics read against a fallback font are wrong once the real one swaps.
let cancelled = false;
document.fonts?.ready.then(() => {
if (!cancelled) {
lastWidthRef.current = -1;
measure();
}
});
return () => {
cancelled = true;
ro.disconnect();
};
}, [measure, glyphCount]);
const track = React.useCallback(
(e: React.PointerEvent<HTMLElement>) => {
// The only rect read per move, and it's the container's — never a glyph's.
const rect = e.currentTarget.getBoundingClientRect();
pointerX.set(e.clientX - rect.left);
pointerY.set(e.clientY - rect.top);
},
[pointerX, pointerY],
);
const release = React.useCallback(() => {
pointerX.set(REST.x);
pointerY.set(REST.y);
}, [pointerX, pointerY]);
const baseSettings = React.useMemo(
() => axes.map((a) => `"${a.tag}" ${a.from}`).join(", "),
[axes],
);
const Tag = as;
return (
<Tag
ref={containerRef as React.Ref<never>}
data-slot="kinetic-type"
onPointerMove={track}
onPointerDown={track}
onPointerLeave={release}
onPointerCancel={release}
className={cn("relative touch-pan-y", className)}
style={{ fontVariationSettings: baseSettings, ...style }}
>
<span className="sr-only">{text}</span>
<span aria-hidden data-slot="kinetic-type-glyphs">
{words.map((token, i) =>
token.space !== null ? (
<React.Fragment key={`s${i}`}>{token.space}</React.Fragment>
) : (
<span key={`w${i}`} className="inline-block whitespace-nowrap">
{token.glyphs.map((glyph) => (
<Glyph
key={glyph.index}
char={glyph.char}
register={(el) => {
glyphRefs.current[glyph.index] = el;
}}
centers={centersRef}
index={glyph.index}
pointerX={pointerX}
pointerY={pointerY}
axes={axes}
radius={radius}
falloff={falloff}
spring={spring}
reduce={Boolean(reduce)}
/>
))}
</span>
),
)}
</span>
</Tag>
);
}
interface GlyphProps {
char: string;
index: number;
register: (el: HTMLSpanElement | null) => void;
centers: React.RefObject<{ x: number; y: number }[]>;
pointerX: MotionValue<number>;
pointerY: MotionValue<number>;
axes: KineticAxis[];
radius: number;
falloff: (distance: number, radius: number) => number;
spring: SpringOptions;
reduce: boolean;
}
function Glyph({
char,
index,
register,
centers,
pointerX,
pointerY,
axes,
radius,
falloff,
spring,
reduce,
}: GlyphProps) {
// Distance is read from the *cached* centre — the whole point of pinning the
// widths is that this number never goes stale mid-interaction.
const intensity = useTransform([pointerX, pointerY], ([x, y]: number[]) => {
if (reduce) return 0;
const center = centers.current[index];
if (!center) return 0;
const distance = Math.hypot(x - center.x, y - center.y);
if (!Number.isFinite(distance)) return 0;
const t = falloff(distance, radius);
return t < 0 ? 0 : t > 1 ? 1 : t;
});
const eased = useSpring(intensity, spring);
// Quantised to 2dp so glyphs that round to the same settings string never
// reach `style` — the motion value drops identical values.
const settings = useTransform(eased, (t) =>
axes
.map((a) => `"${a.tag}" ${(a.from + (a.to - a.from) * t).toFixed(2)}`)
.join(", "),
);
return (
<motion.span
ref={register}
data-slot="kinetic-type-glyph"
className="inline-block text-center"
style={{ fontVariationSettings: settings }}
>
{char}
</motion.span>
);
}import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
/** Merge conditional class names and resolve Tailwind conflicts. */
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
/** Shared view-transition name so a card preview morphs into the detail
* page's preview. Must match on both ends; unique per registry entry. */
export function previewTransitionName(name: string) {
return `preview-${name}`;
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
text | string | — | The string to render. Split per glyph (by code point, so surrogate pairs stay whole); words stay unbreakable and whitespace is preserved as real text nodes. Requires a variable font on the element — the axes you name must exist in it. |
axes | { tag: string; from: number; to: number }[] | [{ tag: "wght", from: 300, to: 800 }] | OpenType axes to drive. `from` is the resting value (and the value reduced-motion pins to), `to` is the value under the pointer. Any axis tag works — `wght`, `wdth`, `slnt`, `opsz` — as long as the loaded font exposes it. |
radius | number | 120 | Radius of influence in pixels. |
falloff | (distance: number, radius: number) => number | — | Distance and radius → intensity in 0…1. Defaults to a raised cosine, whose zero derivative at both ends means neither the peak nor the radius boundary shows a crease. |
spring | SpringOptions | { stiffness: 280, damping: 28, mass: 0.5 } | Per-glyph spring. Each glyph springs independently, so the ripple across a word is emergent. |
as | "p" | "span" | "div" | "h1" | "h2" | "h3" | "h4" | "p" | Element to render as. |