{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"text-reveal","type":"registry:component","title":"Text Animation Text Reveal","description":"Word or character reveal with spring slide-up and blur.","author":"Saurabh <saurabh10102@gmail.com>","dependencies":["clsx","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/text-reveal.tsx","type":"registry:component","target":"@components/motion/text-reveal.tsx","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":"lib/ease.ts","type":"registry:lib","target":"@lib/ease.ts","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":"registry:lib","target":"@lib/utils.ts","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"}]}