{"slug":"text-animation","name":"Text Animation","description":"Animated text primitives for reveal sequences and shimmer loading states.","category":"motion","source_url":"https://beui.saura3h.xyz/r/text-animation/raw","detail_url":"https://beui.saura3h.xyz/r/text-animation","raw_url":"https://beui.saura3h.xyz/r/text-animation/raw","page_url":"https://beui.saura3h.xyz/components/motion/text-animation","dependencies":["clsx","motion","react","tailwind-merge"],"internal":["@/components/motion/text-reveal","@/components/motion/text-shimmer","@/lib/ease","@/lib/utils"],"files":[{"path":"components/motion/text-reveal.tsx","type":"component","content":"\"use client\";\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\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 units = split === \"word\" ? line.split(\" \") : Array.from(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        return (\n          <span key={lineKey} className=\"block\">\n            {units.map((unit, i) => {\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                  className=\"inline-block will-change-transform\"\n                >\n                  {unit}\n                  {split === \"word\" && i < units.length - 1 ? (\n                    <span className=\"inline-block\">&nbsp;</span>\n                  ) : null}\n                </motion.span>\n              );\n            })}\n          </span>\n        );\n      })}\n      {children}\n    </Comp>\n  );\n}\n"},{"path":"components/motion/text-shimmer.tsx","type":"component","content":"import { cn } from \"@/lib/utils\";\nimport type { ElementType, ReactNode } from \"react\";\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        {`@keyframes beui-text-shimmer{from{background-position:200% 0}to{background-position:-200% 0}}`}\n      </style>\n      <Comp\n        style={{ animation: `beui-text-shimmer ${duration}s linear infinite` }}\n        className={cn(\n          \"inline-block bg-[length:200%_100%] bg-clip-text text-transparent\",\n          \"bg-[linear-gradient(110deg,var(--muted-foreground)_30%,var(--foreground)_50%,var(--muted-foreground)_70%)]\",\n          className,\n        )}\n      >\n        {children}\n      </Comp>\n    </>\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"},{"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":"components/previews/motion/text-animation.preview.tsx","type":"preview","content":"\"use client\";\n\nimport { AnimatePresence, motion } from \"motion/react\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { useEffect, useState } from \"react\";\nimport { TextReveal } from \"@/components/motion/text-reveal\";\nimport { TextShimmer } from \"@/components/motion/text-shimmer\";\n\nexport function TextAnimationPreview() {\n  const [variant, setVariant] = useState<\"reveal\" | \"shimmer\">(\"reveal\");\n\n  useEffect(() => {\n    const id = window.setInterval(() => {\n      setVariant((currentVariant) => currentVariant === \"reveal\" ? \"shimmer\" : \"reveal\");\n    }, 3000);\n    return () => window.clearInterval(id);\n  }, []);\n\n  return (\n    <div className=\"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-(--color-fg)\"\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"}]}