{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "text-effect-02",
  "title": "Slice text",
  "description": "A large word drawn as horizontal strips that fly in from the left and the right in turn and settle into its letters, then come apart around the pointer, the strips above it sliding one way and those under it the other.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "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-02.tsx",
      "content": "'use client';\n\nimport { useEffect, useImperativeHandle, useRef } from 'react';\n\nimport {\n  useAutoplay,\n  type AutoplayStep,\n} from '@/registry/ndeko/hooks/use-autoplay';\nimport { cn } from '@/registry/ndeko/lib/utils';\n\nimport { useHover } from './_parts/use-hover';\nimport { usePlayWhileSeen } from './_parts/use-play-while-seen';\n\nconst DEFAULT_STRIPS = 8;\n\n/** Neighbouring strips overlap by this much, so no seam of the page shows between them. */\nconst SEAM_PX = 0.4;\n\n/** A strip may slide this far past the sides of the word before its edge cuts a letter. */\nconst SIDE_ROOM = '-12%';\n\n/** How far a strip next to the cut slides, in em: it scales with the size of the type. */\nconst CUT_EM = 0.12;\n\n/** Letters rise above and drop below the line box of tight type: the first and the last strip reach this far past it, so no top or tail is cut off. */\nconst OUTER_ROOM = '-0.35em';\n\n/** Strips feel the cut this far from it, as a share of the height of the word. */\nconst CUT_REACH = 0.42;\n\nconst ASSEMBLE_MS = 1050;\n\nconst ASSEMBLE_STAGGER_MS = 45;\n\n/** Where each strip leaves to and comes back from, as a share of the width of the word. */\nconst LEAVE_SHARE = 40;\n\nconst ENTER_SHARE = 64;\n\n/** The strip leaves over the first quarter and comes back over the rest. */\nconst TURN_OFFSET = 0.26;\n\nconst LEAVE_EASING = 'cubic-bezier(0.77, 0, 0.175, 1)';\n\nconst ENTER_EASING = 'cubic-bezier(0.23, 1, 0.32, 1)';\n\nfunction percentOf(share: number) {\n  const hundredths = Math.round(share * 10000);\n  return hundredths / 100;\n}\n\n/** Where a band stops, from its own edge of the word: the outer bands reach past the line box, the inner ones overlap their neighbour by a hair. */\nfunction bandEdgeOf(bandsBefore: number, count: number) {\n  if (bandsBefore === 0) {\n    return OUTER_ROOM;\n  }\n  const share = percentOf(bandsBefore / count);\n  return `calc(${share}% - ${SEAM_PX}px)`;\n}\n\nfunction bandOf(index: number, count: number) {\n  const top = bandEdgeOf(index, count);\n  const bandsBelow = count - 1 - index;\n  const bottom = bandEdgeOf(bandsBelow, count);\n  return `inset(${top} ${SIDE_ROOM} ${bottom} ${SIDE_ROOM})`;\n}\n\n/** Even strips come in from the left, odd ones from the right. */\nfunction travelOf(index: number) {\n  if (index % 2 === 0) {\n    return 1;\n  }\n  return -1;\n}\n\n/** A strip slides on the way it travels and out, jumps unseen to the side it comes in from, and settles in place. */\nfunction assembleKeyframesOf(index: number): Keyframe[] {\n  const travel = travelOf(index);\n  const leaveTo = travel * LEAVE_SHARE;\n  const enterFrom = -travel * ENTER_SHARE;\n  return [\n    {\n      offset: 0,\n      transform: 'translateX(0%)',\n      opacity: 1,\n      easing: LEAVE_EASING,\n    },\n    { offset: TURN_OFFSET, transform: `translateX(${leaveTo}%)`, opacity: 0 },\n    {\n      offset: TURN_OFFSET,\n      transform: `translateX(${enterFrom}%)`,\n      opacity: 0,\n      easing: ENTER_EASING,\n    },\n    { offset: 1, transform: 'translateX(0%)', opacity: 1 },\n  ];\n}\n\n/** Strips above the cut slide to one side and strips under it to the other, less the further they are from it. */\nfunction shiftOf(index: number, count: number, cut: number | null) {\n  if (cut === null) {\n    return 'translateX(0em)';\n  }\n  const centre = (index + 0.5) / count;\n  const fromCut = centre - cut;\n  const nearness = Math.max(0, 1 - Math.abs(fromCut) / CUT_REACH);\n  const pull = nearness * nearness;\n  const side = Math.sign(fromCut);\n  const shift = side * CUT_EM * pull;\n  const rounded = Math.round(shift * 1000) / 1000;\n  return `translateX(${rounded}em)`;\n}\n\nfunction stripsIn(root: HTMLElement, part: string) {\n  const elements = root.querySelectorAll<HTMLElement>(`[data-${part}]`);\n  return Array.from(elements);\n}\n\nfunction cancelAll(animations: Animation[]) {\n  for (const animation of animations) {\n    animation.cancel();\n  }\n}\n\nexport type SliceTextHandle = {\n  /** Sends the strips out to both sides and back into the word. */\n  assemble: () => void;\n  /** Cuts the word apart at a height given as a share of it, as a pointer there would, for a demonstration. */\n  cutAt: (share: number) => void;\n  /** Lets the strips ease back into the word. */\n  mend: () => void;\n};\n\nexport type SliceTextProps = {\n  text: string;\n  /** How many horizontal strips the word is cut into. */\n  strips?: number;\n  /** Plays the strips from outside, for a demonstration or a hero that assembles once it loads. */\n  handleRef?: React.Ref<SliceTextHandle>;\n  className?: string;\n};\n\n/**\n * A word drawn as horizontal strips of itself. They fly in from both sides and settle into the\n * letters, and the pointer cuts the word apart around it. The word itself stays in the page as\n * plain text that can be selected and read; the strips are drawn over it. They travel inside a box\n * a little wider than the word, clipped on its sides, so a strip on its way in never widens the page.\n */\nexport function SliceText({\n  text,\n  strips = DEFAULT_STRIPS,\n  handleRef,\n  className,\n}: SliceTextProps) {\n  const { ref, isPlaying, isStill } = usePlayWhileSeen<HTMLSpanElement>();\n  const assembling = useRef<Animation[]>([]);\n  const pendingCut = useRef<number | null>(null);\n  const frame = useRef(0);\n  const count = Math.max(1, Math.round(strips));\n  const indexes = Array.from({ length: count }, (_, index) => index);\n\n  useEffect(() => {\n    if (isPlaying) {\n      return;\n    }\n    cancelAll(assembling.current);\n    assembling.current = [];\n  }, [isPlaying]);\n\n  useEffect(() => {\n    return () => {\n      cancelAnimationFrame(frame.current);\n      cancelAll(assembling.current);\n    };\n  }, []);\n\n  function applyCut() {\n    frame.current = 0;\n    const root = ref.current;\n    if (!root) {\n      return;\n    }\n    const cut = pendingCut.current;\n    const shifts = stripsIn(root, 'strip-shift');\n    shifts.forEach((element, index) => {\n      element.style.transform = shiftOf(index, shifts.length, cut);\n    });\n  }\n\n  /** Many pointer moves can come in one frame: the strips are moved once per frame, to the last one. */\n  function cutAt(share: number | null) {\n    if (isStill) {\n      return;\n    }\n    pendingCut.current = share;\n    if (frame.current !== 0) {\n      return;\n    }\n    frame.current = requestAnimationFrame(applyCut);\n  }\n\n  function assemble() {\n    const root = ref.current;\n    if (!root || isStill) {\n      return;\n    }\n    cancelAll(assembling.current);\n    const bands = stripsIn(root, 'strip');\n    assembling.current = bands.map((band, index) =>\n      band.animate(assembleKeyframesOf(index), {\n        duration: ASSEMBLE_MS,\n        delay: index * ASSEMBLE_STAGGER_MS,\n      })\n    );\n  }\n\n  function cutFromPointer(event: React.PointerEvent<HTMLSpanElement>) {\n    if (event.pointerType === 'touch') {\n      return;\n    }\n    const box = event.currentTarget.getBoundingClientRect();\n    if (box.height === 0) {\n      return;\n    }\n    const share = (event.clientY - box.top) / box.height;\n    cutAt(share);\n  }\n\n  function mend() {\n    cutAt(null);\n  }\n\n  useImperativeHandle(handleRef, () => ({ assemble, cutAt, mend }));\n\n  return (\n    <span\n      ref={ref}\n      onPointerMove={cutFromPointer}\n      onPointerLeave={mend}\n      className={cn(\n        'relative -mx-[0.15em] inline-block overflow-x-clip px-[0.15em]',\n        className\n      )}\n    >\n      <span className='selection:bg-primary/20 text-transparent selection:text-transparent forced-colors:text-[color:CanvasText]'>\n        {text}\n      </span>\n      <span\n        aria-hidden='true'\n        className='pointer-events-none absolute inset-x-[0.15em] inset-y-0 select-none forced-colors:hidden'\n      >\n        {indexes.map((index) => (\n          <span\n            key={index}\n            data-strip=''\n            style={{ clipPath: bandOf(index, count) }}\n            className='absolute inset-0'\n          >\n            <span\n              data-strip-shift=''\n              className='block transition-transform duration-[450ms] ease-(--ease-out-strong)'\n            >\n              {text}\n            </span>\n          </span>\n        ))}\n      </span>\n    </span>\n  );\n}\n\n/** A pointer passing down the word: the heights the cut goes through, as shares of it. */\nconst SWEEP = [0.18, 0.3, 0.42, 0.54, 0.66, 0.78];\n\nconst SWEEP_STEP_MS = 130;\n\nexport default function TextEffect02() {\n  const slice = useRef<SliceTextHandle>(null);\n  const { isHovered, hoverHandlers } = useHover();\n  const sweepSteps: AutoplayStep[] = SWEEP.map((share) => ({\n    after: SWEEP_STEP_MS,\n    run: () => slice.current?.cutAt(share),\n  }));\n  const steps: AutoplayStep[] = [\n    { after: 1400, run: () => slice.current?.assemble() },\n    { after: 2200, run: () => slice.current?.cutAt(0.06) },\n    ...sweepSteps,\n    { after: 520, run: () => slice.current?.mend() },\n  ];\n  const { ref, isAutoplaying } = useAutoplay(steps, {\n    loopAfter: 1800,\n    onReset: () => slice.current?.mend(),\n    isEnabled: !isHovered,\n  });\n  const isDemoStopped = !isAutoplaying && !isHovered;\n\n  /** A cut the demonstration leaves when a person takes over closes: a keyboard has no pointer to mend it. */\n  useEffect(() => {\n    if (isDemoStopped) {\n      slice.current?.mend();\n    }\n  }, [isDemoStopped]);\n\n  return (\n    <div\n      ref={ref}\n      className='@container flex w-full max-w-[34rem] justify-center'\n      {...hoverHandlers}\n    >\n      <p className='text-[length:clamp(3.5rem,20cqw,7rem)] leading-none font-semibold'>\n        <SliceText handleRef={slice} text='Sharp' />\n      </p>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/text-effect/text-effect-02.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"
}