{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "rotating-text",
  "title": "Rotating Text",
  "description": "One word in a sentence that takes turns with others, with a slot that springs to each new width and stops whenever someone is reading.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "https://miseui.junsu4621.workers.dev/r/motion.json",
    "https://miseui.junsu4621.workers.dev/r/split-text.json"
  ],
  "files": [
    {
      "path": "registry/miseui/ui/rotating-text.tsx",
      "content": "import type * as React from \"react\"\nimport { useCallback, useEffect, useRef, useState, useSyncExternalStore } from \"react\"\nimport { AnimatePresence, animate, motion, useInView, useMotionValue, useReducedMotion, type AnimationPlaybackControls } from \"motion/react\"\nimport { SPRING_DEFAULT } from \"@/registry/miseui/lib/motion\"\nimport { CELL_CLASS, RotatingWord, SLOT_CLASS, SizerLetters, type RotatingTextVariant } from \"@/registry/miseui/ui/rotating-text-word\"\nimport { cn } from \"@/registry/miseui/lib/utils\"\n\ntype RotatingTextEffect = \"letters\"\ntype RotatingTextTag = \"h1\" | \"h2\" | \"h3\" | \"h4\" | \"p\" | \"span\" | \"div\"\n\nconst DEFAULT_INTERVAL_MS = 2600\n/** Below this a change is still running when the next one starts, and nobody finishes reading a word. */\nconst MIN_INTERVAL_MS = 1200\n\nfunction nextWordIndex(index: number, count: number) {\n  return count > 0 ? (index + 1) % count : 0\n}\n\nfunction clampInterval(milliseconds: number) {\n  return Number.isFinite(milliseconds) ? Math.max(MIN_INTERVAL_MS, milliseconds) : DEFAULT_INTERVAL_MS\n}\n\n/** What assistive tech hears: every word once, so nothing depends on which one was showing when it got there. */\nfunction spokenSentence(before: string, words: readonly string[], after: string) {\n  return `${before}${words.join(\", \")}${after}`\n}\n\nconst subscribeToVisibility = (onChange: () => void) => {\n  document.addEventListener(\"visibilitychange\", onChange)\n  return () => document.removeEventListener(\"visibilitychange\", onChange)\n}\n\nfunction useTabVisible() {\n  return useSyncExternalStore(\n    subscribeToVisibility,\n    () => !document.hidden,\n    () => true,\n  )\n}\n\n/** A timer rather than a frame loop: it only ticks when the word changes, and one tick costs one small render. */\nfunction useWordCycle(count: number, intervalMs: number, running: boolean) {\n  const [index, setIndex] = useState(0)\n  useEffect(() => {\n    if (!running || count < 2) return\n    const timer = setInterval(() => setIndex((current) => nextWordIndex(current, count)), clampInterval(intervalMs))\n    return () => clearInterval(timer)\n  }, [running, count, intervalMs])\n  return count > 0 ? index % count : 0\n}\n\n/**\n * Measures through a ResizeObserver, so a web font arriving late corrects the slot too. Border box sizes ignore\n * transforms on ancestors, which a bounding rect would not.\n */\nfunction useSlotWidth() {\n  const width = useMotionValue<number | string>(\"auto\")\n  const observer = useRef<ResizeObserver | null>(null)\n  const spring = useRef<AnimationPlaybackControls | null>(null)\n  const placed = useRef(false)\n  const sizerRef = useCallback(\n    (element: HTMLSpanElement | null) => {\n      observer.current?.disconnect()\n      spring.current?.stop()\n      if (!element) return\n      const next = new ResizeObserver(([entry]) => {\n        const measured = Math.ceil(entry?.borderBoxSize?.[0]?.inlineSize ?? element.getBoundingClientRect().width)\n        // The first measurement only places the slot. Later ones are a word changing, and start from wherever the spring has got to.\n        if (!placed.current) {\n          placed.current = true\n          width.set(measured)\n          return\n        }\n        spring.current?.stop()\n        spring.current = animate(width, measured, SPRING_DEFAULT)\n      })\n      next.observe(element)\n      observer.current = next\n    },\n    [width],\n  )\n  return { width, sizerRef }\n}\n\ninterface SlotProps {\n  word: string\n  variant: RotatingTextVariant\n  byLetter: boolean\n  index: number\n}\n\nfunction WordSlot({ word, variant, byLetter, index }: SlotProps) {\n  const { width, sizerRef } = useSlotWidth()\n  return (\n    <motion.span\n      data-slot=\"rotating-text-slot\"\n      className={cn(\"relative inline-block max-w-full align-baseline whitespace-nowrap\", SLOT_CLASS[variant])}\n      style={{ width }}\n    >\n      <span ref={sizerRef} data-slot=\"rotating-text-sizer\" className={cn(\"invisible inline-block\", CELL_CLASS[variant])}>\n        {byLetter ? <SizerLetters word={word} /> : word}\n      </span>\n      <AnimatePresence initial={false}>\n        <RotatingWord key={`${index}:${word}`} word={word} variant={variant} byLetter={byLetter} />\n      </AnimatePresence>\n    </motion.span>\n  )\n}\n\ntype RotatingTextProps = Omit<React.HTMLAttributes<HTMLElement>, \"children\"> & {\n  /** The words that take turns. The first one is what reduced motion and the first paint show. */\n  words: readonly string[]\n  /** Text before the word, with its own trailing space if the language wants one. */\n  before?: string\n  /** Text after the word, with its own leading space if the language wants one. */\n  after?: string\n  as?: RotatingTextTag\n  /** `slide` up and out behind a mask, `blur` cross-fades through focus, `flip` turns on the X axis, `pill` a chip whose width springs. */\n  variant?: RotatingTextVariant\n  /** `letters` staggers the change letter by letter. */\n  effect?: RotatingTextEffect\n  /** Milliseconds each word stays. Never below 1200. */\n  interval?: number\n  /** Holds the current word. Tie it to a pause button: motion past five seconds needs a way to stop it. */\n  paused?: boolean\n}\n\nfunction RotatingText({ words, before = \"\", after = \"\", as: Tag = \"span\", variant = \"slide\", effect, interval = DEFAULT_INTERVAL_MS, paused = false, className, onPointerEnter, onPointerLeave, onFocus, onBlur, ...props }: RotatingTextProps) {\n  const reduceMotion = useReducedMotion() ?? false\n  const ref = useRef<HTMLSpanElement>(null)\n  const inView = useInView(ref)\n  const tabVisible = useTabVisible()\n  const [hovered, setHovered] = useState(false)\n  const [focused, setFocused] = useState(false)\n  const running = !reduceMotion && !paused && inView && tabVisible && !hovered && !focused\n  const index = useWordCycle(words.length, interval, running)\n  const word = words[index] ?? \"\"\n  const root = { ...props, \"data-slot\": \"rotating-text\", \"data-variant\": variant, \"data-effect\": effect, \"data-running\": running ? \"true\" : \"false\" }\n  const handlers = {\n    onPointerEnter: (event: React.PointerEvent<HTMLElement>) => {\n      setHovered(true)\n      onPointerEnter?.(event)\n    },\n    onPointerLeave: (event: React.PointerEvent<HTMLElement>) => {\n      setHovered(false)\n      onPointerLeave?.(event)\n    },\n    onFocus: (event: React.FocusEvent<HTMLElement>) => {\n      setFocused(true)\n      onFocus?.(event)\n    },\n    onBlur: (event: React.FocusEvent<HTMLElement>) => {\n      setFocused(false)\n      onBlur?.(event)\n    },\n  }\n  return (\n    <Tag {...root} {...handlers} className={cn(\"relative\", className)}>\n      <span className=\"sr-only select-none\">{spokenSentence(before, words, after)}</span>\n      <span ref={ref} aria-hidden=\"true\" data-slot=\"rotating-text-visual\">\n        {before}\n        {reduceMotion ? (\n          <span data-slot=\"rotating-text-still\" className={cn(\"inline-block max-w-full whitespace-nowrap\", SLOT_CLASS[variant], CELL_CLASS[variant])}>\n            {words[0] ?? \"\"}\n          </span>\n        ) : (\n          <WordSlot word={word} variant={variant} byLetter={effect === \"letters\"} index={index} />\n        )}\n        {after}\n      </span>\n    </Tag>\n  )\n}\n\nexport { RotatingText, clampInterval, nextWordIndex, spokenSentence, type RotatingTextProps, type RotatingTextVariant, type RotatingTextEffect }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/rotating-text-word.tsx",
      "content": "import { motion, type Variants } from \"motion/react\"\nimport { SPRING_DEFAULT } from \"@/registry/miseui/lib/motion\"\nimport { pieceDelay, splitText } from \"@/registry/miseui/ui/split-text-math\"\nimport { cn } from \"@/registry/miseui/lib/utils\"\n\ntype RotatingTextVariant = \"slide\" | \"blur\" | \"flip\" | \"pill\"\n\ntype WordPose = Record<string, string | number>\n\ninterface WordMotion {\n  enter: WordPose\n  center: WordPose\n  exit: WordPose\n}\n\nconst WORD_MOTION: Record<RotatingTextVariant, WordMotion> = {\n  slide: { enter: { y: \"110%\" }, center: { y: \"0%\" }, exit: { y: \"-110%\" } },\n  blur: {\n    enter: { opacity: 0, filter: \"blur(6px)\" },\n    center: { opacity: 1, filter: \"blur(0px)\" },\n    exit: { opacity: 0, filter: \"blur(6px)\" },\n  },\n  flip: { enter: { rotateX: -90 }, center: { rotateX: 0 }, exit: { rotateX: 90 } },\n  pill: { enter: { opacity: 0, y: \"45%\" }, center: { opacity: 1, y: \"0%\" }, exit: { opacity: 0, y: \"-45%\" } },\n}\n\n/** Long enough to read as a change of word, short enough to be over before the eye has moved on. */\nconst CHANGE_SECONDS = 0.55\n\nconst SLOT_CLASS: Record<RotatingTextVariant, string> = {\n  // The extra bottom room keeps descenders inside the clip; the negative margin gives the line height back.\n  slide: \"overflow-clip pb-[0.14em] -mb-[0.14em]\",\n  blur: \"\",\n  flip: \"[perspective:20em]\",\n  pill: \"overflow-clip rounded-full bg-mise-soft -my-[0.06em]\",\n}\n\n/** Padding lives on the word and its sizer rather than the slot, so the measured width already includes it. */\nconst CELL_CLASS: Record<RotatingTextVariant, string> = {\n  slide: \"\",\n  blur: \"\",\n  flip: \"\",\n  pill: \"px-[0.5em] py-[0.06em]\",\n}\n\n/** Turning about the centre of a cube half a line behind the text, so it reads as one solid turning over. */\nconst PIECE_CLASS: Record<RotatingTextVariant, string> = {\n  slide: \"\",\n  blur: \"\",\n  flip: \"origin-[50%_50%_-0.5lh] backface-hidden\",\n  pill: \"\",\n}\n\nfunction pieceVariants(pose: WordMotion, delay: number): Variants {\n  const transition = { ...SPRING_DEFAULT, duration: CHANGE_SECONDS, delay }\n  return {\n    enter: pose.enter,\n    center: { ...pose.center, transition },\n    exit: { ...pose.exit, transition },\n  }\n}\n\ninterface RotatingWordProps {\n  word: string\n  variant: RotatingTextVariant\n  /** Stagger by letter instead of moving the word as one. */\n  byLetter: boolean\n}\n\nfunction RotatingWord({ word, variant, byLetter }: RotatingWordProps) {\n  const groups = splitText(word, byLetter ? \"chars\" : \"words\")\n  const count = groups.reduce((sum, group) => sum + group.pieces.length, 0)\n  return (\n    <motion.span\n      data-slot=\"rotating-text-word\"\n      className={cn(\"absolute start-0 top-0 block whitespace-nowrap\", variant === \"flip\" && \"[transform-style:preserve-3d]\", CELL_CLASS[variant])}\n      initial=\"enter\"\n      animate=\"center\"\n      exit=\"exit\"\n    >\n      {groups.map((group, position) => (\n        <span key={group.key}>\n          {group.pieces.map((piece) => (\n            <motion.span\n              key={piece.index}\n              data-slot=\"rotating-text-piece\"\n              className={cn(\"inline-block\", PIECE_CLASS[variant])}\n              variants={pieceVariants(WORD_MOTION[variant], byLetter ? pieceDelay(piece.index, count, \"chars\", \"start\") / 1000 : 0)}\n            >\n              {piece.text}\n            </motion.span>\n          ))}\n          {position < groups.length - 1 ? \" \" : null}\n        </span>\n      ))}\n    </motion.span>\n  )\n}\n\n/** Split letters lose kerning, so a slot sized from the whole word comes up short and clips the last letter. */\nfunction SizerLetters({ word }: { word: string }) {\n  const groups = splitText(word, \"chars\")\n  return groups.map((group, position) => (\n    <span key={group.key}>\n      {group.pieces.map((piece) => (\n        <span key={piece.index} className=\"inline-block\">\n          {piece.text}\n        </span>\n      ))}\n      {position < groups.length - 1 ? \" \" : null}\n    </span>\n  ))\n}\n\nexport { CELL_CLASS, RotatingWord, SLOT_CLASS, SizerLetters, type RotatingTextVariant }\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "text"
  ],
  "type": "registry:ui"
}