{
  "name": "pull-cord",
  "type": "registry:component",
  "dependencies": [],
  "files": [
    {
      "type": "registry:component",
      "content": "\"use client\";\n\n/**\n * @author: @dorianbaffier\n * @description: Pull Cord\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 PointerEvent,\n  useCallback,\n  useEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nconst WIDTH = 288;\nconst HEIGHT = 360;\n// Where the cord hangs from, under the lamp shade.\nconst ANCHOR = { x: WIDTH / 2 + 36, y: 116 };\n// The cord is a chain of points joined by fixed-length links.\nconst LINKS = 12;\nconst LINK = 8.5;\n// Simulation, in px and seconds. A fixed step keeps the chain stable on any\n// refresh rate.\nconst STEP = 1 / 120;\nconst GRAVITY = 1400;\nconst DAMPING = 0.992;\n// Fastest a link may move in one step. Letting go of a stretched cord would\n// otherwise fling it up into the shade.\nconst MAX_SPEED = 4;\n// A cord has a little stiffness: two links apart, points never get closer\n// than this, so it bends in curves instead of folding into kinks.\nconst BEND_MIN = LINK * 1.9;\nconst BEND_STIFFNESS = 0.8;\nconst ITERATIONS = 14;\n// How far past its length the cord has to be pulled to click the switch,\n// and how far it can stretch at all. The stretch flattens out, so it slows\n// down instead of stopping at a wall.\nconst CLICK_PULL = 22;\nconst MAX_STRETCH = 34;\n// Below this movement per step for a while, the chain is at rest and the\n// loop stops.\nconst REST_EPSILON = 0.015;\nconst REST_STEPS = 24;\n\nfunction damp(distance: number, max: number) {\n  return max * (1 - Math.exp(-distance / max));\n}\n\nfunction haptic() {\n  if (typeof navigator !== \"undefined\" && \"vibrate\" in navigator) {\n    navigator.vibrate(8);\n  }\n}\n\n/**\n * Move points a and b towards `length` apart. A bend constraint only ever\n * pushes apart, and only part of the way each pass: stiff, not rigid.\n */\nfunction relax(\n  chain: { xs: Float64Array; ys: Float64Array; held: number },\n  a: number,\n  b: number,\n  length: number,\n  bend: boolean\n) {\n  const { xs, ys, held } = chain;\n  const aFixed = a === 0 || a === held;\n  const bFixed = b === held;\n  if (aFixed && bFixed) {\n    return;\n  }\n  const dx = xs[b] - xs[a];\n  const dy = ys[b] - ys[a];\n  const dist = Math.hypot(dx, dy) || 0.0001;\n  if (bend && dist >= length) {\n    return;\n  }\n  const diff = ((dist - length) / dist) * (bend ? BEND_STIFFNESS : 1);\n  let wa = 0.5;\n  if (aFixed) {\n    wa = 0;\n  } else if (bFixed) {\n    wa = 1;\n  }\n  xs[a] += dx * diff * wa;\n  ys[a] += dy * diff * wa;\n  xs[b] -= dx * diff * (1 - wa);\n  ys[b] -= dy * diff * (1 - wa);\n}\n\n/** A smooth curve through the chain's points. */\nfunction cordPath(xs: Float64Array, ys: Float64Array) {\n  let d = `M${xs[0].toFixed(2)} ${ys[0].toFixed(2)}`;\n  for (let i = 1; i < xs.length - 1; i++) {\n    const mx = (xs[i] + xs[i + 1]) / 2;\n    const my = (ys[i] + ys[i + 1]) / 2;\n    d += ` Q${xs[i].toFixed(2)} ${ys[i].toFixed(2)} ${mx.toFixed(2)} ${my.toFixed(2)}`;\n  }\n  const n = xs.length - 1;\n  d += ` L${xs[n].toFixed(2)} ${ys[n].toFixed(2)}`;\n  return d;\n}\n\nfunction restingChain() {\n  const xs = new Float64Array(LINKS + 1);\n  const ys = new Float64Array(LINKS + 1);\n  for (let i = 0; i <= LINKS; i++) {\n    xs[i] = ANCHOR.x;\n    ys[i] = ANCHOR.y + i * LINK;\n  }\n  return { xs, ys };\n}\n\nconst REST = restingChain();\nconst REST_PATH = cordPath(REST.xs, REST.ys);\n\nexport interface PullCordProps {\n  /** Controlled state. */\n  checked?: boolean;\n  /** Initial state when uncontrolled. */\n  defaultChecked?: boolean;\n  onCheckedChange?: (checked: boolean) => void;\n  /** Accessible name of the switch. */\n  label?: string;\n  className?: string;\n}\n\nexport default function PullCord({\n  checked: checkedProp,\n  defaultChecked = true,\n  onCheckedChange,\n  label = \"Desk lamp\",\n  className,\n}: PullCordProps) {\n  const [internal, setInternal] = useState(defaultChecked);\n  const checked = checkedProp ?? internal;\n  const [focused, setFocused] = useState(false);\n  const [grabbing, setGrabbing] = useState(false);\n\n  const svgRef = useRef<SVGSVGElement>(null);\n  const cordRef = useRef<SVGPathElement>(null);\n  const hitRef = useRef<SVGPathElement>(null);\n  const beadRef = useRef<SVGGElement>(null);\n\n  const sim = useRef({\n    xs: Float64Array.from(REST.xs),\n    ys: Float64Array.from(REST.ys),\n    pxs: Float64Array.from(REST.xs),\n    pys: Float64Array.from(REST.ys),\n    raf: 0,\n    last: 0,\n    carry: 0,\n    still: 0,\n    // The link being held, and where the hand is holding it.\n    held: -1,\n    targetX: 0,\n    targetY: 0,\n    armed: true,\n    reduceMotion: false,\n  });\n\n  // Read from pointer handlers, which outlive the render they came from.\n  const latest = useRef<{ checked: boolean; toggle: (next: boolean) => void }>({\n    checked,\n    toggle: () => undefined,\n  });\n  const toggle = useCallback(\n    (next: boolean) => {\n      if (checkedProp === undefined) {\n        setInternal(next);\n      }\n      onCheckedChange?.(next);\n    },\n    [checkedProp, onCheckedChange]\n  );\n  latest.current = { checked, toggle };\n\n  const draw = useCallback(() => {\n    const { xs, ys } = sim.current;\n    const d = cordPath(xs, ys);\n    cordRef.current?.setAttribute(\"d\", d);\n    hitRef.current?.setAttribute(\"d\", d);\n    const n = LINKS;\n    const angle =\n      (Math.atan2(xs[n] - xs[n - 1], ys[n] - ys[n - 1]) * 180) / Math.PI;\n    beadRef.current?.setAttribute(\n      \"transform\",\n      `translate(${xs[n].toFixed(2)} ${ys[n].toFixed(2)}) rotate(${(-angle).toFixed(2)})`\n    );\n  }, []);\n\n  // Verlet: each free link keeps the velocity it had last step, plus\n  // gravity. The held link goes to the hand.\n  const integrate = useCallback(() => {\n    const s = sim.current;\n    const { xs, ys, pxs, pys } = s;\n    let moved = 0;\n    for (let i = 1; i <= LINKS; i++) {\n      if (i === s.held) {\n        continue;\n      }\n      let vx = (xs[i] - pxs[i]) * DAMPING;\n      let vy = (ys[i] - pys[i]) * DAMPING;\n      const speed = Math.hypot(vx, vy);\n      if (speed > MAX_SPEED) {\n        vx *= MAX_SPEED / speed;\n        vy *= MAX_SPEED / speed;\n      }\n      pxs[i] = xs[i];\n      pys[i] = ys[i];\n      xs[i] += vx;\n      ys[i] += vy + GRAVITY * STEP * STEP;\n      moved = Math.max(moved, Math.abs(vx) + Math.abs(vy));\n    }\n    if (s.held > 0) {\n      const i = s.held;\n      moved = Math.max(\n        moved,\n        Math.abs(s.targetX - xs[i]) + Math.abs(s.targetY - ys[i])\n      );\n      pxs[i] = xs[i];\n      pys[i] = ys[i];\n      xs[i] = s.targetX;\n      ys[i] = s.targetY;\n    }\n    return moved;\n  }, []);\n\n  // Pull each link back to its length, and keep points two links apart from\n  // folding too close. The anchor and the held link don't move; everything\n  // else shares the correction.\n  const constrain = useCallback(() => {\n    const s = sim.current;\n    for (let k = 0; k < ITERATIONS; k++) {\n      for (let i = 0; i < LINKS; i++) {\n        relax(s, i, i + 1, LINK, false);\n      }\n      for (let i = 0; i + 2 <= LINKS; i++) {\n        relax(s, i, i + 2, BEND_MIN, true);\n      }\n    }\n  }, []);\n\n  const step = useCallback(() => {\n    const moved = integrate();\n    constrain();\n    return moved;\n  }, [integrate, constrain]);\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      s.carry += elapsed;\n      let moved = 0;\n      while (s.carry >= STEP) {\n        moved = Math.max(moved, step());\n        s.carry -= STEP;\n      }\n      draw();\n      s.still = moved < REST_EPSILON ? s.still + 1 : 0;\n      if (s.held > 0 || s.still < REST_STEPS) {\n        s.raf = requestAnimationFrame(tick);\n      } else {\n        s.last = 0;\n      }\n    },\n    [draw, step]\n  );\n\n  const wake = useCallback(() => {\n    const s = sim.current;\n    s.still = 0;\n    if (!s.raf) {\n      s.raf = requestAnimationFrame(tick);\n    }\n  }, [tick]);\n\n  useEffect(() => {\n    const s = sim.current;\n    s.reduceMotion = window.matchMedia(\n      \"(prefers-reduced-motion: reduce)\"\n    ).matches;\n    return () => cancelAnimationFrame(s.raf);\n  }, []);\n\n  const localPoint = (event: PointerEvent) => {\n    const rect = svgRef.current?.getBoundingClientRect();\n    if (!rect) {\n      return { x: 0, y: 0 };\n    }\n    // The drawing is laid out in its own units; map the pointer into them.\n    return {\n      x: ((event.clientX - rect.left) / rect.width) * WIDTH,\n      y: ((event.clientY - rect.top) / rect.height) * HEIGHT,\n    };\n  };\n\n  // The hand can pull the cord a little past its length, with growing\n  // resistance, but never pull it off the anchor.\n  const reach = (index: number, x: number, y: number) => {\n    const dx = x - ANCHOR.x;\n    const dy = y - ANCHOR.y;\n    const dist = Math.hypot(dx, dy) || 0.0001;\n    const length = index * LINK;\n    if (dist <= length) {\n      return { x, y, pull: 0 };\n    }\n    const allowed = length + damp(dist - length, MAX_STRETCH);\n    return {\n      x: ANCHOR.x + (dx / dist) * allowed,\n      y: ANCHOR.y + (dy / dist) * allowed,\n      pull: dy > 0 ? (allowed - length) * (dy / dist) : 0,\n    };\n  };\n\n  const handlePointerDown = (event: PointerEvent<SVGElement>) => {\n    const s = sim.current;\n    if (s.held > 0) {\n      return;\n    }\n    if (event.pointerType === \"mouse\" && event.button !== 0) {\n      return;\n    }\n    event.preventDefault();\n    const point = localPoint(event);\n    // Hold the link nearest to where the cord was grabbed.\n    let nearest = LINKS;\n    let best = Number.POSITIVE_INFINITY;\n    for (let i = 1; i <= LINKS; i++) {\n      const d = Math.hypot(s.xs[i] - point.x, s.ys[i] - point.y);\n      if (d < best) {\n        best = d;\n        nearest = i;\n      }\n    }\n    svgRef.current?.setPointerCapture(event.pointerId);\n    s.held = nearest;\n    s.targetX = s.xs[nearest];\n    s.targetY = s.ys[nearest];\n    s.armed = true;\n    setGrabbing(true);\n    wake();\n  };\n\n  const handlePointerMove = (event: PointerEvent<SVGSVGElement>) => {\n    const s = sim.current;\n    if (s.held < 0) {\n      return;\n    }\n    const point = localPoint(event);\n    const target = reach(s.held, point.x, point.y);\n    s.targetX = target.x;\n    s.targetY = target.y;\n    // The switch clicks once per pull, as soon as the cord is pulled far\n    // enough, not on release. It re-arms when the cord eases back.\n    if (s.armed && target.pull > CLICK_PULL) {\n      s.armed = false;\n      haptic();\n      latest.current.toggle(!latest.current.checked);\n    } else if (!s.armed && target.pull < CLICK_PULL * 0.4) {\n      s.armed = true;\n    }\n    wake();\n  };\n\n  const handlePointerUp = () => {\n    const s = sim.current;\n    if (s.held < 0) {\n      return;\n    }\n    s.held = -1;\n    setGrabbing(false);\n    if (s.reduceMotion) {\n      // No swinging back: the cord drops straight to rest.\n      s.xs.set(REST.xs);\n      s.ys.set(REST.ys);\n      s.pxs.set(REST.xs);\n      s.pys.set(REST.ys);\n      draw();\n      return;\n    }\n    wake();\n  };\n\n  const handleKeyToggle = () => {\n    toggle(!checked);\n    const s = sim.current;\n    if (!s.reduceMotion) {\n      // A small tug on the bead, so the cord answers the key too.\n      s.pys[LINKS] = s.ys[LINKS] - 5;\n      wake();\n    }\n  };\n\n  return (\n    <div\n      className={cn(\n        \"relative select-none overflow-hidden rounded-3xl border transition-colors duration-300 ease-out\",\n        checked\n          ? \"border-zinc-200 bg-white text-zinc-900\"\n          : \"border-zinc-800 bg-zinc-950 text-zinc-100\",\n        className\n      )}\n      style={{ width: WIDTH, height: HEIGHT }}\n    >\n      <svg\n        aria-hidden=\"true\"\n        className={cn(\n          \"absolute inset-0 touch-none\",\n          grabbing ? \"cursor-grabbing\" : \"cursor-default\"\n        )}\n        height={HEIGHT}\n        onLostPointerCapture={handlePointerUp}\n        onPointerCancel={handlePointerUp}\n        onPointerMove={handlePointerMove}\n        onPointerUp={handlePointerUp}\n        ref={svgRef}\n        viewBox={`0 0 ${WIDTH} ${HEIGHT}`}\n        width={WIDTH}\n      >\n        {/* Ceiling wire and shade. */}\n        <line\n          className={cn(\n            \"transition-[stroke] duration-300 ease-out\",\n            checked ? \"stroke-zinc-300\" : \"stroke-zinc-700\"\n          )}\n          strokeWidth={1.5}\n          x1={WIDTH / 2}\n          x2={WIDTH / 2}\n          y1={0}\n          y2={62}\n        />\n        <path\n          className={cn(\n            \"transition-[fill] duration-300 ease-out\",\n            checked ? \"fill-zinc-900\" : \"fill-zinc-800\"\n          )}\n          d={`M${WIDTH / 2 - 14} 62 h28 l38 50 q2 4 -3 4 h-98 q-5 0 -3 -4 Z`}\n        />\n        {/* The bulb, lit or not. */}\n        <path\n          className={cn(\n            \"transition-[fill] duration-300 ease-out\",\n            checked ? \"fill-amber-300\" : \"fill-zinc-700\"\n          )}\n          d={`M${WIDTH / 2 - 15} 116 a15 13 0 0 0 30 0 Z`}\n        />\n\n        <path\n          className={cn(\n            \"transition-[stroke] duration-300 ease-out\",\n            checked ? \"stroke-zinc-400\" : \"stroke-zinc-600\"\n          )}\n          d={REST_PATH}\n          fill=\"none\"\n          ref={cordRef}\n          strokeLinecap=\"round\"\n          strokeWidth={1.5}\n        />\n        {/* A wider invisible copy of the cord, so it's easy to grab. */}\n        <path\n          className={cn(!grabbing && \"cursor-grab\")}\n          d={REST_PATH}\n          fill=\"none\"\n          onPointerDown={handlePointerDown}\n          pointerEvents=\"stroke\"\n          ref={hitRef}\n          stroke=\"transparent\"\n          strokeWidth={22}\n        />\n        <g\n          className={cn(!grabbing && \"cursor-grab\")}\n          onPointerDown={handlePointerDown}\n          ref={beadRef}\n          transform={`translate(${ANCHOR.x} ${ANCHOR.y + LINKS * LINK})`}\n        >\n          <circle fill=\"transparent\" r={20} />\n          {focused && (\n            <rect\n              className=\"fill-none stroke-zinc-400\"\n              height={30}\n              rx={9}\n              strokeWidth={2}\n              width={20}\n              x={-10}\n              y={-4}\n            />\n          )}\n          <rect\n            className={cn(\n              \"transition-[fill] duration-300 ease-out\",\n              checked ? \"fill-zinc-800\" : \"fill-zinc-300\"\n            )}\n            height={20}\n            rx={5}\n            width={10}\n            x={-5}\n            y={1}\n          />\n        </g>\n      </svg>\n\n      <div className=\"pointer-events-none absolute inset-x-0 bottom-0 p-6\">\n        <p className=\"font-medium text-sm\">{label}</p>\n        <p className=\"text-sm text-zinc-500\">\n          {checked ? \"On\" : \"Off\"} · pull the cord\n        </p>\n      </div>\n\n      <button\n        aria-checked={checked}\n        aria-label={label}\n        className=\"sr-only\"\n        onBlur={() => setFocused(false)}\n        onClick={handleKeyToggle}\n        onFocus={(event) =>\n          setFocused(event.currentTarget.matches(\":focus-visible\"))\n        }\n        role=\"switch\"\n        type=\"button\"\n      />\n    </div>\n  );\n}\n",
      "path": "/components/kokonutui/pull-cord.tsx",
      "target": "components/kokonutui/pull-cord.tsx"
    }
  ]
}