yugodesign

Content·10.8

Text Shimmer Wave

The same wait, read letter by letter.

Generating the migration

text-shimmer-wave

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-wave.json

Usage

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

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

children
string

The phrase to run the wave along. Short: every character carries its own animation.

activetrue
boolean

Whether the work is in flight. False lands every character at the lit tone and stops.

duration1
number

Seconds for one character's rise and fall.

spread1
number

How tightly the delays are packed. Higher is a shorter, faster wave.

yDistance-2
number

Pixels a character lifts at the crest.

scaleDistance1.06
number

Scale at the crest. Kept near 1 because transform-scaled type loses its hinting.

xDistance0
number

Horizontal travel at the crest. Off by default: a phrase whose letters slide sideways is a phrase that is hard to read.

zDistance0
number

Depth at the crest. Setting it, or rotateYDistance, is what turns the perspective and preserve-3d on.

rotateYDistance0
number

Degrees of rotation at the crest. Off by default because a rotated glyph is a blurred glyph.

as"span"
ElementType

The element to render as, memoised for the same reason as text-shimmer.

className""
string

Appended last to the outer box.