Content·10.7
Text Shimmer
A spinner made of words.
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.jsonUsage
"use client";
import { TextShimmer } from "@/components/yugo/text-shimmer";
export function ToolLine({ done, path }: { done: boolean; path: string }) {
return (
<TextShimmer active={!done} className="text-[13px]">
{done ? "Read " + path : "Reading " + path}
</TextShimmer>
);
}Source
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { motion, useInView, useReducedMotion } from "motion/react";
const INSTANT = { duration: 0 } as const;
const SWEEP = 2;
const SPREAD = 2;
const MIN_BAND = 12;
/**
* A shimmer is a spinner made of words: unknown duration, constant speed, one
* pass. That is why it moves `linear` and why it is gated on `active` — a
* sweep that keeps running after the work landed is decoration, and this set
* does not ship decoration.
*/
const RESTING =
"text-stone-700 dark:text-stone-200";
const SHIMMERING =
"bg-clip-text text-transparent [background-repeat:no-repeat,padding-box] [--lit:#1c1917] [--base:#a8a29e] dark:[--lit:#f5f5f4] dark:[--base:#78716c]";
export type UseTextShimmerOptions = {
active?: boolean;
};
export type UseTextShimmerResult<T extends HTMLElement> = {
ref: React.RefObject<T | null>;
running: boolean;
};
export function useTextShimmer<T extends HTMLElement = HTMLSpanElement>({
active = true,
}: UseTextShimmerOptions = {}): UseTextShimmerResult<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 TextShimmerProps = UseTextShimmerOptions & {
children: string;
as?: React.ElementType;
duration?: number;
spread?: number;
className?: string;
};
export function TextShimmer({
children,
as: Component = "span",
active = true,
duration = SWEEP,
spread = SPREAD,
className = "",
}: TextShimmerProps) {
const { ref, running } = useTextShimmer<HTMLElement>({ active });
// motion.create() mints a component type. Calling it in the render body
// makes a new type every render, which remounts the node and restarts the
// sweep on every parent update.
const Motion = useMemo(
() => motion.create(Component as "span"),
[Component],
);
const band = Math.max(MIN_BAND, children.length * spread);
return (
<Motion
ref={ref}
className={`relative inline-block ${running ? SHIMMERING : RESTING} ${className}`}
style={
running
? {
backgroundImage: `linear-gradient(90deg, transparent calc(50% - ${band}px), var(--lit), transparent calc(50% + ${band}px)), linear-gradient(var(--base), var(--base))`,
backgroundSize: "250% 100%, auto",
}
: undefined
}
initial={running ? { backgroundPosition: "100% center" } : false}
animate={
running ? { backgroundPosition: ["100% center", "0% center"] } : {}
}
transition={
running
? { duration, ease: "linear", repeat: Infinity }
: INSTANT
}
>
{children}
</Motion>
);
}Props
childrenstringThe text to sweep. A string, not nodes: the sweep is a gradient clipped to the glyphs and needs one text run.
activetruebooleanWhether work is actually in flight. False settles the text to its resting ink and stops the sweep.
duration2numberSeconds for one pass, at a constant rate.
spread2numberPixels of lit band per character, so a long phrase gets a proportionally wider highlight instead of a dot.
as"span"ElementTypeThe element to render as. Memoised, because minting a motion component in the render body remounts the node and restarts the sweep on every parent update.
className""stringAppended last, so the type size and any override of the two tone variables are the caller's.