yugodesign

Content·10.7

Text Shimmer

A spinner made of words.

Reading 24 files in components/yugo
text-shimmer

Install

One dependency. The component is copied into your project, so the file is yours after that.

terminal
bun add motion

Or let the shadcn CLI do the copying: same file, landing in components/yugo.

terminal
bunx shadcn@latest add https://yugo.click/r/text-shimmer.json

Usage

stats.tsx
"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

components/yugo/text-shimmer.tsx
"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

children
string

The text to sweep. A string, not nodes: the sweep is a gradient clipped to the glyphs and needs one text run.

activetrue
boolean

Whether work is actually in flight. False settles the text to its resting ink and stops the sweep.

duration2
number

Seconds for one pass, at a constant rate.

spread2
number

Pixels of lit band per character, so a long phrase gets a proportionally wider highlight instead of a dot.

as"span"
ElementType

The 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""
string

Appended last, so the type size and any override of the two tone variables are the caller's.