{
  "name": "tear-button",
  "type": "registry:component",
  "dependencies": [],
  "files": [
    {
      "type": "registry:component",
      "content": "\"use client\";\n\n/**\n * @author: @dorianbaffier\n * @description: Tear Button\n * @version: 1.0.0\n * @date: 2026-10-14\n * @license: MIT\n * @website: https://kokonutui.com\n * @github: https://github.com/kokonut-labs/kokonutui\n */\n\nimport {\n  type KeyboardEvent,\n  type PointerEvent,\n  type ReactNode,\n  useCallback,\n  useEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nconst HEIGHT = 68;\nconst BODY_WIDTH = 236;\nconst STUB_WIDTH = 76;\n// How far (deg) the stub swings, about the foot of the perforation, before\n// the last fibre gives.\nconst TEAR_ANGLE = 34;\n// The perforation tears hole by hole: each one holds for a moment, then\n// gives. That stick-slip is what makes it feel like paper.\nconst HOLES = 7;\nconst HOLD = 0.55;\nconst HOLD_SHARE = 0.3;\n// The stub's swing chases the hand on a stiff spring, so the slips snap.\nconst SWING_STIFFNESS = 900;\nconst SWING_DAMPING = 42;\nconst RETURN_STIFFNESS = 520;\nconst RETURN_DAMPING = 22;\n// Once free: px and seconds.\nconst GRAVITY = 2600;\nconst FALL_DISTANCE = 220;\nconst STEP = 1 / 120;\n// Ragged edge of the tear, as offsets in px along the perforation.\nconst FIBRES = [\n  1.6, 0.4, 2.2, 0.9, 1.8, 0.2, 2.4, 1.1, 0.5, 2, 1.4, 0.3, 2.3, 0.8, 1.7, 0.1,\n  2.1, 1.2, 0.6, 1.9,\n];\nconst EASE_OUT = \"cubic-bezier(0.23, 1, 0.32, 1)\";\n\ntype Phase = \"idle\" | \"tearing\" | \"flying\" | \"torn\";\n\nfunction clamp(value: number, min: number, max: number) {\n  return Math.min(Math.max(value, min), max);\n}\n\nfunction haptic(ms: number) {\n  if (typeof navigator !== \"undefined\" && \"vibrate\" in navigator) {\n    navigator.vibrate(ms);\n  }\n}\n\n/** Raw pull (0..1+) to how far the tear has run, holding at each hole. */\nfunction stickSlip(pull: number) {\n  const scaled = clamp(pull, 0, 1) * HOLES;\n  const hole = Math.min(Math.floor(scaled), HOLES - 1);\n  const f = scaled - hole;\n  const given =\n    f < HOLD\n      ? (f / HOLD) * HOLD_SHARE\n      : HOLD_SHARE + ((f - HOLD) / (1 - HOLD)) * (1 - HOLD_SHARE);\n  return (hole + given) / HOLES;\n}\n\n/**\n * One side of the tear as a clip-path: ragged down to `tip` (px from the\n * top), straight below. `side` is 1 for the body's right edge, -1 for the\n * stub's left edge.\n */\nfunction tornEdge(tip: number, width: number, side: 1 | -1) {\n  const points: string[] = [];\n  const edge = side === 1 ? width : 0;\n  const far = side === 1 ? 0 : width;\n  points.push(`${far}px 0px`);\n  const rows = Math.ceil((tip / HEIGHT) * FIBRES.length);\n  for (let k = 0; k <= rows; k++) {\n    const y = Math.min((k / FIBRES.length) * HEIGHT, tip);\n    const x = edge - side * FIBRES[k % FIBRES.length];\n    points.push(`${x.toFixed(1)}px ${y.toFixed(1)}px`);\n  }\n  points.push(`${edge}px ${tip.toFixed(1)}px`, `${edge}px ${HEIGHT}px`);\n  points.push(`${far}px ${HEIGHT}px`);\n  return `polygon(${points.join(\",\")})`;\n}\n\n// Ticket notches, cut where the perforation meets the top and bottom edges.\nconst NOTCH = 7;\nconst bodyMask = `radial-gradient(circle at 100% 0, #0000 ${NOTCH}px, #000 ${NOTCH + 0.5}px) top / 100% 51% no-repeat, radial-gradient(circle at 100% 100%, #0000 ${NOTCH}px, #000 ${NOTCH + 0.5}px) bottom / 100% 51% no-repeat`;\nconst stubMask = `radial-gradient(circle at 0 0, #0000 ${NOTCH}px, #000 ${NOTCH + 0.5}px) top / 100% 51% no-repeat, radial-gradient(circle at 0 100%, #0000 ${NOTCH}px, #000 ${NOTCH + 0.5}px) bottom / 100% 51% no-repeat`;\n\nconst TONES = {\n  default: {\n    paper: \"bg-zinc-900 text-white dark:bg-zinc-100 dark:text-zinc-900\",\n    chip: \"bg-white/10 dark:bg-zinc-900/10\",\n    chipDone: \"bg-white text-zinc-900 dark:bg-zinc-900 dark:text-white\",\n    sub: \"text-white/55 dark:text-zinc-900/55\",\n    stub: \"text-white/60 dark:text-zinc-900/60\",\n    perforation: \"border-white/30 dark:border-zinc-900/30\",\n  },\n  muted: {\n    paper: \"bg-zinc-100 text-zinc-900 dark:bg-zinc-800 dark:text-zinc-100\",\n    chip: \"bg-zinc-900/5 dark:bg-white/10\",\n    chipDone: \"bg-zinc-900 text-white dark:bg-white dark:text-zinc-900\",\n    sub: \"text-zinc-500 dark:text-zinc-400\",\n    stub: \"text-zinc-500 dark:text-zinc-400\",\n    perforation: \"border-zinc-900/20 dark:border-white/25\",\n  },\n  danger: {\n    paper: \"bg-red-600 text-white\",\n    chip: \"bg-white/15\",\n    chipDone: \"bg-white text-red-600\",\n    sub: \"text-white/70\",\n    stub: \"text-white/75\",\n    perforation: \"border-white/40\",\n  },\n} as const;\n\nconst TRASH = (\n  <svg\n    aria-hidden=\"true\"\n    className=\"size-4\"\n    fill=\"none\"\n    stroke=\"currentColor\"\n    strokeLinecap=\"round\"\n    strokeLinejoin=\"round\"\n    strokeWidth={2}\n    viewBox=\"0 0 24 24\"\n  >\n    <path d=\"M4 7h16M10 11v6M14 11v6M6 7l1 12a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-12M9 7V4h6v3\" />\n  </svg>\n);\n\nexport interface TearButtonProps {\n  /** Paper colour. */\n  tone?: keyof typeof TONES;\n  /** Shown in the round chip until the stub is torn; a trash can by default. */\n  icon?: ReactNode;\n  label?: string;\n  description?: string;\n  stubLabel?: string;\n  confirmedLabel?: string;\n  confirmedDescription?: string;\n  /** Called once the stub comes away. */\n  onConfirm?: () => void;\n  /** Print a fresh stub this many ms after confirming. Off when 0. */\n  resetAfter?: number;\n  className?: string;\n}\n\nexport default function TearButton({\n  tone = \"default\",\n  icon = TRASH,\n  label = \"Delete workspace\",\n  description = \"Tear off the stub to confirm\",\n  stubLabel = \"Tear\",\n  confirmedLabel = \"Workspace deleted\",\n  confirmedDescription = \"Kept in trash for 30 days\",\n  onConfirm,\n  resetAfter = 2400,\n  className,\n}: TearButtonProps) {\n  const [phase, setPhase] = useState<Phase>(\"idle\");\n  const [generation, setGeneration] = useState(0);\n  const rootRef = useRef<HTMLDivElement>(null);\n  const bodyRef = useRef<HTMLDivElement>(null);\n  const stubRef = useRef<HTMLButtonElement>(null);\n\n  const sim = useRef({\n    angle: 0,\n    speed: 0,\n    target: 0,\n    held: false,\n    grabAngle: 0,\n    hole: 0,\n    raf: 0,\n    last: 0,\n    carry: 0,\n    // Free flight, once torn.\n    x: 0,\n    y: 0,\n    vx: 0,\n    vy: 0,\n    spin: 0,\n    // The hand's recent movement, for the throw.\n    px: 0,\n    py: 0,\n    pt: 0,\n    pvx: 0,\n    pvy: 0,\n    auto: 0,\n    flying: false,\n    reduceMotion: false,\n  });\n\n  const latest = useRef({ onConfirm });\n  latest.current = { onConfirm };\n\n  const hinge = useCallback(() => {\n    const rect = rootRef.current?.getBoundingClientRect();\n    return rect\n      ? { x: rect.left + BODY_WIDTH, y: rect.top + HEIGHT }\n      : { x: 0, y: 0 };\n  }, []);\n\n  const drawTear = useCallback(() => {\n    const s = sim.current;\n    const tip = clamp(s.angle / TEAR_ANGLE, 0, 1) * HEIGHT;\n    const stub = stubRef.current;\n    const body = bodyRef.current;\n    if (body) {\n      body.style.clipPath = tip > 0.5 ? tornEdge(tip, BODY_WIDTH, 1) : \"\";\n    }\n    if (stub) {\n      stub.style.clipPath = tip > 0.5 ? tornEdge(tip, STUB_WIDTH, -1) : \"\";\n      stub.style.transform = `rotate(${s.angle.toFixed(2)}deg)`;\n    }\n  }, []);\n\n  const drawFlight = useCallback(() => {\n    const s = sim.current;\n    const stub = stubRef.current;\n    if (!stub) {\n      return;\n    }\n    const fade = clamp(\n      1 - (s.y - FALL_DISTANCE * 0.5) / (FALL_DISTANCE * 0.5),\n      0,\n      1\n    );\n    stub.style.transform = `translate(${s.x.toFixed(1)}px, ${s.y.toFixed(1)}px) rotate(${s.angle.toFixed(1)}deg)`;\n    stub.style.opacity = fade.toFixed(3);\n  }, []);\n\n  const detach = useCallback(() => {\n    const s = sim.current;\n    s.held = false;\n    haptic(14);\n    // The stub leaves with the swing it had, plus the hand's throw.\n    const rad = (s.angle * Math.PI) / 180;\n    const tangential = ((s.speed * Math.PI) / 180) * HEIGHT;\n    s.x = 0;\n    s.y = 0;\n    s.vx = Math.cos(rad) * tangential + clamp(s.pvx, -900, 900) * 0.6 + 90;\n    s.vy = Math.sin(rad) * tangential + clamp(s.pvy, -900, 900) * 0.6 - 120;\n    s.spin = s.speed * 0.6 + 140;\n    s.flying = true;\n    if (stubRef.current) {\n      stubRef.current.style.transition = \"none\";\n    }\n    setPhase(\"flying\");\n    latest.current.onConfirm?.();\n  }, []);\n\n  // While attached: the swing chases its target, held or springing home.\n  const stepSwing = useCallback(() => {\n    const s = sim.current;\n    const stiffness = s.held || s.auto ? SWING_STIFFNESS : RETURN_STIFFNESS;\n    const damping = s.held || s.auto ? SWING_DAMPING : RETURN_DAMPING;\n    const force = (s.target - s.angle) * stiffness - s.speed * damping;\n    s.speed += force * STEP;\n    s.angle += s.speed * STEP;\n    // A tick for each hole that gives.\n    const hole = Math.floor(clamp(s.angle / TEAR_ANGLE, 0, 1) * HOLES);\n    if (hole > s.hole) {\n      haptic(3);\n    }\n    s.hole = hole;\n    return s.angle >= TEAR_ANGLE * 0.995;\n  }, []);\n\n  const stepFlight = useCallback(() => {\n    const s = sim.current;\n    s.vy += GRAVITY * STEP;\n    s.x += s.vx * STEP;\n    s.y += s.vy * STEP;\n    s.angle += s.spin * STEP;\n    return s.y > FALL_DISTANCE;\n  }, []);\n\n  // Runs the fixed steps owed since the last frame; true once this phase is\n  // over (the stub tore through, or it has fallen out of sight).\n  const advance = useCallback(\n    (elapsed: number) => {\n      const s = sim.current;\n      s.carry += elapsed;\n      let finished = false;\n      while (s.carry >= STEP && !finished) {\n        s.carry -= STEP;\n        if (s.flying) {\n          finished = stepFlight();\n        } else if (s.auto) {\n          s.target = Math.min(s.target + STEP * 60, TEAR_ANGLE * 1.05);\n          finished = stepSwing();\n        } else {\n          finished = stepSwing() && s.held;\n        }\n      }\n      return finished;\n    },\n    [stepFlight, stepSwing]\n  );\n\n  const tick = useCallback(\n    (now: number) => {\n      const s = sim.current;\n      s.raf = 0;\n      const elapsed = s.last ? Math.min((now - s.last) / 1000, 0.05) : STEP;\n      s.last = now;\n      const { flying } = s;\n      const finished = advance(elapsed);\n      if (flying) {\n        drawFlight();\n        if (finished) {\n          s.last = 0;\n          setPhase(\"torn\");\n          return;\n        }\n        s.raf = requestAnimationFrame(tick);\n        return;\n      }\n      drawTear();\n      if (finished) {\n        s.auto = 0;\n        detach();\n        s.raf = requestAnimationFrame(tick);\n        return;\n      }\n      const resting =\n        !(s.held || s.auto) &&\n        Math.abs(s.angle) < 0.02 &&\n        Math.abs(s.speed) < 0.05;\n      if (!resting) {\n        s.raf = requestAnimationFrame(tick);\n        return;\n      }\n      s.angle = 0;\n      s.speed = 0;\n      s.last = 0;\n      drawTear();\n      setPhase(\"idle\");\n    },\n    [advance, detach, drawFlight, drawTear]\n  );\n\n  const wake = useCallback(() => {\n    const s = sim.current;\n    if (!s.raf) {\n      s.raf = requestAnimationFrame(tick);\n    }\n  }, [tick]);\n\n  useEffect(() => {\n    const s = sim.current;\n    const query = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    s.reduceMotion = query.matches;\n    const onChange = () => {\n      s.reduceMotion = query.matches;\n    };\n    query.addEventListener(\"change\", onChange);\n    return () => {\n      query.removeEventListener(\"change\", onChange);\n      cancelAnimationFrame(s.raf);\n      s.raf = 0;\n    };\n  }, []);\n\n  // A fresh stub, a little while after the last one came away.\n  useEffect(() => {\n    if (phase !== \"torn\" || !resetAfter) {\n      return;\n    }\n    const id = window.setTimeout(() => {\n      const s = sim.current;\n      s.angle = 0;\n      s.speed = 0;\n      s.target = 0;\n      s.hole = 0;\n      s.flying = false;\n      setGeneration((g) => g + 1);\n      setPhase(\"idle\");\n    }, resetAfter);\n    return () => window.clearTimeout(id);\n  }, [phase, resetAfter]);\n\n  // In reduced motion the stub doesn't fall: it fades where it tore.\n  useEffect(() => {\n    if (phase !== \"flying\" || !sim.current.reduceMotion) {\n      return;\n    }\n    const s = sim.current;\n    cancelAnimationFrame(s.raf);\n    s.raf = 0;\n    s.last = 0;\n    const stub = stubRef.current;\n    if (stub) {\n      stub.style.transition = `opacity 180ms ${EASE_OUT}`;\n      stub.style.opacity = \"0\";\n    }\n    const id = window.setTimeout(() => setPhase(\"torn\"), 180);\n    return () => window.clearTimeout(id);\n  }, [phase]);\n\n  const handlePointerDown = (event: PointerEvent<HTMLButtonElement>) => {\n    const s = sim.current;\n    if (phase === \"flying\" || phase === \"torn\") {\n      return;\n    }\n    if (event.pointerType === \"mouse\" && event.button !== 0) {\n      return;\n    }\n    event.preventDefault();\n    event.currentTarget.setPointerCapture(event.pointerId);\n    const h = hinge();\n    s.grabAngle =\n      (Math.atan2(event.clientY - h.y, event.clientX - h.x) * 180) / Math.PI -\n      s.angle;\n    s.held = true;\n    s.px = event.clientX;\n    s.py = event.clientY;\n    s.pt = event.timeStamp;\n    s.pvx = 0;\n    s.pvy = 0;\n    setPhase(\"tearing\");\n    wake();\n  };\n\n  const handlePointerMove = (event: PointerEvent<HTMLButtonElement>) => {\n    const s = sim.current;\n    if (!s.held) {\n      return;\n    }\n    const dt = event.timeStamp - s.pt;\n    if (dt > 0) {\n      s.pvx = s.pvx * 0.5 + ((event.clientX - s.px) / dt) * 500;\n      s.pvy = s.pvy * 0.5 + ((event.clientY - s.py) / dt) * 500;\n    }\n    s.px = event.clientX;\n    s.py = event.clientY;\n    s.pt = event.timeStamp;\n    const h = hinge();\n    const raw =\n      (Math.atan2(event.clientY - h.y, event.clientX - h.x) * 180) / Math.PI -\n      s.grabAngle;\n    // Pushing the stub back into the body only gives a little.\n    const pull = raw < 0 ? raw * 0.15 : raw;\n    s.target =\n      pull >= TEAR_ANGLE\n        ? TEAR_ANGLE * 1.05\n        : stickSlip(Math.max(pull, 0) / TEAR_ANGLE) * TEAR_ANGLE +\n          Math.min(pull, 0);\n    wake();\n  };\n\n  const handlePointerUp = () => {\n    const s = sim.current;\n    if (!s.held) {\n      return;\n    }\n    s.held = false;\n    // Not torn through: the stub swings back and the perforation holds.\n    s.target = 0;\n    wake();\n  };\n\n  const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {\n    if (event.key !== \"Enter\" && event.key !== \" \") {\n      return;\n    }\n    event.preventDefault();\n    const s = sim.current;\n    if (phase !== \"idle\" || event.repeat) {\n      return;\n    }\n    // From the keyboard the stub tears itself, hole by hole.\n    s.auto = 1;\n    s.target = 0;\n    setPhase(\"tearing\");\n    wake();\n  };\n\n  const done = phase === \"flying\" || phase === \"torn\";\n  const colors = TONES[tone];\n\n  return (\n    <div\n      className={cn(\"relative select-none\", className)}\n      ref={rootRef}\n      style={{ width: BODY_WIDTH + STUB_WIDTH, height: HEIGHT }}\n    >\n      <div\n        className={cn(\n          \"absolute inset-y-0 left-0 flex items-center gap-3 rounded-l-2xl px-4\",\n          colors.paper\n        )}\n        ref={bodyRef}\n        style={{ width: BODY_WIDTH, mask: bodyMask, WebkitMask: bodyMask }}\n      >\n        <span\n          aria-hidden=\"true\"\n          className={cn(\n            \"flex size-9 shrink-0 items-center justify-center rounded-full transition-colors duration-300 ease-out\",\n            done ? colors.chipDone : colors.chip\n          )}\n        >\n          {done ? (\n            <svg\n              aria-hidden=\"true\"\n              className=\"size-4\"\n              fill=\"none\"\n              stroke=\"currentColor\"\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n              strokeWidth={2}\n              viewBox=\"0 0 24 24\"\n            >\n              <path d=\"M5 12.5l4.5 4.5L19 7.5\" />\n            </svg>\n          ) : (\n            icon\n          )}\n        </span>\n        <span className=\"relative grid min-w-0 flex-1\">\n          {[\n            { key: \"ask\", on: !done, title: label, sub: description },\n            {\n              key: \"done\",\n              on: done,\n              title: confirmedLabel,\n              sub: confirmedDescription,\n            },\n          ].map((line) => (\n            <span\n              aria-hidden={!line.on}\n              className={cn(\n                \"col-start-1 row-start-1 min-w-0 transition-[opacity,filter,translate] duration-300 ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-opacity\",\n                line.on\n                  ? \"translate-y-0 opacity-100 blur-0\"\n                  : \"pointer-events-none translate-y-1 opacity-0 blur-[3px]\"\n              )}\n              key={line.key}\n            >\n              <span className=\"block truncate font-medium text-sm\">\n                {line.title}\n              </span>\n              <span className={cn(\"block truncate text-xs\", colors.sub)}>\n                {line.sub}\n              </span>\n            </span>\n          ))}\n        </span>\n      </div>\n\n      {phase === \"torn\" ? null : (\n        <button\n          aria-label={`${label}: ${description}`}\n          className={cn(\n            \"absolute inset-y-0 flex touch-none flex-col items-center justify-center gap-1 rounded-r-2xl outline-none focus-visible:ring-2 focus-visible:ring-zinc-400 focus-visible:ring-offset-2 dark:ring-offset-zinc-950\",\n            colors.paper,\n            colors.stub,\n            \"starting:-translate-x-3 starting:opacity-0 transition-[opacity,translate] duration-300 ease-[cubic-bezier(0.23,1,0.32,1)]\",\n            phase === \"tearing\" ? \"cursor-grabbing\" : \"cursor-grab\"\n          )}\n          // A new stub per tear, so no inline style from the last one lingers.\n          key={generation}\n          onKeyDown={handleKeyDown}\n          onLostPointerCapture={handlePointerUp}\n          onPointerCancel={handlePointerUp}\n          onPointerDown={handlePointerDown}\n          onPointerMove={handlePointerMove}\n          onPointerUp={handlePointerUp}\n          ref={stubRef}\n          style={{\n            left: BODY_WIDTH,\n            width: STUB_WIDTH,\n            mask: stubMask,\n            WebkitMask: stubMask,\n            transformOrigin: \"0 100%\",\n          }}\n          type=\"button\"\n        >\n          {/* The perforation. */}\n          <span\n            aria-hidden=\"true\"\n            className={cn(\n              \"absolute inset-y-2.5 left-0 border-l-2 border-dotted\",\n              colors.perforation\n            )}\n          />\n          <svg\n            aria-hidden=\"true\"\n            className=\"size-4\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n            strokeWidth={2}\n            viewBox=\"0 0 24 24\"\n          >\n            <path d=\"M6 6c6 0 12 3 12 11M14 14l4 4 4-4\" />\n          </svg>\n          <span className=\"font-medium text-xs\">{stubLabel}</span>\n        </button>\n      )}\n      <span aria-live=\"polite\" className=\"sr-only\">\n        {done ? confirmedLabel : \"\"}\n      </span>\n    </div>\n  );\n}\n",
      "path": "/components/kokonutui/tear-button.tsx",
      "target": "components/kokonutui/tear-button.tsx"
    }
  ]
}