Files
infinite-canvas/web/src/components/ui/dia-text-reveal.tsx
T

227 lines
7.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useRef, useState } from "react";
import { animate, motion, useInView, useMotionValue, useReducedMotion, useTransform, type HTMLMotionProps } from "motion/react";
import { cn } from "@/lib/utils";
const DEFAULT_COLORS = ["#c679c4", "#fa3d1d", "#ffb005", "#e1e1fe", "#0358f7"];
const BAND_HALF = 17;
const SWEEP_START = -BAND_HALF;
const SWEEP_END = 100 + BAND_HALF;
const sweepEase = (t: number) => (t < 0.5 ? 4 * t ** 3 : 1 - (-2 * t + 2) ** 3 / 2);
function buildGradient(pos: number, colors: string[], textColor: string) {
const bandStart = pos - BAND_HALF;
const bandEnd = pos + BAND_HALF;
if (bandStart >= 100) {
return `linear-gradient(90deg, ${textColor}, ${textColor})`;
}
const n = colors.length;
const parts: string[] = [];
if (bandStart > 0) parts.push(`${textColor} 0%`, `${textColor} ${bandStart.toFixed(2)}%`);
colors.forEach((c, i) => {
const pct = n === 1 ? pos : bandStart + (i / (n - 1)) * BAND_HALF * 2;
parts.push(`${c} ${pct.toFixed(2)}%`);
});
if (bandEnd < 100) parts.push(`transparent ${bandEnd.toFixed(2)}%`, `transparent 100%`);
return `linear-gradient(90deg, ${parts.join(", ")})`;
}
function measureWidths(el: HTMLElement, texts: string[]) {
const ghost = el.cloneNode() as HTMLElement;
Object.assign(ghost.style, {
position: "absolute",
visibility: "hidden",
pointerEvents: "none",
width: "auto",
whiteSpace: "nowrap",
});
el.parentElement!.appendChild(ghost);
const widths = texts.map((t) => {
ghost.textContent = t;
return ghost.getBoundingClientRect().width;
});
ghost.remove();
return widths;
}
/**
* Props for {@link DiaTextReveal}.
*/
export interface DiaTextRevealProps extends Omit<HTMLMotionProps<"span">, "ref" | "children" | "style" | "animate" | "transition" | "color"> {
/**
* Text to reveal. Pass multiple strings to rotate when {@link DiaTextRevealProps.repeat} is `true`.
*/
text: string | string[];
/**
* Colors sampled across the moving gradient band. Defaults to a built-in palette.
*/
colors?: string[];
/**
* CSS color for revealed text after the sweep and for leading/trailing regions during the animation.
* @defaultValue `"var(--foreground)"`
*/
textColor?: string;
/**
* Duration of one sweep pass, in seconds.
* @defaultValue `1.5`
*/
duration?: number;
/**
* Delay before the sweep starts, in seconds.
* @defaultValue `0`
*/
delay?: number;
/**
* When `text` is an array, replay the sweep and advance to the next string after each completion.
* @defaultValue `false`
*/
repeat?: boolean;
/**
* Pause between cycles when {@link DiaTextRevealProps.repeat} is `true`, in seconds.
* @defaultValue `0.5`
*/
repeatDelay?: number;
/**
* If `true`, the animation starts only after the element enters the viewport.
* @defaultValue `true`
*/
startOnView?: boolean;
/**
* Passed to `useInView`: if `true`, in-view detection fires at most once (no replay on scroll-back).
* @defaultValue `true`
*/
once?: boolean;
/**
* Additional class names for the animated `span` (e.g. typography utilities).
*/
className?: string;
/**
* When `text` has multiple entries, use the widest strings width for layout instead of animating width per line.
* @defaultValue `false`
*/
fixedWidth?: boolean;
}
export function DiaTextReveal({ text, colors = DEFAULT_COLORS, textColor = "var(--foreground)", duration = 1.5, delay = 0, repeat = false, repeatDelay = 0.5, startOnView = true, once = true, className, fixedWidth = false, ...props }: DiaTextRevealProps) {
const texts = Array.isArray(text) ? text : [text];
const isMulti = texts.length > 1;
const prefersReducedMotion = useReducedMotion();
const spanRef = useRef<HTMLSpanElement>(null);
const optsRef = useRef({
colors,
textColor,
duration,
delay,
repeat,
repeatDelay,
texts,
});
optsRef.current = {
colors,
textColor,
duration,
delay,
repeat,
repeatDelay,
texts,
};
const indexRef = useRef(0);
const hasPlayedRef = useRef(false);
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const playRef = useRef<() => void>(null!);
const stopRef = useRef<(() => void) | null>(null);
const [activeIndex, setActiveIndex] = useState(0);
const [measuredWidths, setMeasuredWidths] = useState<number[]>([]);
const sweepPos = useMotionValue(SWEEP_START);
const backgroundImage = useTransform(sweepPos, (pos) => buildGradient(pos, optsRef.current.colors, optsRef.current.textColor));
const isInView = useInView(spanRef, { once, amount: 0.1 });
useEffect(() => {
const el = spanRef.current;
if (!el || !isMulti) return;
setMeasuredWidths(measureWidths(el, texts));
}, [Array.isArray(text) ? text.join("\0") : text]);
playRef.current = () => {
const { duration, delay, repeat, repeatDelay, texts } = optsRef.current;
sweepPos.set(SWEEP_START);
const controls = animate(sweepPos, SWEEP_END, {
duration,
delay,
ease: sweepEase,
onComplete() {
if (!repeat) return;
timerRef.current = setTimeout(() => {
const next = (indexRef.current + 1) % texts.length;
indexRef.current = next;
setActiveIndex(next);
playRef.current();
}, repeatDelay * 1000);
},
});
stopRef.current = () => controls.stop();
};
useEffect(() => {
if (prefersReducedMotion) {
sweepPos.set(SWEEP_END);
return;
}
if (startOnView && !isInView) return;
if (once && hasPlayedRef.current) return;
hasPlayedRef.current = true;
playRef.current();
return () => {
stopRef.current?.();
clearTimeout(timerRef.current);
};
}, [isInView, startOnView, once, prefersReducedMotion, sweepPos]);
const fixedW = isMulti && fixedWidth && measuredWidths.length > 0 ? Math.max(...measuredWidths) : undefined;
const animatedW = isMulti && !fixedWidth && measuredWidths[activeIndex] != null ? measuredWidths[activeIndex] : undefined;
return (
<motion.span
ref={spanRef}
className={cn("align-bottom leading-[100%] text-inherit", className)}
style={{
transform: "translateY(-2px)",
color: "transparent",
backgroundClip: "text",
WebkitBackgroundClip: "text",
backgroundSize: "100% 100%",
backgroundImage,
...(isMulti && {
display: "inline-block",
overflow: "hidden",
whiteSpace: "nowrap",
verticalAlign: "text-center",
...(fixedW != null && { width: fixedW }),
}),
}}
animate={animatedW != null ? { width: animatedW } : undefined}
transition={{ duration: 0.4, ease: [0.4, 0, 0.2, 1] }}
{...props}
>
{texts[activeIndex]}
</motion.span>
);
}