Content·10.8
Text Shimmer Wave
The same wait, read letter by letter.
Install
One dependency. The component is copied into your project, so the file is yours after that.
bun add motionOr let the shadcn CLI do the copying: same file, landing in components/yugo.
bunx shadcn@latest add https://yugo.click/r/text-shimmer-wave.jsonUsage
"use client";
import { TextShimmerWave } from "@/components/yugo/text-shimmer-wave";
export function Working({ busy }: { busy: boolean }) {
return (
<TextShimmerWave
active={busy}
duration={1.1}
spread={1.4}
className="text-[15px]"
>
Generating the migration
</TextShimmerWave>
);
}Source
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { motion, useInView, useReducedMotion } from "motion/react";
const INSTANT = { duration: 0 } as const;
const BASE = "var(--base)";
const LIT = "var(--lit)";
const TONES =
"[--lit:#1c1917] [--base:#a8a29e] dark:[--lit:#f5f5f4] dark:[--base:#78716c]";
/** The gap between one pass and the next, so the wave reads as a pass rather
* than as a permanent ripple. */
const REST_PER_CHARACTER = 0.05;
export type UseTextShimmerWaveOptions = {
active?: boolean;
};
export type UseTextShimmerWaveResult<T extends HTMLElement> = {
ref: React.RefObject<T | null>;
running: boolean;
};
export function useTextShimmerWave<T extends HTMLElement = HTMLSpanElement>({
active = true,
}: UseTextShimmerWaveOptions = {}): UseTextShimmerWaveResult<T> {
const ref = useRef<T>(null);
const inView = useInView(ref);
const reduced = useReducedMotion();
const [awake, setAwake] = useState(true);
useEffect(() => {
const read = () => setAwake(!document.hidden);
read();
document.addEventListener("visibilitychange", read);
return () => document.removeEventListener("visibilitychange", read);
}, []);
return { ref, running: Boolean(active) && awake && inView && !reduced };
}
export type TextShimmerWaveProps = UseTextShimmerWaveOptions & {
children: string;
as?: React.ElementType;
duration?: number;
spread?: number;
xDistance?: number;
yDistance?: number;
zDistance?: number;
scaleDistance?: number;
rotateYDistance?: number;
className?: string;
};
export function TextShimmerWave({
children,
as: Component = "span",
active = true,
duration = 1,
spread = 1,
xDistance = 0,
yDistance = -2,
zDistance = 0,
scaleDistance = 1.06,
rotateYDistance = 0,
className = "",
}: TextShimmerWaveProps) {
const { ref, running } = useTextShimmerWave<HTMLElement>({ active });
const Motion = useMemo(
() => motion.create(Component as "span"),
[Component],
);
// Array.from, not split(""): split cuts surrogate pairs in half and an
// emoji becomes two broken glyphs mid-wave.
const characters = useMemo(() => Array.from(children), [children]);
// Depth is only paid for when it is asked for. A perspective and a
// preserve-3d on a text run that never leaves the plane is a compositor
// layer for nothing.
const dimensional = zDistance !== 0 || rotateYDistance !== 0;
return (
<Motion
ref={ref}
className={`relative inline-block ${TONES} ${dimensional ? "[perspective:500px]" : ""} ${className}`}
style={{ color: running ? BASE : undefined }}
>
<span className="sr-only">{children}</span>
<span aria-hidden="true">
{characters.map((character, i) => {
const delay = (i * duration * (1 / spread)) / characters.length;
return (
<motion.span
key={`${i}-${character}`}
className={`inline-block whitespace-pre ${dimensional ? "[transform-style:preserve-3d]" : ""}`}
initial={false}
animate={
running
? {
x: xDistance === 0 ? 0 : [0, xDistance, 0],
y: yDistance === 0 ? 0 : [0, yDistance, 0],
z: zDistance === 0 ? 0 : [0, zDistance, 0],
scale:
scaleDistance === 1 ? 1 : [1, scaleDistance, 1],
rotateY:
rotateYDistance === 0
? 0
: [0, rotateYDistance, 0],
color: [BASE, LIT, BASE],
}
: { x: 0, y: 0, z: 0, scale: 1, rotateY: 0, color: LIT }
}
transition={
running
? {
duration,
delay,
repeat: Infinity,
repeatDelay:
(characters.length * REST_PER_CHARACTER) / spread,
ease: "easeInOut",
}
: INSTANT
}
>
{character}
</motion.span>
);
})}
</span>
</Motion>
);
}Props
childrenstringThe phrase to run the wave along. Short: every character carries its own animation.
activetruebooleanWhether the work is in flight. False lands every character at the lit tone and stops.
duration1numberSeconds for one character's rise and fall.
spread1numberHow tightly the delays are packed. Higher is a shorter, faster wave.
yDistance-2numberPixels a character lifts at the crest.
scaleDistance1.06numberScale at the crest. Kept near 1 because transform-scaled type loses its hinting.
xDistance0numberHorizontal travel at the crest. Off by default: a phrase whose letters slide sideways is a phrase that is hard to read.
zDistance0numberDepth at the crest. Setting it, or rotateYDistance, is what turns the perspective and preserve-3d on.
rotateYDistance0numberDegrees of rotation at the crest. Off by default because a rotated glyph is a blurred glyph.
as"span"ElementTypeThe element to render as, memoised for the same reason as text-shimmer.
className""stringAppended last to the outer box.