"use client";
import * as React from "react";
import { AnimatePresence, motion, useMotionValue, useSpring, useTransform, useVelocity } from "motion/react";
export type TextImageRevealMode = "inline" | "float";
export type TextImageRevealCue = "underline" | "dotted" | "highlight";
export interface TextImageRevealSpring {
stiffness: number;
damping: number;
mass: number;
}
export interface TextImageRevealProps extends React.HTMLAttributes<HTMLDivElement> {
/** Running text. Mark revealable words with `<TextImageReveal.Word image alt>`. */
children: React.ReactNode;
/** "inline" grows a pill-shaped image inside the line; "float" shows a larger image near the pointer or word. */
mode?: TextImageRevealMode;
/** Width of the inline pill, in em (it scales with the font size). */
pillWidth?: number;
/** Width of the floating image in px (height is 3:4 of it). */
floatWidth?: number;
/** Maximum tilt of the floating image, in degrees. It leans with the pointer's horizontal speed. */
tilt?: number;
/** Float mode: the image follows the mouse. Off: it anchors above the word. */
followPointer?: boolean;
/** Spring for the pill width and the floating image. */
spring?: TextImageRevealSpring;
/** How revealable words are marked in the text. */
cue?: TextImageRevealCue;
/** Accent for the cue and focus ring. */
accent?: string;
}
export interface TextImageRevealWordProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "children"> {
/** Image URL (a data URI works too). */
image: string;
/** Describes the image. Exposed to assistive tech through aria-describedby. */
alt: string;
children: React.ReactNode;
}
interface RevealContext {
mode: TextImageRevealMode;
pillWidth: number;
floatWidth: number;
tilt: number;
followPointer: boolean;
spring: TextImageRevealSpring;
cue: TextImageRevealCue;
reduced: boolean;
rootRef: React.RefObject<HTMLDivElement | null>;
}
const DEFAULT_SPRING: TextImageRevealSpring = { stiffness: 320, damping: 28, mass: 0.7 };
const RevealCtx = React.createContext<RevealContext | null>(null);
function subscribeReducedMotion(cb: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", cb);
return () => mq.removeEventListener("change", cb);
}
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
const CUES: Record<TextImageRevealCue, string> = {
underline:
"underline decoration-[var(--tir-accent)] decoration-[0.07em] underline-offset-[0.2em] data-[open=true]:decoration-[0.12em]",
dotted:
"underline decoration-dotted decoration-zinc-400 decoration-[0.08em] underline-offset-[0.2em] data-[open=true]:decoration-solid data-[open=true]:decoration-[var(--tir-accent)] dark:decoration-zinc-500",
highlight:
"rounded-[0.2em] bg-[color-mix(in_srgb,var(--tir-accent)_16%,transparent)] px-[0.12em] data-[open=true]:bg-[color-mix(in_srgb,var(--tir-accent)_28%,transparent)]",
};
/**
* Editorial text in which marked words reveal an image: inline (the word grows a pill-shaped
* thumbnail and pushes the line apart) or float (a larger image springs in near the pointer and
* leans with its speed). Words are real buttons: hover, keyboard focus or a tap reveals the image,
* Escape hides it, and the image's description is linked with aria-describedby.
*/
export function TextImageReveal({
children,
mode = "inline",
pillWidth = 2.4,
floatWidth = 200,
tilt = 8,
followPointer = true,
spring = DEFAULT_SPRING,
cue = "underline",
accent = "#FF7448",
className = "",
style,
...rest
}: TextImageRevealProps) {
const rootRef = React.useRef<HTMLDivElement>(null);
const reduced = usePrefersReducedMotion();
const ctx: RevealContext = { mode, pillWidth, floatWidth, tilt, followPointer, spring, cue, reduced, rootRef };
return (
<RevealCtx.Provider value={ctx}>
<div
ref={rootRef}
{...rest}
className={`relative ${className}`}
style={{ ...style, ["--tir-accent" as string]: accent }}
>
{children}
</div>
</RevealCtx.Provider>
);
}
/** A word inside <TextImageReveal> that reveals an image. Also available as `TextImageReveal.Word`. */
export function TextImageRevealWord({
image,
alt,
children,
className = "",
onPointerDown,
onPointerEnter,
onPointerMove,
onPointerLeave,
onFocus,
onBlur,
onClick,
onKeyDown,
...rest
}: TextImageRevealWordProps) {
const parent = React.useContext(RevealCtx);
const fallbackRef = React.useRef<HTMLDivElement>(null);
const ctx: RevealContext = parent ?? {
mode: "inline",
pillWidth: 2.4,
floatWidth: 200,
tilt: 8,
followPointer: true,
spring: DEFAULT_SPRING,
cue: "underline",
reduced: false,
rootRef: fallbackRef,
};
const { mode, pillWidth, floatWidth, tilt, followPointer, spring, cue, reduced } = ctx;
const [open, setOpen] = React.useState(false);
const descId = React.useId();
const buttonRef = React.useRef<HTMLButtonElement>(null);
const lastPointer = React.useRef("mouse");
const floatHeight = Math.round(floatWidth * 0.75);
// Floating image position (relative to the root), smoothed by a spring; the lean follows its velocity.
const x = useMotionValue(0);
const y = useMotionValue(0);
const springX = useSpring(x, spring);
const springY = useSpring(y, spring);
const velocityX = useVelocity(springX);
const lean = useTransform(velocityX, [-1400, 0, 1400], [-tilt, 0, tilt], { clamp: true });
// Alternate the resting tilt between words so a sentence of images doesn't look stamped.
const side = image.length % 2 === 0 ? 1 : -1;
const rootBox = () => ctx.rootRef.current?.getBoundingClientRect() ?? null;
/** Places the image centred above a point (in root coordinates), kept inside the root's width. */
const placeAt = (px: number, py: number, jump: boolean) => {
const root = rootBox();
const width = root?.width ?? floatWidth;
const nx = Math.max(-16, Math.min(width - floatWidth + 16, px - floatWidth / 2));
const ny = py - floatHeight - 14 < -floatHeight * 0.6 ? py + 28 : py - floatHeight - 14;
x.set(Math.round(nx));
y.set(Math.round(ny));
if (jump) {
springX.jump(Math.round(nx));
springY.jump(Math.round(ny));
}
};
/** Anchors the floating image above the word (keyboard, touch, or followPointer off). */
const placeAtWord = (jump: boolean) => {
const root = rootBox();
const word = buttonRef.current?.getBoundingClientRect();
if (!root || !word) return;
placeAt(word.left - root.left + word.width / 2, word.top - root.top, jump);
};
const show = (anchor: "word" | { clientX: number; clientY: number }) => {
if (mode === "float") {
if (anchor === "word" || !followPointer) placeAtWord(true);
else {
const root = rootBox();
if (root) placeAt(anchor.clientX - root.left, anchor.clientY - root.top, true);
}
}
setOpen(true);
};
// Touch / pen: a tap elsewhere closes the image (some browsers never blur a tapped button).
React.useEffect(() => {
if (!open) return;
const onDown = (event: PointerEvent) => {
if (event.pointerType === "mouse") return;
if (buttonRef.current?.contains(event.target as Node)) return;
setOpen(false);
};
document.addEventListener("pointerdown", onDown, true);
return () => document.removeEventListener("pointerdown", onDown, true);
}, [open]);
const cueClass = CUES[cue];
return (
<>
<button
ref={buttonRef}
type="button"
{...rest}
aria-expanded={open}
aria-describedby={descId}
data-open={open}
className={`inline cursor-default rounded-[0.18em] bg-transparent p-0 text-left [color:inherit] [font:inherit] [letter-spacing:inherit] outline-hidden transition-[text-decoration-color,background-color,text-decoration-thickness] duration-200 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--tir-accent)] ${cueClass} ${className}`}
onPointerDown={(event) => {
lastPointer.current = event.pointerType;
onPointerDown?.(event);
}}
onPointerEnter={(event) => {
lastPointer.current = event.pointerType;
if (event.pointerType === "mouse") show({ clientX: event.clientX, clientY: event.clientY });
onPointerEnter?.(event);
}}
onPointerMove={(event) => {
// Motion values only — no React state on pointer move.
if (event.pointerType === "mouse" && mode === "float" && followPointer && !reduced) {
const root = rootBox();
if (root) placeAt(event.clientX - root.left, event.clientY - root.top, false);
}
onPointerMove?.(event);
}}
onPointerLeave={(event) => {
// Keep it open while the word also has keyboard focus.
if (event.pointerType === "mouse" && !buttonRef.current?.matches(":focus-visible")) setOpen(false);
onPointerLeave?.(event);
}}
onFocus={(event) => {
// Keyboard focus reveals; a mouse click or tap that focuses the button does not.
if (event.currentTarget.matches(":focus-visible")) show("word");
onFocus?.(event);
}}
onBlur={(event) => {
setOpen(false);
onBlur?.(event);
}}
onClick={(event) => {
// Mouse hover already reveals; taps and Enter / Space toggle.
const keyboard = event.detail === 0;
if (keyboard || lastPointer.current !== "mouse") {
if (open) setOpen(false);
else show("word");
}
onClick?.(event);
}}
onKeyDown={(event) => {
// Escape hides only this image, and only while it is showing.
if (event.key === "Escape" && open) {
event.preventDefault();
event.stopPropagation();
setOpen(false);
}
onKeyDown?.(event);
}}
>
{children}
{mode === "inline" ? (
<motion.span
aria-hidden="true"
initial={false}
animate={{ width: open ? `${pillWidth}em` : "0em", marginLeft: open ? "0.22em" : "0em", opacity: open ? 1 : 0 }}
transition={reduced ? { duration: 0 } : { type: "spring", ...spring, opacity: { duration: 0.18 } }}
className="inline-block h-[0.82em] overflow-hidden rounded-full align-[-0.1em] shadow-[inset_0_0_0_1px_rgb(0_0_0/0.08)] dark:shadow-[inset_0_0_0_1px_rgb(255_255_255/0.12)]"
>
{/* eslint-disable-next-line @next/next/no-img-element -- the shipped component must not depend on next/image */}
<img
src={image}
alt=""
draggable={false}
decoding="async"
className="block h-full max-w-none object-cover"
style={{ width: `${pillWidth}em` }}
/>
</motion.span>
) : null}
</button>
{/* Hidden from reading order but still resolved by aria-describedby. */}
<span id={descId} hidden>
{`Image: ${alt}`}
</span>
{mode === "float" ? (
<motion.span
aria-hidden="true"
className="pointer-events-none absolute left-0 top-0 z-20 block"
style={{ x: reduced ? x : springX, y: reduced ? y : springY, rotate: reduced ? 0 : lean, width: floatWidth, height: floatHeight }}
>
<AnimatePresence>
{open ? (
<motion.span
key="image"
className="block size-full overflow-hidden rounded-xl bg-zinc-200 shadow-[0_24px_48px_-20px_rgb(0_0_0/0.45),0_0_0_1px_rgb(0_0_0/0.08)] dark:bg-zinc-800 dark:shadow-[0_24px_48px_-20px_rgb(0_0_0/0.8),0_0_0_1px_rgb(255_255_255/0.1)]"
initial={reduced ? { opacity: 1 } : { opacity: 0, scale: 0.6, rotate: -side * tilt }}
animate={{ opacity: 1, scale: 1, rotate: reduced ? 0 : side * tilt * 0.35 }}
exit={reduced ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, scale: 0.85, transition: { duration: 0.16 } }}
transition={reduced ? { duration: 0 } : { type: "spring", ...spring, opacity: { duration: 0.16 } }}
>
{/* eslint-disable-next-line @next/next/no-img-element -- the shipped component must not depend on next/image */}
<img src={image} alt="" draggable={false} decoding="async" className="block size-full object-cover" />
</motion.span>
) : null}
</AnimatePresence>
</motion.span>
) : null}
</>
);
}
TextImageReveal.Word = TextImageRevealWord;