{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ticket-tear",
  "type": "registry:component",
  "title": "Ticket Tear",
  "description": "A booking ticket that, once confirmed, rips along its perforation: the stub pulls open from the top, comes free and drops away with a lazy spin, and the ticket you keep slides back to centre with its torn edge intact.",
  "categories": [
    "motion",
    "Celebration"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/devl/ticket-tear.tsx",
      "type": "registry:component",
      "content": "\"use client\";\n\nimport { motion, useAnimate, useReducedMotion } from \"motion/react\";\nimport { type ReactNode, useEffect, useLayoutEffect, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface TicketTearProps {\n  main: ReactNode;\n  stub: ReactNode;\n  torn: boolean;\n  onTorn?: () => void;\n  stubWidth?: number;\n  height?: number;\n  className?: string;\n}\n\nconst NOTCH = 9;\nconst BLEED = 4;\nconst CORNER = 14;\n\nconst useIsoLayoutEffect = typeof window === \"undefined\" ? useEffect : useLayoutEffect;\n\nfunction useSafeReducedMotion() {\n  const prefers = useReducedMotion();\n  const [mounted, setMounted] = useState(false);\n  useEffect(() => setMounted(true), []);\n  return mounted && !!prefers;\n}\n\nfunction seam(x: number, height: number) {\n  const top = NOTCH;\n  const bottom = height - NOTCH;\n  const n = Math.max(2, Math.round((bottom - top) / 5));\n  return Array.from({ length: n + 1 }, (_, i) => {\n    const edge = i === 0 || i === n;\n    const j = Math.sin((i + 1) * 12.9898) * 43758.5453;\n    const noise = j - Math.floor(j);\n    const dx = edge ? 0 : (i % 2 ? 1 : -1) * 2.2 * (0.5 + 0.5 * noise);\n    return `${(x + dx).toFixed(2)} ${(top + ((bottom - top) * i) / n).toFixed(2)}`;\n  });\n}\n\nfunction shapes(width: number, height: number, stubWidth: number) {\n  const x = width - stubWidth;\n  const r = CORNER;\n  const R = NOTCH;\n  const H = height;\n  const W = width;\n  const zig = seam(x, H);\n  const down = zig.map((p) => `L${p}`).join(\" \");\n  const up = [...zig].reverse().map((p) => `L${p}`).join(\" \");\n  const mainEdge = `M${x} ${H - R} A${R} ${R} 0 0 0 ${x - R} ${H} H${r} A${r} ${r} 0 0 1 0 ${H - r} V${r} A${r} ${r} 0 0 1 ${r} 0 H${x - R} A${R} ${R} 0 0 0 ${x} ${R}`;\n  const stubEdge = `M${x} ${R} A${R} ${R} 0 0 0 ${x + R} 0 H${W - r} A${r} ${r} 0 0 1 ${W} ${r} V${H - r} A${r} ${r} 0 0 1 ${W - r} ${H} H${x + R} A${R} ${R} 0 0 0 ${x} ${H - R}`;\n  return {\n    x,\n    mainFill: `${mainEdge} ${down.replace(/^L[^L]*/, \"\")} Z`,\n    mainEdge,\n    stubFill: `${stubEdge} ${up.replace(/^L[^L]*/, \"\")} Z`,\n    stubEdge,\n    seam: `M${zig.join(\" L\")}`,\n  };\n}\n\nexport function TicketTear({ main, stub, torn, onTorn, stubWidth = 96, height = 168, className }: TicketTearProps) {\n  const root = useRef<HTMLDivElement>(null);\n  const [width, setWidth] = useState(360);\n  const [mainScope, animateMain] = useAnimate<HTMLDivElement>();\n  const [stubScope, animateStub] = useAnimate<HTMLDivElement>();\n  const reduce = useSafeReducedMotion();\n  const shown = useRef<boolean | null>(null);\n  const done = useRef(onTorn);\n  done.current = onTorn;\n\n  useIsoLayoutEffect(() => {\n    const el = root.current;\n    if (!el) return;\n    const measure = () => setWidth(el.offsetWidth);\n    measure();\n    const observer = new ResizeObserver(measure);\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, []);\n\n  useEffect(() => {\n    const mainEl = mainScope.current;\n    const stubEl = stubScope.current;\n    if (!mainEl || !stubEl) return;\n    if (shown.current === null) {\n      shown.current = torn;\n      if (torn) {\n        animateStub(stubEl, { opacity: 0 }, { duration: 0 });\n        animateMain(mainEl, { x: stubWidth / 2 }, { duration: 0 });\n      }\n      return;\n    }\n    if (shown.current === torn) return;\n    shown.current = torn;\n    let cancelled = false;\n    if (torn) {\n      if (reduce) {\n        animateStub(stubEl, { opacity: 0 }, { duration: 0.2 }).then(() => {\n          if (cancelled) return;\n          animateMain(mainEl, { x: stubWidth / 2 }, { duration: 0 });\n          done.current?.();\n        });\n        return () => {\n          cancelled = true;\n        };\n      }\n      const ease = [0.23, 1, 0.32, 1] as const;\n      animateStub([\n        [stubEl, { rotate: 2 }, { duration: 0.14, ease: \"easeOut\" }],\n        [stubEl, { rotate: 6 }, { duration: 0.18, ease: [0.5, 0, 0.75, 0.4] }],\n        [stubEl, { rotate: 13, x: -18 }, { duration: 0.55, ease: \"easeOut\" }],\n        [stubEl, { y: 130 }, { duration: 0.55, ease: [0.45, 0, 0.9, 0.45], at: \"<\" }],\n        [stubEl, { opacity: 0 }, { duration: 0.28, ease: \"easeIn\", at: \"-0.3\" }],\n      ]).then(() => !cancelled && done.current?.());\n      animateMain([\n        [mainEl, { x: -2 }, { duration: 0.3, ease: \"easeOut\" }],\n        [mainEl, { x: stubWidth / 2 }, { type: \"spring\", stiffness: 260, damping: 30, at: 0.5 }],\n      ]);\n    } else {\n      animateStub(stubEl, { rotate: 0, x: 0, y: 0 }, { duration: 0 });\n      animateStub(stubEl, { opacity: [0, 1], scale: reduce ? 1 : [0.96, 1] }, { duration: 0.24, ease: [0.23, 1, 0.32, 1] });\n      animateMain(mainEl, { x: 0 }, reduce ? { duration: 0 } : { type: \"spring\", stiffness: 320, damping: 32 });\n    }\n    return () => {\n      cancelled = true;\n    };\n  }, [torn, reduce, stubWidth, animateMain, animateStub, mainScope, stubScope]);\n\n  const s = shapes(width, height, stubWidth);\n  const seamTransition = { duration: reduce ? 0.2 : 0.16, delay: torn && !reduce ? 0.12 : 0 };\n  const svgClass =\n    \"absolute inset-0 overflow-visible drop-shadow-[0_1px_2px_rgb(0_0_0/0.06)] dark:drop-shadow-[0_1px_2px_rgb(0_0_0/0.3)]\";\n\n  return (\n    <div className={cn(\"relative w-full select-none\", className)} ref={root} style={{ height }}>\n      <div className=\"absolute inset-y-0 left-0\" ref={mainScope} style={{ right: stubWidth - BLEED }}>\n        <svg aria-hidden className={svgClass} height={height} viewBox={`0 0 ${s.x + BLEED} ${height}`} width={s.x + BLEED}>\n          <path className=\"fill-card\" d={s.mainFill} />\n          <path className=\"fill-none stroke-border\" d={s.mainEdge} />\n          <motion.path\n            animate={{ opacity: torn ? 1 : 0 }}\n            className=\"fill-none stroke-border\"\n            d={s.seam}\n            initial={false}\n            transition={seamTransition}\n          />\n          <motion.line\n            animate={{ opacity: torn ? 0 : 1 }}\n            className=\"stroke-muted-foreground/45\"\n            initial={false}\n            strokeDasharray=\"0.5 5.5\"\n            strokeLinecap=\"round\"\n            strokeWidth={2}\n            transition={{ duration: 0.12 }}\n            x1={s.x}\n            x2={s.x}\n            y1={NOTCH + 5}\n            y2={height - NOTCH - 5}\n          />\n        </svg>\n        <div className=\"absolute inset-y-0 left-0\" style={{ right: BLEED }}>\n          {main}\n        </div>\n      </div>\n      <div\n        className=\"absolute inset-y-0 right-0\"\n        ref={stubScope}\n        style={{ width: stubWidth + BLEED, transformOrigin: `${BLEED}px 100%` }}\n      >\n        <svg\n          aria-hidden\n          className={svgClass}\n          height={height}\n          viewBox={`${s.x - BLEED} 0 ${stubWidth + BLEED} ${height}`}\n          width={stubWidth + BLEED}\n        >\n          <path className=\"fill-card\" d={s.stubFill} />\n          <path className=\"fill-none stroke-border\" d={s.stubEdge} />\n          <motion.path\n            animate={{ opacity: torn ? 1 : 0 }}\n            className=\"fill-none stroke-border\"\n            d={s.seam}\n            initial={false}\n            transition={seamTransition}\n          />\n        </svg>\n        <div className=\"absolute inset-y-0 right-0\" style={{ width: stubWidth }}>\n          {stub}\n        </div>\n      </div>\n    </div>\n  );\n}\n"
    }
  ]
}