Sora UI

Text Morph

Per-character layout morphing for labels, counters, and live text swaps, powered by Motion. React component.

Made by Axyl

Per-character layout morph between two labels — click the preview button to trigger a swap.

Loading...

Examples

Text Morph Input

TextMorph driven by a live input field — type to see characters morph in real time.

Loading...

Installation

File Structure

text-morph.tsx

Usage

import { TextMorph } from '@/components/sora-ui/texts/text-morph';

export default function StatusLabel({ value }: { value: string }) {
  return (
    <TextMorph
      as="span"
      className="font-mono text-sm uppercase tracking-widest"
      live="polite"
    >
      {value}
    </TextMorph>
  );
}

Cycle copy on click:

'use client';

import { useState } from 'react';
import { TextMorph } from '@/components/sora-ui/texts/text-morph';

export function TextMorphButton() {
  const [text, setText] = useState('Continue');

  return (
    <button
      className="flex h-10 w-[120px] shrink-0 items-center justify-center rounded-full bg-foreground px-4 text-base font-medium text-background shadow-xs transition-colors hover:bg-foreground/85"
      onClick={() =>
        setText((current) => (current === 'Continue' ? 'Confirm' : 'Continue'))
      }
      type="button"
    >
      <TextMorph as="span">{text}</TextMorph>
    </button>
  );
}

Live morph while typing:

'use client';

import { useState } from 'react';
import { TextMorph } from '@/components/sora-ui/texts/text-morph';

export function TextMorphInput() {
  const [text, setText] = useState('Craft');

  return (
    <div className="flex w-full max-w-sm flex-col items-center space-y-12">
      <TextMorph className="text-5xl font-medium" live="polite">
        {text}
      </TextMorph>
      <input
        className="h-9 w-full rounded-lg border border-input bg-transparent p-2 text-base shadow-xs outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
        onChange={(event) => setText(event.target.value)}
        placeholder="Type your text here"
        type="text"
        value={text}
      />
    </div>
  );
}

Props

PropTypeDefault
children?
string
-
as?
ElementType
"p"
variant?
"blur" | "slide"
-
staggerDelay?
number
0
skipUnchanged?
boolean
false
charClassName?
string
-
live?
"off" | "polite" | "assertive"
"off"
variants?
Variants
-
transition?
Transition
-
className?
string
-

When to Use

Use TextMorph for label swaps that need to feel fluid rather than abrupt: copy buttons toggling "Copy" → "Copied", tab selectors, status badges, or counters where a hard cut feels jarring. It is most effective on short text (1–15 characters) where individual character transitions are clearly visible.

For longer text or entire paragraphs that change, a fade transition is simpler and more legible.

Accessibility

Set live="polite" when the content change is the result of a user action that screen reader users should be notified about (e.g., "Copied"). Use live="assertive" only for urgent status updates. The morphing animation is visual only; the final string is announced by the live region after the transition completes. When prefers-reduced-motion: reduce is set, the component skips the character-by-character morph via useReducedMotion() and renders the text directly — no extra configuration needed.

Credits

Inspired by motion-primitives.

Built by Axyl. A motion-first component registry for React.

Last updated: 7/26/2026