# 텍스트 모핑

짧은 문구가 바뀔 때 겹치는 글자는 그대로 두고 다른 글자만 바꿔 줘요.

- Type: component
- Theme: base
- Tags: arc, component, text, animation, label, transition
- English title: Text morph
- URL: https://dsui.vercel.app/items/text-morph

## Used by

- [색상 선택기](https://dsui.vercel.app/items/color-picker): 색을 직접 고르거나 값을 입력하고 견본을 저장해 둘 수 있어요.

## Controls

| Key | Type | Label | Default |
| --- | --- | --- | --- |
| as | select | 태그 | h2 |

## Source

### text-morph.module.css

```css
/* The frame carries the animated width. Its mask reveals new letters through a soft trailing edge as it grows and fades leaving ones as it shrinks.
   Padding with matching negative margins gives the mask room for blur and descenders without changing layout; the edge sits entirely in that room, so resting text is never dimmed.
   The track always starts at the frame's leading edge, even in centered text: otherwise it would recenter inside the old width and leaving letters would jump. */
.frame.frame, .frame { position: relative; display: inline-block; box-sizing: content-box; padding: .5em .5em .5em .2em; margin: -.5em -.5em -.5em -.2em; text-align: start; white-space: nowrap; mask-image: linear-gradient(90deg, #000 calc(100% - .5em), transparent); }
/* The track sizes to the letters in flow. Leaving letters are taken out of flow and positioned against it. */
.track { position: relative; display: inline-block; white-space: nowrap; }
.glyph { display: inline-block; white-space: pre; transform-origin: 50% 60%; }
.srOnly { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; user-select: none; }

```

### text-morph.tsx

```tsx
"use client";
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { AnimatePresence, animate, motion, type AnimationPlaybackControls } from "motion/react";
import { useReducedMotion } from "../lib/use-reduced-motion";
import { motionTokens } from "../lib/motion-tokens";
import styles from "./text-morph.module.css";

/**
 * Morphs one short label into the next in place. Letters both strings share glide to their new positions, new letters sharpen in, removed letters blur away,
 * and the width follows on a spring so the surrounding layout glides instead of jumping. Use it for status words that change a few letters at a time,
 * such as "게시하기", "게시하는 중", and "게시했어요". It stays on one line, and assistive technology reads the plain text.
 */
export interface TextMorphProps { children: string; as?: "span" | "div" | "p" | "strong" | "h1" | "h2" | "h3"; className?: string; id?: string }

const enter = [...motionTokens.ease.enter] as [number, number, number, number];
const standard = [...motionTokens.ease.standard] as [number, number, number, number];
const blurred = `blur(${motionTokens.blur.soft}px)`;
/** Lets leaving letters start to clear before the first new letter sharpens in their place. */
const HANDOFF = .05;
/** Letters match by character and occurrence, so the second "i" in one string pairs with the second "i" in the next. */
function toGlyphs(text: string) {
  const seen = new Map<string, number>();
  return Array.from(text).map(char => { const count = seen.get(char) ?? 0; seen.set(char, count + 1); return { char, key: `${char}-${count}` }; });
}
const measure = (element: HTMLElement) => parseFloat(getComputedStyle(element).width);

export function TextMorph({ children, as = "span", className, id }: TextMorphProps) {
  const Tag = as;
  const reduced = useReducedMotion();
  const frame = useRef<HTMLSpanElement>(null);
  const track = useRef<HTMLSpanElement>(null);
  const width = useRef(0);
  const sizing = useRef<AnimationPlaybackControls | null>(null);
  const glyphs = useMemo(() => toGlyphs(children), [children]);
  // The previous label tells which letters are new, so only they stagger, in reading order, however far along the word they sit.
  const [labels, setLabels] = useState({ current: children, previous: children });
  if (labels.current !== children) setLabels({ current: children, previous: labels.current });
  const kept = useMemo(() => new Set(toGlyphs(labels.previous).map(glyph => glyph.key)), [labels.previous]);
  let entering = 0;

  // The frame holds an explicit width so a new label never snaps it; the spring then follows the track, which already has the new letters in place.
  useLayoutEffect(() => {
    const frameElement = frame.current, trackElement = track.current;
    if (!frameElement || !trackElement) return;
    const next = measure(trackElement);
    if (!Number.isFinite(next)) return;
    if (width.current && Math.abs(next - width.current) > .5 && !reduced) sizing.current = animate(frameElement, { width: next }, motionTokens.spring.morph);
    else { sizing.current?.stop(); frameElement.style.width = `${next}px`; }
    width.current = next;
  }, [children, reduced]);
  // Font loading or a responsive font size changes the width without a new label: follow it immediately.
  useEffect(() => {
    const frameElement = frame.current, trackElement = track.current;
    if (!frameElement || !trackElement || typeof ResizeObserver === "undefined") return;
    const observer = new ResizeObserver(() => {
      const next = measure(trackElement);
      if (!Number.isFinite(next) || Math.abs(next - width.current) < .5) return;
      sizing.current?.stop(); frameElement.style.width = `${next}px`; width.current = next;
    });
    observer.observe(trackElement);
    return () => observer.disconnect();
  }, []);

  return <Tag id={id} className={className}>
    <span className={styles.srOnly}>{children}</span>
    <span ref={frame} className={styles.frame} aria-hidden="true"><span ref={track} className={styles.track}>
      <AnimatePresence mode="popLayout" initial={false}>
        {glyphs.map(({ char, key }) => { const order = kept.has(key) ? 0 : entering++; return <motion.span key={key} layout="position" className={styles.glyph}
          initial={reduced ? false : { opacity: 0, scale: .8, y: "0.14em", filter: blurred }}
          animate={{ opacity: 1, scale: 1, y: "0em", filter: "blur(0px)" }}
          exit={reduced ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, scale: .86, y: "-0.1em", filter: blurred, transition: { duration: motionTokens.duration.exit, ease: standard } }}
          transition={reduced ? { duration: 0 } : { layout: motionTokens.spring.morph, default: { duration: motionTokens.duration.standard + .04, ease: enter, delay: HANDOFF + Math.min(order, 8) * motionTokens.stagger.char * 2 } }}>
          {char === " " ? "\u00a0" : char}
        </motion.span>; })}
      </AnimatePresence>
    </span></span>
  </Tag>;
}

export default TextMorph;

```
