yugodesign

Content·10.6

Text Morph

Letters travel to their new word.

seats remaining

text-morph

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

Usage

stats.tsx
"use client";

import { useState } from "react";
import { TextMorph } from "@/components/yugo/text-morph";

const TIERS = ["seventeen", "forty-one", "one hundred"];

export function SeatCount() {
  const [seats, setSeats] = useState(TIERS[0]);

  return (
    <div>
      <TextMorph
        text={seats}
        reserve={TIERS}
        announce
        className="text-[27px] font-medium tracking-[-0.03em]"
      />
      {TIERS.map((tier) => (
        <button key={tier} type="button" onClick={() => setSeats(tier)}>
          {tier}
        </button>
      ))}
    </div>
  );
}

Source

components/yugo/text-morph.tsx
"use client";

import { useEffect, useId, useMemo, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

const EASE = [0.23, 1, 0.32, 1] as const;
const LEAVE = [0.4, 0, 1, 1] as const;

const SMALL = {
  type: "spring",
  stiffness: 700,
  damping: 46,
  mass: 0.5,
} as const;

const INSTANT = { duration: 0 } as const;

const SETTLE = 400;
const SPACE = " ";

export type TextMorphCharacter = {
  /** stable across renders: the nth occurrence of this letter, case-folded */
  id: string;
  label: string;
};

/**
 * Splits a string into characters that keep their identity between two
 * strings. The key is the letter plus how many times it has already appeared,
 * case-folded, so the "e" in "seven" and the "E" in "Eleven" are the same
 * object and travel rather than crossfade.
 */
export function useTextMorph(text: string): TextMorphCharacter[] {
  const base = useId();

  return useMemo(() => {
    const seen: Record<string, number> = {};

    return Array.from(text).map((char) => {
      const key = char.toLowerCase();
      seen[key] = (seen[key] ?? 0) + 1;
      return {
        id: `${base}-${key}-${seen[key]}`,
        label: char === " " ? SPACE : char,
      };
    });
  }, [text, base]);
}

export type TextMorphProps = {
  text: string;
  as?: React.ElementType;
  reserve?: readonly string[];
  announce?: boolean;
  className?: string;
};

export function TextMorph({
  text,
  as: Component = "span",
  reserve,
  announce = false,
  className = "",
}: TextMorphProps) {
  const reduced = useReducedMotion();
  const characters = useTextMorph(text);
  const [said, setSaid] = useState("");

  // Announce late: a value that morphs through four strings while a number
  // ticks should be one sentence, not four. §18
  useEffect(() => {
    if (!announce) return;
    const timer = setTimeout(() => setSaid(text), SETTLE);
    return () => clearTimeout(timer);
  }, [announce, text]);

  return (
    <Component className={`inline-grid ${className}`}>
      {/* Every string this box can ever hold, drawn invisibly in the same
          cell, so the column is already at its widest and the letters morph
          inside a box that never moves. */}
      {reserve?.map((phrase) => (
        <span
          key={phrase}
          aria-hidden="true"
          className="invisible col-start-1 row-start-1 whitespace-pre"
        >
          {phrase}
        </span>
      ))}

      <span
        aria-hidden="true"
        className="col-start-1 row-start-1 whitespace-pre"
      >
        {/* Surviving letters keep their key, so they stay mounted and travel
            to their new slot with layout="position". No layoutId: a shared id
            here means an exiting letter and an arriving one can claim the
            same identity mid-flight and pile up. §5i */}
        <AnimatePresence mode="popLayout" initial={false}>
          {characters.map((character) => (
            <motion.span
              key={character.id}
              layout={reduced ? false : "position"}
              className="inline-block"
              initial={{ opacity: 0 }}
              animate={{ opacity: 1 }}
              exit={{
                opacity: 0,
                transition: reduced
                  ? INSTANT
                  : { duration: 0.08, ease: LEAVE },
              }}
              transition={
                reduced
                  ? INSTANT
                  : {
                      ...SMALL,
                      // Entrants wait for the leavers to clear: 60ms is under
                      // perception as a delay and over it as a smear.
                      opacity: { duration: 0.16, ease: EASE, delay: 0.06 },
                    }
              }
            >
              {character.label}
            </motion.span>
          ))}
        </AnimatePresence>
      </span>

      <span className="sr-only" aria-live={announce ? "polite" : undefined}>
        {announce ? said : text}
      </span>
    </Component>
  );
}

Props

text
string

The string to display. Changing it morphs from the old one rather than replacing it.

as"span"
ElementType

The element the box is rendered as, for slotting a morphing value into a heading or a paragraph.

reserve
readonly string[]

Every string this box will ever hold. Each is drawn invisibly in the same cell, so the column is already at its widest and nothing around it moves.

announcefalse
boolean

Whether to speak the settled value through a polite live region, 400ms after the last change.

className""
string

Appended last to the outer box.