{"slug":"text-animation","name":"Text Animation","description":"Animated text primitives for spring reveals, chromatic sweeps, shimmer loading states, letter-cascade swaps and character scrambles.","category":"motion","source_url":"https://beui.dev/r/text-animation/raw","detail_url":"https://beui.dev/r/text-animation","raw_url":"https://beui.dev/r/text-animation/raw","page_url":"https://beui.dev/components/motion/text-animation","markdown_url":"https://beui.dev/components/motion/text-animation.md","published_at":"2026-05-17","updated_at":"2026-08-19","dependencies":["clsx","motion","react","tailwind-merge"],"internal":["./action-swap","@/components/motion/chromatic-text-reveal","@/components/motion/text-reveal","@/components/motion/text-shimmer","@/lib/ease","@/lib/text-shimmer","@/lib/utils"],"files":[{"path":"components/motion/text-reveal.tsx","type":"component","content":"\"use client\";\n// beui.dev/components/motion/text-animation\n\nimport { motion, type Transition, useInView, useReducedMotion } from \"motion/react\";\nimport { useRef, type ElementType, type ReactNode } from \"react\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\ntype SplitMode = \"word\" | \"char\";\n\nexport interface TextRevealProps {\n  text: string | string[];\n  as?: ElementType;\n  className?: string;\n  split?: SplitMode;\n  stagger?: number;\n  delay?: number;\n  blur?: number;\n  yOffset?: string | number;\n  spring?: { stiffness?: number; damping?: number; mass?: number };\n  once?: boolean;\n  whileInView?: boolean;\n  children?: ReactNode;\n}\n\nconst DEFAULT_SPRING = { stiffness: 140, damping: 26, mass: 1.2 };\n\ntype WordGroup = { text: string; trailing: string };\n\n/**\n * One tokenizer for both modes: a line becomes the words it is made of, each\n * carrying the whitespace that follows it. Word mode animates a group at a\n * time, char mode the characters inside one — so the two can't drift apart on\n * what counts as a word or where a space belongs. Runs of whitespace and tabs\n * survive as their own group rather than collapsing.\n */\nfunction toWordGroups(line: string): WordGroup[] {\n  const chunks = line.match(/\\S+\\s*|\\s+/g) ?? [];\n  return chunks.map((chunk) => {\n    const text = chunk.replace(/\\s+$/, \"\");\n    return { text, trailing: chunk.slice(text.length) };\n  });\n}\n\nexport function TextReveal({\n  text,\n  as: Comp = \"span\",\n  className,\n  split = \"word\",\n  stagger = 0.09,\n  delay = 0,\n  blur = 12,\n  yOffset = \"40%\",\n  spring,\n  once = true,\n  whileInView = false,\n  children,\n}: TextRevealProps) {\n  const ref = useRef<HTMLElement>(null);\n  const inView = useInView(ref, { once, amount: 0.4 });\n  const reduce = useReducedMotion();\n  const shouldAnimate = whileInView ? inView : true;\n\n  const lines = Array.isArray(text) ? text : [text];\n  const s = { ...DEFAULT_SPRING, ...spring };\n\n  let unitIndex = 0;\n  const lineCounts = new Map<string, number>();\n\n  return (\n    <Comp ref={ref} className={cn(\"block\", className)}>\n      {lines.map((line) => {\n        const lineCount = lineCounts.get(line) ?? 0;\n        lineCounts.set(line, lineCount + 1);\n        const lineKey = `${line}-${lineCount}`;\n        const unitCounts = new Map<string, number>();\n\n        const renderUnit = (unit: string) => {\n          const d = delay + unitIndex * stagger;\n          unitIndex += 1;\n          const unitCount = unitCounts.get(unit) ?? 0;\n          unitCounts.set(unit, unitCount + 1);\n          const unitKey = `${unit}-${unitCount}`;\n          const initial = reduce\n            ? { opacity: 0 }\n            : { y: yOffset, opacity: 0, filter: `blur(${blur}px)` };\n          const animate = shouldAnimate\n            ? reduce\n              ? { opacity: 1 }\n              : { y: 0, opacity: 1, filter: \"blur(0px)\" }\n            : initial;\n          const transition: Transition = reduce\n            ? { opacity: { duration: 0.25, ease: EASE_OUT, delay: d * 0.3 } }\n            : {\n                y: { type: \"spring\" as const, ...s, delay: d },\n                opacity: { duration: 0.7, ease: EASE_OUT, delay: d },\n                filter: { duration: 0.9, ease: EASE_OUT, delay: d },\n              };\n          return (\n            <motion.span\n              key={unitKey}\n              initial={initial}\n              animate={animate}\n              transition={transition}\n              // `whitespace-pre` is load-bearing: a unit's trailing space is\n              // inside an inline-block and would otherwise collapse to zero\n              // width, running every word together.\n              className=\"inline-block whitespace-pre will-change-transform\"\n            >\n              {unit}\n            </motion.span>\n          );\n        };\n\n        const groups = toWordGroups(line);\n        const groupCounts = new Map<string, number>();\n\n        return (\n          <span key={lineKey} className=\"block\">\n            {groups.map((group) => {\n              const whole = group.text + group.trailing;\n              // Characters animate one at a time, but each word (plus the\n              // space that follows it) sits in its own inline-block so a long\n              // line wraps between words instead of mid-word.\n              if (split !== \"char\") return renderUnit(whole);\n\n              const groupCount = groupCounts.get(whole) ?? 0;\n              groupCounts.set(whole, groupCount + 1);\n              return (\n                <span\n                  key={`${whole}-${groupCount}`}\n                  className=\"inline-block whitespace-pre\"\n                >\n                  {Array.from(whole).map((char) => renderUnit(char))}\n                </span>\n              );\n            })}\n          </span>\n        );\n      })}\n      {children}\n    </Comp>\n  );\n}\n"},{"path":"components/motion/chromatic-text-reveal.tsx","type":"component","content":"\"use client\";\n// beui.dev/components/motion/text-animation\n\nimport {\n  type MotionStyle,\n  motion,\n  type UseInViewOptions,\n  useInView,\n  useReducedMotion,\n} from \"motion/react\";\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport { EASE_IN_OUT, EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nconst CHROMATIC_PALETTE = [\n  \"#60a5fa\",\n  \"#818cf8\",\n  \"#c084fc\",\n  \"#fb7185\",\n  \"#fbbf24\",\n];\n\nconst TRAIL_HALF_WIDTH = 14;\nconst REVEAL_START = `-${TRAIL_HALF_WIDTH}%`;\nconst REVEAL_FINISH = `${100 + TRAIL_HALF_WIDTH}%`;\n\nexport type ChromaticTextRevealProps = {\n  /** Sentence fragment that remains fixed while the final word changes. */\n  prefix: string;\n  /** Words revealed one after another after the fixed prefix. */\n  words: string[];\n  /** Colors used along the moving chromatic edge. */\n  colors?: string[];\n  /** Final text color after the sweep passes. */\n  foregroundColor?: string;\n  /** Sweep duration in seconds. */\n  duration?: number;\n  /** Delay before the first sweep, in seconds. */\n  delay?: number;\n  /** Rest after a word finishes revealing, in seconds. */\n  pauseDuration?: number;\n  /** Returns to the first word after the final word. */\n  loop?: boolean;\n  /** Starts when the text enters the viewport. */\n  startOnView?: boolean;\n  /** Only starts on the first viewport entry. */\n  once?: boolean;\n  /** IntersectionObserver root margin used by the viewport trigger. */\n  inViewMargin?: UseInViewOptions[\"margin\"];\n  className?: string;\n};\n\nfunction composeChromaticGradient(colors: string[], foregroundColor: string) {\n  const palette = colors.length > 0 ? colors : CHROMATIC_PALETTE;\n  const colorStops = palette.map((color, index) => {\n    const offset =\n      palette.length === 1\n        ? 0\n        : -TRAIL_HALF_WIDTH +\n          (index / (palette.length - 1)) * TRAIL_HALF_WIDTH * 2;\n    const operator = offset < 0 ? \"-\" : \"+\";\n    const distance = Number(Math.abs(offset).toFixed(2));\n    return `${color} calc(var(--chromatic-sweep) ${operator} ${distance}%)`;\n  });\n\n  return `linear-gradient(90deg, ${foregroundColor} 0%, ${foregroundColor} calc(var(--chromatic-sweep) - ${TRAIL_HALF_WIDTH}%), ${colorStops.join(\", \")}, transparent calc(var(--chromatic-sweep) + ${TRAIL_HALF_WIDTH}%), transparent 100%)`;\n}\n\nexport function ChromaticTextReveal({\n  prefix,\n  words,\n  colors = CHROMATIC_PALETTE,\n  foregroundColor = \"var(--foreground)\",\n  duration = 1.2,\n  delay = 0,\n  pauseDuration = 0.8,\n  loop = true,\n  startOnView = true,\n  once = true,\n  inViewMargin,\n  className,\n}: ChromaticTextRevealProps) {\n  const ref = useRef<HTMLSpanElement>(null);\n  const timerRef = useRef<number | null>(null);\n  const [wordIndex, setWordIndex] = useState(0);\n  const reduceMotion = useReducedMotion();\n  const isInView = useInView(ref, {\n    once,\n    margin: inViewMargin,\n    amount: 0.4,\n  });\n  const shouldReveal = !startOnView || isInView || reduceMotion;\n  const backgroundImage = composeChromaticGradient(colors, foregroundColor);\n  const hasWords = words.length > 0;\n  const activeIndex = hasWords ? wordIndex % words.length : 0;\n  const activeWord = words[activeIndex] ?? \"\";\n  const sizingWords = Array.from(new Set(words));\n\n  const clearPendingWord = useCallback(() => {\n    if (timerRef.current !== null) {\n      window.clearTimeout(timerRef.current);\n      timerRef.current = null;\n    }\n  }, []);\n\n  const scheduleNextWord = useCallback(() => {\n    clearPendingWord();\n    const isLastWord = activeIndex === words.length - 1;\n    if (\n      reduceMotion ||\n      !shouldReveal ||\n      words.length < 2 ||\n      (isLastWord && !loop)\n    ) {\n      return;\n    }\n\n    timerRef.current = window.setTimeout(() => {\n      setWordIndex((index) => (index + 1) % words.length);\n    }, pauseDuration * 1000);\n  }, [\n    activeIndex,\n    clearPendingWord,\n    loop,\n    pauseDuration,\n    reduceMotion,\n    shouldReveal,\n    words.length,\n  ]);\n\n  useEffect(() => clearPendingWord, [clearPendingWord]);\n\n  return (\n    <span ref={ref} className={cn(\"inline-flex items-baseline\", className)}>\n      <span className=\"whitespace-nowrap\">\n        {prefix}\n        {hasWords ? \"\\u00A0\" : null}\n      </span>\n      {hasWords ? (\n        <span className=\"relative inline-grid\">\n          {sizingWords.map((word) => (\n            <span\n              key={word}\n              aria-hidden\n              className=\"invisible col-start-1 row-start-1 whitespace-nowrap\"\n            >\n              {word}\n            </span>\n          ))}\n          {/* Moving a clipped text gradient defines this effect. Paint\n              containment bounds that deliberate repaint to the active word. */}\n          <motion.span\n            key={`${activeWord}-${activeIndex}`}\n            aria-hidden\n            initial={\n              reduceMotion\n                ? false\n                : {\n                    opacity: 0.56,\n                    filter: \"blur(6px)\",\n                    transform: \"translateY(5px)\",\n                  }\n            }\n            animate={{\n              \"--chromatic-sweep\": shouldReveal\n                ? REVEAL_FINISH\n                : REVEAL_START,\n              opacity: 1,\n              filter: \"blur(0px)\",\n              transform: \"translateY(0px)\",\n            }}\n            transition={{\n              \"--chromatic-sweep\": reduceMotion\n                ? { duration: 0 }\n                : { duration, delay, ease: EASE_IN_OUT },\n              opacity: reduceMotion\n                ? { duration: 0 }\n                : { duration: 0.28, ease: EASE_OUT },\n              filter: reduceMotion\n                ? { duration: 0 }\n                : { duration: 0.36, ease: EASE_OUT },\n              transform: reduceMotion\n                ? { duration: 0 }\n                : { duration: 0.36, ease: EASE_OUT },\n            }}\n            onAnimationComplete={scheduleNextWord}\n            className=\"absolute start-0 top-0 whitespace-nowrap bg-clip-text text-transparent [background-image:var(--chromatic-gradient)] [contain:paint]\"\n            style={{\n              \"--chromatic-sweep\": reduceMotion\n                ? REVEAL_FINISH\n                : REVEAL_START,\n              \"--chromatic-gradient\": backgroundImage,\n              backgroundSize: \"100% 100%\",\n              backgroundRepeat: \"no-repeat\",\n            } as MotionStyle}\n          >\n            {activeWord}\n          </motion.span>\n          <span className=\"sr-only\">{activeWord}</span>\n        </span>\n      ) : null}\n    </span>\n  );\n}\n"},{"path":"components/motion/text-shimmer.tsx","type":"component","content":"// beui.dev/components/motion/text-animation\nimport { cn } from \"@/lib/utils\";\nimport type { ElementType, ReactNode } from \"react\";\nimport {\n  TEXT_SHIMMER_CLASS_NAME,\n  TEXT_SHIMMER_KEYFRAMES,\n  textShimmerStyle,\n} from \"@/lib/text-shimmer\";\n\nexport interface TextShimmerProps {\n  children: ReactNode;\n  as?: ElementType;\n  duration?: number;\n  className?: string;\n}\n\nexport function TextShimmer({ children, as: Comp = \"span\", duration = 2.5, className }: TextShimmerProps) {\n  return (\n    <>\n      <style>\n        {TEXT_SHIMMER_KEYFRAMES}\n      </style>\n      <Comp\n        style={textShimmerStyle(duration)}\n        className={cn(\n          \"inline-block\",\n          TEXT_SHIMMER_CLASS_NAME,\n          className,\n        )}\n      >\n        {children}\n      </Comp>\n    </>\n  );\n}\n"},{"path":"components/motion/text-cascade.tsx","type":"component","content":"\"use client\";\n// beui.dev/components/motion/text-animation\n\nimport { ActionSwapText } from \"./action-swap\";\n\nexport interface TextCascadeProps {\n  /** Current text. Changing it cascades the letters to the new value. */\n  text: string;\n  className?: string;\n}\n\n/**\n * Letter-by-letter slot roll for standalone text — the old letters drop away\n * as the new ones land, left to right. Same motion as the action-swap\n * cascade variant, with a text-first API.\n */\nexport function TextCascade({ text, className }: TextCascadeProps) {\n  return (\n    <ActionSwapText value={text} animation=\"cascade\" className={className}>\n      {text}\n    </ActionSwapText>\n  );\n}\n"},{"path":"components/motion/text-scramble.tsx","type":"component","content":"\"use client\";\n// beui.dev/components/motion/text-animation\n\nimport { useReducedMotion } from \"motion/react\";\nimport {\n  useEffect,\n  useRef,\n  useState,\n  type CSSProperties,\n} from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nconst DEFAULT_GLYPHS = \"ABCDEFGHJKLMNPQRSTUVWXYZ0123456789#%&@$?/\";\n\nexport interface TextScrambleProps {\n  /** Final text revealed by the scramble animation. */\n  text: string;\n  /** Maximum animation duration in milliseconds. */\n  duration?: number;\n  /** Characters sampled while unresolved positions are scrambling. */\n  glyphs?: string;\n  className?: string;\n  style?: CSSProperties;\n}\n\n/** Character scramble that resolves to `text` and respects reduced motion. */\nexport function TextScramble({\n  text,\n  duration,\n  glyphs = DEFAULT_GLYPHS,\n  className,\n  style,\n}: TextScrambleProps) {\n  const reduce = useReducedMotion() ?? false;\n  const [display, setDisplay] = useState(text);\n  const mounted = useRef(false);\n\n  useEffect(() => {\n    if (!mounted.current) {\n      mounted.current = true;\n      setDisplay(text);\n      return;\n    }\n\n    if (reduce || !glyphs) {\n      setDisplay(text);\n      return;\n    }\n\n    const characters = text.split(\"\");\n    const startedAt = performance.now();\n    const animationDuration = duration\n      ?? Math.min(760, Math.max(420, characters.length * 32));\n    let frame = 0;\n    let lastUpdate = 0;\n\n    const animate = (now: number) => {\n      if (now - lastUpdate >= 40) {\n        lastUpdate = now;\n        const progress = Math.min((now - startedAt) / animationDuration, 1);\n        const settled = Math.floor(progress * characters.length);\n        setDisplay(characters.map((character, index) => {\n          if (index < settled || character === \" \") return character;\n          return glyphs[Math.floor(Math.random() * glyphs.length)];\n        }).join(\"\"));\n      }\n\n      if (now - startedAt < animationDuration) {\n        frame = requestAnimationFrame(animate);\n      } else {\n        setDisplay(text);\n      }\n    };\n\n    frame = requestAnimationFrame(animate);\n    return () => cancelAnimationFrame(frame);\n  }, [duration, glyphs, reduce, text]);\n\n  return (\n    <span className={cn(\"inline-block whitespace-pre\", className)} style={style}>\n      <span className=\"sr-only\">{text}</span>\n      <span aria-hidden=\"true\">{reduce ? text : display}</span>\n    </span>\n  );\n}\n"},{"path":"lib/ease.ts","type":"util","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n\n/** Dragged handles and fills (sliders) — critically damped `useSpring` config,\n * so the value follows the pointer butterily and never rebounds off an end. */\nexport const SPRING_GLIDE = {\n  stiffness: 700,\n  damping: 50,\n  mass: 0.5,\n} as const;\n"},{"path":"lib/utils.ts","type":"util","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"},{"path":"lib/text-shimmer.ts","type":"util","content":"import type { CSSProperties } from \"react\";\n\n// The reduced-motion rule travels with the component. app/globals.css calms CSS\n// animation globally, but the registry bundles imports only, so an installed\n// copy never receives that reset.\n//\n// `!important` because the sweep is an inline style, which outranks a plain rule\n// in a media query. It selects a marker class carried by TEXT_SHIMMER_CLASS_NAME\n// so it also reaches consumers that build their own span out of these exports.\nexport const TEXT_SHIMMER_KEYFRAMES =\n  \"@keyframes beui-text-shimmer{from{background-position:200% 0}to{background-position:-200% 0}}\" +\n  \"@media (prefers-reduced-motion: reduce){.beui-text-shimmer{animation:none !important}}\";\n\nexport const TEXT_SHIMMER_CLASS_NAME =\n  \"beui-text-shimmer bg-[length:200%_100%] bg-clip-text text-transparent bg-[linear-gradient(110deg,var(--muted-foreground)_30%,var(--foreground)_50%,var(--muted-foreground)_70%)]\";\n\nexport function textShimmerStyle(duration: number): CSSProperties {\n  return {\n    animation: `beui-text-shimmer ${duration}s linear infinite`,\n  };\n}\n"},{"path":"components/motion/action-swap.tsx","type":"util","content":"\"use client\";\n\nimport { AnimatePresence, motion, useReducedMotion, type HTMLMotionProps, type Variants } from \"motion/react\";\nimport { useState } from \"react\";\nimport type { ReactNode } from \"react\";\nimport { EASE_OUT, SPRING_PRESS, SPRING_SWAP } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport type ActionSwapItem = {\n  id: string;\n  label: ReactNode;\n  icon?: ReactNode;\n  ariaLabel?: string;\n};\n\nexport type ActionSwapButtonVariant = \"primary\" | \"secondary\" | \"outline\" | \"ghost\";\nexport type ActionSwapButtonSize = \"sm\" | \"md\" | \"lg\" | \"icon\";\nexport type ActionSwapAnimation = \"blur\" | \"roll\" | \"cascade\";\n\n/** Animations with a single-element variant set (cascade animates per letter). */\ntype CoreAnimation = \"blur\" | \"roll\";\n\nexport interface ActionSwapButtonProps extends Omit<\n  HTMLMotionProps<\"button\">,\n  \"children\" | \"onChange\"\n> {\n  items: ActionSwapItem[];\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (value: string, item: ActionSwapItem) => void;\n  variant?: ActionSwapButtonVariant;\n  size?: ActionSwapButtonSize;\n  animation?: ActionSwapAnimation;\n  iconOnly?: boolean;\n  cycle?: boolean;\n}\n\nexport interface ActionSwapTextProps {\n  value: string;\n  children: ReactNode;\n  animation?: ActionSwapAnimation;\n  className?: string;\n}\n\nexport interface ActionSwapIconProps {\n  value: string;\n  children: ReactNode;\n  animation?: ActionSwapAnimation;\n  className?: string;\n}\n\nconst BLUR_TRANSITION = { duration: 0.2, ease: \"easeInOut\" } as const;\nconst ROLL_TRANSITION = SPRING_SWAP;\nconst ROLL_EXIT_TRANSITION = { duration: 0.14, ease: EASE_OUT } as const;\nconst SWAP_BLUR = \"blur(8px)\";\nconst ROLL_BLUR = \"blur(3px)\";\n\n// Cascade rolls the label one letter at a time, left to right. The leaving\n// and landing strings overlap as independent layers (no shared cells), so\n// proportional glyph widths never jitter. Exits cascade at half the enter\n// stagger so the tail of the old label lingers briefly.\nconst CASCADE_STAGGER = 0.025;\n\nconst CASCADE_LETTER_VARIANTS: Variants = {\n  initial: { opacity: 0, y: \"105%\", filter: ROLL_BLUR },\n  animate: (delay: number = 0) => ({\n    opacity: 1,\n    y: \"0%\",\n    filter: \"blur(0px)\",\n    transition: { ...SPRING_SWAP, delay },\n  }),\n  exit: (delay: number = 0) => ({\n    opacity: 0,\n    y: \"-105%\",\n    filter: ROLL_BLUR,\n    transition: { duration: 0.16, ease: EASE_OUT, delay: delay * 0.5 },\n  }),\n};\n\nconst TEXT_VARIANTS: Record<CoreAnimation, Variants> = {\n  blur: {\n    initial: { opacity: 0, scale: 0.94, filter: SWAP_BLUR },\n    animate: {\n      opacity: 1,\n      scale: 1,\n      filter: \"blur(0px)\",\n      transition: BLUR_TRANSITION,\n    },\n    exit: {\n      opacity: 0,\n      scale: 0.94,\n      filter: SWAP_BLUR,\n      transition: BLUR_TRANSITION,\n    },\n  },\n  roll: {\n    initial: { opacity: 0, y: \"90%\", filter: ROLL_BLUR },\n    animate: {\n      opacity: 1,\n      y: \"0%\",\n      filter: \"blur(0px)\",\n      transition: ROLL_TRANSITION,\n    },\n    exit: {\n      opacity: 0,\n      y: \"-90%\",\n      filter: ROLL_BLUR,\n      transition: ROLL_EXIT_TRANSITION,\n    },\n  },\n};\n\nconst ICON_VARIANTS: Record<CoreAnimation, Variants> = {\n  blur: {\n    initial: { opacity: 0, scale: 0.25, filter: SWAP_BLUR },\n    animate: {\n      opacity: 1,\n      scale: 1,\n      filter: \"blur(0px)\",\n      transition: BLUR_TRANSITION,\n    },\n    exit: {\n      opacity: 0,\n      scale: 0.25,\n      filter: SWAP_BLUR,\n      transition: BLUR_TRANSITION,\n    },\n  },\n  roll: {\n    initial: { opacity: 0, y: 12, filter: ROLL_BLUR },\n    animate: {\n      opacity: 1,\n      y: 0,\n      filter: \"blur(0px)\",\n      transition: ROLL_TRANSITION,\n    },\n    exit: {\n      opacity: 0,\n      y: -12,\n      filter: ROLL_BLUR,\n      transition: ROLL_EXIT_TRANSITION,\n    },\n  },\n};\n\nconst VARIANT_CLASS: Record<ActionSwapButtonVariant, string> = {\n  primary: \"bg-primary text-primary-foreground hover:bg-primary/90\",\n  secondary: \"border border-border bg-card text-foreground hover:border-border\",\n  outline: \"border border-border bg-transparent text-foreground hover:bg-primary/5\",\n  ghost: \"text-muted-foreground hover:bg-primary/5 hover:text-foreground\",\n};\n\nconst SIZE_CLASS: Record<ActionSwapButtonSize, string> = {\n  sm: \"h-8 gap-1.5 rounded-full px-3 text-xs\",\n  md: \"h-10 gap-2 rounded-full px-4 text-sm\",\n  lg: \"h-12 gap-2.5 rounded-full px-5 text-base\",\n  icon: \"h-10 w-10 rounded-full\",\n};\n\nexport function ActionSwapText({\n  value,\n  children,\n  animation = \"blur\",\n  className,\n}: ActionSwapTextProps) {\n  const reduce = useReducedMotion();\n\n  // Cascade needs a plain string to split into letters; non-string content\n  // and reduced motion fall back to the closest single-element animation.\n  const label = typeof children === \"string\" ? children : null;\n  const cascade = animation === \"cascade\" && label !== null && !reduce;\n  const coreAnimation: CoreAnimation =\n    animation === \"cascade\" ? \"roll\" : animation;\n\n  return (\n    <span\n      className={cn(\n        \"relative -my-[0.08em] inline-block max-w-full whitespace-nowrap py-[0.08em] align-bottom\",\n        className,\n      )}\n      style={{\n        clipPath: \"inset(0 -999px)\",\n        WebkitClipPath: \"inset(0 -999px)\",\n      }}\n    >\n      <span\n        aria-hidden\n        className=\"invisible inline-block whitespace-nowrap\"\n      >\n        {cascade\n          ? label.split(\"\").map((char, index) => (\n              <span\n                // biome-ignore lint/suspicious/noArrayIndexKey: position is the slot identity.\n                key={index}\n                className=\"inline-block whitespace-pre\"\n              >\n                {char}\n              </span>\n            ))\n          : children}\n      </span>\n      {cascade ? (\n        <>\n          {/* Letters are decorative fragments; readers get the whole label. */}\n          <span className=\"sr-only\">{label}</span>\n          <AnimatePresence initial={false}>\n            <motion.span\n              key={`cascade-${value}`}\n              aria-hidden\n              initial=\"initial\"\n              animate=\"animate\"\n              exit=\"exit\"\n              className=\"absolute left-0 top-[0.08em] inline-block whitespace-pre\"\n            >\n              {label.split(\"\").map((char, i) => (\n                <motion.span\n                  // biome-ignore lint/suspicious/noArrayIndexKey: position is the slot identity — the letter at a position is exactly what rolls.\n                  key={i}\n                  custom={i * CASCADE_STAGGER}\n                  variants={CASCADE_LETTER_VARIANTS}\n                  className=\"inline-block whitespace-pre will-change-[opacity,filter,transform]\"\n                >\n                  {char}\n                </motion.span>\n              ))}\n            </motion.span>\n          </AnimatePresence>\n        </>\n      ) : (\n        <AnimatePresence initial={false}>\n          <motion.span\n            key={`${animation}-${value}`}\n            variants={TEXT_VARIANTS[coreAnimation]}\n            initial={reduce ? false : \"initial\"}\n            animate={reduce ? { opacity: 1, filter: \"blur(0px)\", scale: 1, y: 0 } : \"animate\"}\n            exit={reduce ? undefined : \"exit\"}\n            // Truncation lives on the layer that holds the text — the layer\n            // moves as a whole, so clipping it never eats the roll.\n            className=\"absolute left-0 top-[0.08em] inline-block max-w-full truncate will-change-[opacity,filter,transform]\"\n          >\n            {children}\n          </motion.span>\n        </AnimatePresence>\n      )}\n    </span>\n  );\n}\n\nexport function ActionSwapIcon({\n  value,\n  children,\n  animation = \"blur\",\n  className,\n}: ActionSwapIconProps) {\n  const reduce = useReducedMotion();\n  // Icons are single elements — cascade maps to its closest motion, roll.\n  const coreAnimation: CoreAnimation =\n    animation === \"cascade\" ? \"roll\" : animation;\n\n  return (\n    <span className={cn(\"relative inline-grid shrink-0 place-items-center overflow-hidden\", className)}>\n      <AnimatePresence mode=\"popLayout\" initial={false}>\n        <motion.span\n          key={`${animation}-${value}`}\n          aria-hidden\n          variants={ICON_VARIANTS[coreAnimation]}\n          initial={reduce ? false : \"initial\"}\n          animate={reduce ? { opacity: 1, filter: \"blur(0px)\", scale: 1, y: 0 } : \"animate\"}\n          exit={reduce ? undefined : \"exit\"}\n          className=\"col-start-1 row-start-1 inline-flex items-center justify-center will-change-[opacity,filter,transform]\"\n        >\n          {children}\n        </motion.span>\n      </AnimatePresence>\n    </span>\n  );\n}\n\nexport function ActionSwapButton({\n  items,\n  value,\n  defaultValue,\n  onValueChange,\n  variant = \"secondary\",\n  size = \"md\",\n  animation = \"blur\",\n  iconOnly = size === \"icon\",\n  cycle = true,\n  className,\n  disabled,\n  onClick,\n  ...rest\n}: ActionSwapButtonProps) {\n  const reduce = useReducedMotion();\n  const [internalValue, setInternalValue] = useState(defaultValue ?? items[0]?.id);\n  const currentValue = value ?? internalValue;\n  const activeIndex = Math.max(0, items.findIndex((item) => item.id === currentValue));\n  const activeItem = items[activeIndex] ?? items[0];\n  const hasIcon = items.some((item) => item.icon);\n  const nextItem = cycle && items.length > 0 ? items[(activeIndex + 1) % items.length] : undefined;\n\n  if (!activeItem) return null;\n\n  const accessibleLabel = activeItem.ariaLabel ?? (iconOnly && typeof activeItem.label === \"string\" ? activeItem.label : undefined);\n\n  return (\n    <motion.button\n      type=\"button\"\n      disabled={disabled}\n      whileTap={reduce || disabled ? undefined : { scale: 0.97 }}\n      transition={SPRING_PRESS}\n      className={cn(\n        \"inline-flex items-center justify-center overflow-hidden font-medium transition-colors\",\n        \"disabled:pointer-events-none disabled:opacity-50\",\n        VARIANT_CLASS[variant],\n        SIZE_CLASS[size],\n        className,\n      )}\n      aria-label={accessibleLabel}\n      onClick={(event) => {\n        onClick?.(event);\n        if (event.defaultPrevented || disabled || !cycle || !nextItem) return;\n        if (value === undefined) setInternalValue(nextItem.id);\n        onValueChange?.(nextItem.id, nextItem);\n      }}\n      {...rest}\n    >\n      {hasIcon ? (\n        <ActionSwapIcon value={activeItem.id} animation={animation} className=\"h-4 w-4\">\n          {activeItem.icon ?? null}\n        </ActionSwapIcon>\n      ) : null}\n      {!iconOnly ? (\n        <ActionSwapText value={activeItem.id} animation={animation}>\n          {activeItem.label}\n        </ActionSwapText>\n      ) : null}\n    </motion.button>\n  );\n}\n"},{"path":"components/previews/motion/text-animation.preview.tsx","type":"preview","content":"\"use client\";\n\nimport { AnimatePresence, motion } from \"motion/react\";\nimport { useEffect, useState } from \"react\";\nimport { ChromaticTextReveal } from \"@/components/motion/chromatic-text-reveal\";\nimport { TextReveal } from \"@/components/motion/text-reveal\";\nimport { TextShimmer } from \"@/components/motion/text-shimmer\";\nimport { EASE_OUT } from \"@/lib/ease\";\n\nconst variants = [\"chromatic\", \"reveal\", \"shimmer\"] as const;\n\nexport function TextAnimationPreview() {\n  const [variant, setVariant] =\n    useState<(typeof variants)[number]>(\"chromatic\");\n\n  useEffect(() => {\n    const id = window.setInterval(() => {\n      setVariant((currentVariant) => {\n        const index = variants.indexOf(currentVariant);\n        return variants[(index + 1) % variants.length];\n      });\n    }, 3200);\n    return () => window.clearInterval(id);\n  }, []);\n\n  return (\n    <div className=\"@container relative flex min-h-20 w-full items-center justify-center text-center\">\n      <AnimatePresence mode=\"wait\" initial={false}>\n        <motion.div\n          key={variant}\n          initial={{ opacity: 0, filter: \"blur(6px)\", transform: \"translateY(4px)\" }}\n          animate={{ opacity: 1, filter: \"blur(0px)\", transform: \"translateY(0px)\" }}\n          exit={{ opacity: 0, filter: \"blur(6px)\", transform: \"translateY(-4px)\" }}\n          transition={{ duration: 0.22, ease: EASE_OUT }}\n        >\n          {variant === \"reveal\" ? (\n            <TextReveal\n              as=\"h2\"\n              text=\"Motion in words.\"\n              stagger={0.045}\n              blur={6}\n              yOffset=\"18%\"\n              className=\"text-balance text-3xl font-semibold tracking-tight text-foreground\"\n            />\n          ) : variant === \"chromatic\" ? (\n            // This sentence never wraps, so it scales with the column it sits\n            // in rather than overflowing it at narrow widths.\n            <ChromaticTextReveal\n              prefix=\"Motion that feels\"\n              words={[\"natural.\", \"intentional.\", \"alive.\"]}\n              startOnView={false}\n              className=\"font-semibold tracking-tight [font-size:clamp(1.125rem,7.8cqw,1.875rem)]\"\n            />\n          ) : (\n            <TextShimmer duration={1.8} className=\"text-xl font-semibold\">\n              Loading with shimmer\n            </TextShimmer>\n          )}\n        </motion.div>\n      </AnimatePresence>\n    </div>\n  );\n}\n"}]}