# Text Morph (Lumen Halo): prompt.md (v1.0.0)

- id: `lumen-text-morph` · version 1.0.0 · component · free
- category: Text & motion
- build: Base UI (one set of files; its dependencies follow the build)
- install (this build): `npx shadcn@latest add https://beautiful-ui.dev/r/lumen-text-morph.json`
- npm dependencies: none
- registry dependencies: utils, https://beautiful-ui.dev/r/lumen-foundation.json
- docs: https://beautiful-ui.dev/components/lumen-text-morph
- The install command carries everything this item needs (files, CSS, tokens, npm and registry dependencies). Prefer it to copying source by hand.

A label that changes into its next state without a jump: shared letters glide to their new places, the rest blur out and in, and the width eases so the button around it resizes smoothly.

## Build it
- Stack: React 19 (`ref` is a plain prop), TypeScript, Tailwind CSS v4 utilities, a shadcn-initialised project with the `@/*` alias.
- Packages: none beyond React.
- Files: `components/ui/lumen/controls/text-morph.tsx`.
- Registry dependencies, installed with it automatically: shadcn `utils` (cn), `lumen-foundation`.
- Builds: one set of files for both, but its dependencies come in Base UI and Radix builds. Install the one that matches the project (see Install): a free item's bare URL installs the Base UI build of it and its dependencies.
- Exports to keep: `TextMorph`, and every exported type.
- CSS: the install merges this item's rules (the registry `css` field) into your global stylesheet, in `@layer components`, and adds the lumen foundation (tokens, keyframes, motion levels) once. Nothing to import by hand.
- Re-running `add` (or `--overwrite`) re-applies those rules: put overrides in your own CSS, never in the installed rules.
- Tokens: retheme with the `--lumen-*` custom properties (`--lumen-accent`, `--lumen-accent-foreground`, `--lumen-accent-text`, `--lumen-bad`, `--lumen-bad-text`, `--lumen-chart-accent`, `--lumen-chart-accent-foreground`, `--lumen-chart-accent-text`, `--lumen-focus`, `--lumen-good`, `--lumen-good-text`, `--lumen-hairline`, `--lumen-ink`, `--lumen-muted-ink`, `--lumen-series-1`, `--lumen-series-2`, `--lumen-series-3`, `--lumen-series-4`, `--lumen-series-5`, `--lumen-series-6`, `--lumen-warn`, `--lumen-warn-text`). Never add Tailwind colour classes inside the component.

```tsx
import { Button } from "@/components/ui/lumen/button";
```

## Parts

| Part | data-slot | What it is for |
|---|---|---|
| `TextMorph` | — | The label. |

## Sound
- Keep every `data-slot` and `data-sound` attribute: the sound layer reads them.
- Installing this item adds no audio. Nothing plays until the app mounts `GlassSoundProvider` once (install: `npx shadcn@latest add https://beautiful-ui.dev/r/glass-sound.json`, import from `@/components/beautiful-ui/glass-sound`); `GlassSoundToggle` is its mute control. Without a provider the audio engine never loads.

