{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "text-effect-05",
  "title": "Perspective swap",
  "description": "A headline that alternates between two or three phrases, each one turning away round an axis behind the line in soft perspective as the next turns in from the other side, and holding still under the pointer.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://ui.ndekocode.dev/r/segments.json",
    "https://ui.ndekocode.dev/r/theme.json",
    "https://ui.ndekocode.dev/r/use-agent-run.json",
    "https://ui.ndekocode.dev/r/use-autoplay.json",
    "https://ui.ndekocode.dev/r/use-prefers-reduced-motion.json",
    "https://ui.ndekocode.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "registry/ndeko/components/text-effect/text-effect-05.tsx",
      "content": "'use client';\n\nimport { useEffect, useLayoutEffect, useRef, useState } from 'react';\n\nimport {\n  useAutoplay,\n  type AutoplayStep,\n} from '@/registry/ndeko/hooks/use-autoplay';\nimport { cn } from '@/registry/ndeko/lib/utils';\nimport { Segments, type Segment } from '@/registry/ndeko/ui/segments';\n\nimport { useHover } from './_parts/use-hover';\nimport { usePlayWhileSeen } from './_parts/use-play-while-seen';\n\nexport type SwapDirection = 'up' | 'down';\n\ntype Tilt = {\n  away: string;\n  toward: string;\n};\n\nconst DEFAULT_HOLD_MS = 2600;\n\nconst TURN_MS = 640;\n\nconst TURN_EASING = 'cubic-bezier(0.77, 0, 0.175, 1)';\n\n/** The two phrases are faces of one drum: they turn round an axis this far behind the line, so they stay a quarter turn apart. */\nconst AXIS = '50% 50% -0.6em';\n\nconst PERSPECTIVE = '30em';\n\nconst SHARP = 'blur(0px)';\n\nconst BLURRED = 'blur(2px)';\n\n/** The leaving phrase is gone by this share of the turn and the arriving one starts to show after the matching share, so the two never read as one smear. */\nconst LEAVES_BY = 0.6;\n\nconst ARRIVES_FROM = 0.4;\n\nconst TILTS: Record<SwapDirection, Tilt> = {\n  up: { away: 'rotateX(90deg)', toward: 'rotateX(-90deg)' },\n  down: { away: 'rotateX(-90deg)', toward: 'rotateX(90deg)' },\n};\n\nfunction nextIndexOf(current: number, count: number) {\n  return (current + 1) % count;\n}\n\nfunction leaveKeyframesOf(direction: SwapDirection): Keyframe[] {\n  const { away } = TILTS[direction];\n  return [\n    { transform: 'rotateX(0deg)', opacity: 1, filter: SHARP },\n    { offset: LEAVES_BY, opacity: 0 },\n    { transform: away, opacity: 0, filter: BLURRED },\n  ];\n}\n\nfunction arriveKeyframesOf(direction: SwapDirection): Keyframe[] {\n  const { toward } = TILTS[direction];\n  return [\n    { transform: toward, opacity: 0, filter: BLURRED },\n    { offset: ARRIVES_FROM, opacity: 0 },\n    { transform: 'rotateX(0deg)', opacity: 1, filter: SHARP },\n  ];\n}\n\nexport type PerspectiveSwapProps = {\n  /** The phrases the headline goes through, two or three of them. */\n  phrases: readonly string[];\n  /** Which way the phrase that leaves turns: `up` tips its top edge away, `down` its bottom edge. */\n  direction?: SwapDirection;\n  /** How long a phrase rests before the next one turns in, in milliseconds. */\n  holdMs?: number;\n  /** What a screen reader reads in place of the phrases. */\n  label?: string;\n  className?: string;\n};\n\n/**\n * A headline that alternates between phrases. Each one turns away round an axis behind the line\n * while the next turns in from the other side, in soft perspective. The phrases share one box, so\n * the headline keeps the size of the longest and nothing under it moves. It holds under the\n * pointer, off screen and in a hidden tab, and under reduced motion it rests on the first phrase.\n */\nexport function PerspectiveSwap({\n  phrases,\n  direction = 'up',\n  holdMs = DEFAULT_HOLD_MS,\n  label,\n  className,\n}: PerspectiveSwapProps) {\n  const { ref, isPlaying, isStill } = usePlayWhileSeen<HTMLSpanElement>();\n  const { isHovered, hoverHandlers } = useHover();\n  const [index, setIndex] = useState(0);\n  const faces = useRef<(HTMLSpanElement | null)[]>([]);\n  const lastShown = useRef(0);\n  const count = phrases.length;\n  const hasSeveral = count > 1;\n  const canTurn = isPlaying && hasSeveral;\n  const isTurning = canTurn && !isHovered;\n  const shown = index % Math.max(count, 1);\n  const spoken = label ?? phrases.join(', ');\n\n  useEffect(() => {\n    if (!isTurning) {\n      return;\n    }\n    const timer = window.setTimeout(() => {\n      setIndex((current) => nextIndexOf(current, count));\n    }, holdMs);\n    return () => window.clearTimeout(timer);\n  }, [isTurning, index, count, holdMs]);\n\n  useLayoutEffect(() => {\n    const previous = lastShown.current;\n    lastShown.current = shown;\n    const hasTurned = previous !== shown;\n    if (!hasTurned || isStill) {\n      return;\n    }\n    const leaving = faces.current[previous];\n    const arriving = faces.current[shown];\n    if (!leaving || !arriving) {\n      return;\n    }\n    const timing = { duration: TURN_MS, easing: TURN_EASING };\n    leaving.animate(leaveKeyframesOf(direction), timing);\n    arriving.animate(arriveKeyframesOf(direction), timing);\n  }, [shown, direction, isStill]);\n\n  if (count === 0) {\n    return null;\n  }\n\n  return (\n    <span\n      ref={ref}\n      style={{ perspective: PERSPECTIVE }}\n      className={cn(\n        'relative -mx-[0.1em] inline-grid max-w-full overflow-x-clip px-[0.1em] text-start',\n        className\n      )}\n      {...hoverHandlers}\n    >\n      <span className='sr-only'>{spoken}</span>\n      {phrases.map((phrase, position) => (\n        <span\n          key={position}\n          ref={(element) => {\n            faces.current[position] = element;\n          }}\n          aria-hidden='true'\n          data-current={position === shown}\n          style={{ transformOrigin: AXIS }}\n          className='col-start-1 row-start-1 data-[current=false]:pointer-events-none data-[current=false]:opacity-0 data-[current=false]:select-none'\n        >\n          {phrase}\n        </span>\n      ))}\n    </span>\n  );\n}\n\nconst PHRASES = [\n  'Plan the week together',\n  'Ship before Friday',\n  'Review on Monday',\n];\n\nconst DIRECTIONS: Segment<SwapDirection>[] = [\n  { value: 'up', label: 'Up' },\n  { value: 'down', label: 'Down' },\n];\n\n/** About three turns each way, so both directions are seen before the demonstration goes round again. */\nconst UP_SHOWN_MS = 10000;\n\nconst DOWN_SHOWN_MS = 10000;\n\nexport default function TextEffect05() {\n  const [direction, setDirection] = useState<SwapDirection>('up');\n  const { isHovered, hoverHandlers } = useHover();\n  const steps: AutoplayStep[] = [\n    { after: UP_SHOWN_MS, run: () => setDirection('down') },\n    { after: DOWN_SHOWN_MS, run: () => setDirection('up') },\n  ];\n  const { ref } = useAutoplay(steps, {\n    loopAfter: 0,\n    onReset: () => setDirection('up'),\n    isEnabled: !isHovered,\n  });\n\n  return (\n    <div\n      ref={ref}\n      className='@container flex w-full max-w-[34rem] flex-col gap-5'\n      {...hoverHandlers}\n    >\n      <p className='text-2xl leading-tight font-semibold text-balance @md:text-3xl'>\n        <PerspectiveSwap phrases={PHRASES} direction={direction} />\n      </p>\n      <Segments\n        value={direction}\n        segments={DIRECTIONS}\n        onValueChange={setDirection}\n        label='Turn direction'\n        className='self-start'\n      />\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/text-effect/text-effect-05.tsx"
    },
    {
      "path": "registry/ndeko/components/text-effect/_parts/use-hover.ts",
      "content": "'use client';\n\nimport { useState } from 'react';\n\n/** Whether a mouse or a pen rests on an element. A finger does not hover, so a touch never counts. */\nexport function useHover() {\n  const [isHovered, setIsHovered] = useState(false);\n\n  function hoverStarts(event: React.PointerEvent<HTMLElement>) {\n    if (event.pointerType !== 'touch') {\n      setIsHovered(true);\n    }\n  }\n\n  function hoverEnds() {\n    setIsHovered(false);\n  }\n\n  const hoverHandlers = {\n    onPointerEnter: hoverStarts,\n    onPointerLeave: hoverEnds,\n  };\n\n  return { isHovered, hoverHandlers };\n}\n",
      "type": "registry:component",
      "target": "components/text-effect/_parts/use-hover.ts"
    },
    {
      "path": "registry/ndeko/components/text-effect/_parts/use-play-while-seen.ts",
      "content": "'use client';\n\nimport { useInView } from 'motion/react';\nimport { useRef } from 'react';\n\nimport { useDocumentVisible } from '@/registry/ndeko/hooks/use-agent-run';\nimport { usePrefersReducedMotion } from '@/registry/ndeko/hooks/use-prefers-reduced-motion';\n\n/**\n * A text effect plays only while someone can see it: on screen, in the tab that is shown, without\n * reduced motion. Everything that repeats hangs on `isPlaying`, and `isStill` asks for the still\n * frame, which must read as the finished text.\n */\nexport function usePlayWhileSeen<Element extends HTMLElement = HTMLElement>() {\n  const ref = useRef<Element>(null);\n  const isInView = useInView(ref);\n  const isTabVisible = useDocumentVisible();\n  const isStill = usePrefersReducedMotion();\n  const isOnScreen = isInView && isTabVisible;\n  const isPlaying = isOnScreen && !isStill;\n\n  return { ref, isPlaying, isStill };\n}\n",
      "type": "registry:component",
      "target": "components/text-effect/_parts/use-play-while-seen.ts"
    }
  ],
  "categories": [
    "components",
    "text-effect"
  ],
  "type": "registry:component"
}