## Match the original
- Read `components/ui/lumen/controls/text-morph.tsx` as the reference implementation before changing or recreating anything, and match it: sizes, colours per theme, motion timings, copy and behaviour.
- If you deviate (a prop you can't honour, a style you changed, a dependency you swapped), say so in your reply, part by part.
- Keep the accessibility contract, the keyboard map and the motion levels listed below.

## Use it when
- text morph, morphing label, animated label, button label change, Save Saved, Follow Following, state label
- Button labels that change with state: Save → Saving → Saved, Follow → Following, Copy → Copied
- A short status word that updates in place

### Not when
- Sentences or paragraphs: morphing reads only on a few words
- Numbers that count: a rolling number fits better

## Mistakes
- Pass a string, not elements: letters are matched by character
- Keep labels short (one to three words)

## Usage

```tsx
"use client";

import * as React from "react";
import { Button } from "@/components/ui/lumen/button";
import { TextMorph } from "@/components/ui/lumen/controls/text-morph";

export function FollowButton() {
  const [following, setFollowing] = React.useState(false);
  return (
    <Button variant={following ? "secondary" : "default"} aria-pressed={following} onClick={() => setFollowing((f) => !f)}>
      <TextMorph>{following ? "Following" : "Follow"}</TextMorph>
    </Button>
  );
}
```

## Props

| Prop | Type | Default | What it does |
|---|---|---|---|
| `children` | `string` |  | The label. Change it and it morphs. |
| `motion` | `"full" \| "subtle" \| "off"` | `"full"` | full: glide and blur; subtle: shorter, no blur; off: swap at once. Reduced motion is always off. |

Full docs: https://beautiful-ui.dev/components/lumen-text-morph

## Customising
- Motion: `motion="full"` (default) | `"subtle"` | `"off"`. prefers-reduced-motion is always respected.
- Colours: set the `--lumen-*` tokens on `:root`, or on a container with the `lumen-scope` class to retheme one area. Add the `lumen-inherit` class to follow your shadcn palette instead (`--chart-N`, `--destructive`).
- Dark mode follows the `.dark` class on an ancestor (the shadcn and next-themes convention).
- Update later by re-running the install with `--overwrite` (review the diff if you edited it). Changelog: https://beautiful-ui.dev/r/changelog.json

## Keyboard

| Keys | Action |
|---|---|
| Tab | Not focusable: it is a label inside your control |

## Motion inventory

| Interaction | What moves |
|---|---|
| The label changes | Shared letters glide 360ms, others blur in and out, the width eases; subtle is 220ms without blur |

## Accessibility contract (preserve when editing)
- Screen readers get the plain label once; the animated letters are hidden from them
- Put aria-live="polite" on a wrapper when the change should be announced (a status, not a button)

## Install

Base UI project (a base-* style in components.json):

```bash
npx shadcn@latest add https://beautiful-ui.dev/r/lumen-text-morph.json
```

Radix project (a radix-*, new-york or default style):

```bash
npx shadcn@latest add https://beautiful-ui.dev/r/radix-nova/lumen-text-morph.json
```

Or add the `@beautiful-ui` registry to components.json and run `npx shadcn@latest add @beautiful-ui/lumen-text-morph`: the CLI picks the build from your style.

## Credits
- Built on shadcn/ui (https://ui.shadcn.com)

## Source (Base UI build)

### components/ui/lumen/controls/text-morph.tsx

```tsx
"use client";
// Generated from src/registry/core/controls/text-morph.tsx by scripts/gen-systems.ts. Edit the core file, not this one.

import * as React from "react";
import { cn } from "@/lib/utils";

/*
 * Text Morph: a label that changes into its next state (Save → Saving → Saved) without a jump. Letters
 * the two labels share glide to their new places, the others blur out and in, and the width eases so a
 * surrounding button resizes smoothly. No animation library: the browser's own animations, measured
 * before paint. Screen readers get the plain label; give the wrapper aria-live="polite" when the
 * change should be announced.
 *
 *   <Button><TextMorph>{saved ? "Saved" : "Save changes"}</TextMorph></Button>
 */

const useIsoLayoutEffect = typeof window === "undefined" ? React.useEffect : React.useLayoutEffect;

/** Each letter's identity: the nth "e" in one label pairs with the nth "e" in the next. */
export function morphKeys(text: string): { char: string; key: string }[] {
  const seen = new Map<string, number>();
  return morphUnits(text).map((char) => {
    const n = seen.get(char) ?? 0;
    seen.set(char, n + 1);
    return { char, key: `${char}-${n}` };
  });
}

/** Scripts whose letters join (Arabic, Indic, Mongolian…): split apart they'd stop joining. */
const JOINED = /[\u0590-\u08FF\u0900-\u0DFF\u0F00-\u0FFF\u1000-\u109F\u1800-\u18AF\uFB1D-\uFDFF\uFE70-\uFEFF]/;

/**
 * The pieces that move: whole graphemes (emoji, accents stay together), or whole words (with their
 * spaces) in scripts whose letters join, where splitting would break the shapes.
 */
export function morphUnits(text: string): string[] {
  if (JOINED.test(text)) return text.split(/(\s+)/).filter(Boolean);
  const Seg = (Intl as { Segmenter?: new (l?: string, o?: { granularity: "grapheme" }) => { segment(t: string): Iterable<{ segment: string }> } }).Segmenter;
  return Seg ? Array.from(new Seg(undefined, { granularity: "grapheme" }).segment(text), (x) => x.segment) : Array.from(text);
}

const EASE = "cubic-bezier(.22,1,.36,1)";

function TextMorph({
  children,
  motion = "full",
  className,
  ...props
}: Omit<React.ComponentProps<"span">, "children"> & {
  /** The label. Changing it morphs to the new one. */
  children: string;
  /** full: glide and blur; subtle: a shorter glide and fade; off: swap at once. Reduced motion is off. */
  motion?: "full" | "subtle" | "off";
}) {
  const box = React.useRef<HTMLSpanElement>(null);
  const ghosts = React.useRef<HTMLSpanElement>(null);
  const last = React.useRef<{ width: number; at: Map<string, { left: number; char: string }> } | null>(null);
  const letters = morphKeys(children);

  useIsoLayoutEffect(() => {
    const el = box.current;
    if (!el) return;
    // A width still easing from the last change would be measured as the new width: settle it first.
    for (const a of el.getAnimations?.() ?? []) a.cancel();
    const now = new Map<string, { left: number; char: string; node: HTMLElement }>();
    for (const node of el.querySelectorAll<HTMLElement>("[data-k]")) now.set(node.dataset.k!, { left: node.offsetLeft, char: node.textContent ?? "", node });
    const width = el.offsetWidth;
    const before = last.current;
    last.current = { width, at: new Map([...now].map(([k, v]) => [k, { left: v.left, char: v.char }])) };
    const reduced = typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    if (!before || motion === "off" || reduced || typeof el.animate !== "function") return;

    const t = motion === "full" ? 360 : 220;
    const blur = motion === "full" ? "blur(3px)" : "blur(0px)";
    const lift = motion === "full" ? 0.18 : 0;
    for (const [k, cur] of now) {
      const was = before.at.get(k);
      if (was) {
        const dx = was.left - cur.left;
        if (dx) cur.node.animate([{ transform: `translateX(${dx}px)` }, { transform: "none" }], { duration: t, easing: EASE });
      } else {
        cur.node.animate([{ opacity: 0, filter: blur, transform: `translateY(${lift}em)` }, { opacity: 1, filter: "blur(0px)", transform: "none" }], { duration: t * 0.75, delay: t * 0.15, easing: EASE, fill: "backwards" });
      }
    }
    // Letters that left fade out where they stood, drawn as ghosts React doesn't own.
    const layer = ghosts.current;
    if (layer)
      for (const [k, was] of before.at) {
        if (now.has(k)) continue;
        const g = document.createElement("span");
        g.textContent = was.char;
        g.className = `lumen-text-morph-ghost`;
        // offsetLeft is physical, so place it with physical left (right-to-left text included).
        g.style.left = `${was.left}px`;
        layer.append(g);
        const a = g.animate([{ opacity: 1, filter: "blur(0px)", transform: "none" }, { opacity: 0, filter: blur, transform: `translateY(-${lift}em)` }], { duration: t * 0.6, easing: EASE, fill: "forwards" });
        a.onfinish = () => g.remove();
        a.oncancel = () => g.remove();
      }
    if (before.width !== width) el.animate([{ width: `${before.width}px` }, { width: `${width}px` }], { duration: t, easing: EASE });
  }, [children, motion]);

  return (
    <span data-slot="text-morph" data-motion={motion} className={cn(`lumen-text-morph`, className)} {...props}>
      <span className={`lumen-sr`}>{children}</span>
      <span ref={box} aria-hidden className={`lumen-text-morph-letters`}>
        {letters.map(({ char, key }) => (
          <span key={key} data-k={key} className={`lumen-text-morph-letter`}>
            {char}
          </span>
        ))}
        <span ref={ghosts} className={`lumen-text-morph-ghosts`} />
      </span>
    </span>
  );
}

export { TextMorph };
```

### CSS (the registry `css` / `cssVars`, merged into the global stylesheet by the shadcn CLI)

```css
@layer components {
  .lumen-text-morph {
    display: inline-flex;
    white-space: pre;
  }
  .lumen-text-morph-letters {
    position: relative;
    display: inline-flex;
    white-space: pre;
  }
  .lumen-text-morph-letter {
    display: inline-block;
    white-space: pre;
  }
  .lumen-text-morph-ghosts {
    position: absolute;
    inset: 0;
    pointer-events: none;
  }
  .lumen-text-morph-ghost {
    position: absolute;
    inset-block-start: 0;
    white-space: pre;
  }
}
```

The lumen foundation (the tokens listed above, keyframes and motion levels) installs once with the first component; its CSS is public at https://beautiful-ui.dev/r/lumen-foundation.json.
