{
  "name": "slide-to-confirm",
  "type": "registry:component",
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "files": [
    {
      "type": "registry:component",
      "content": "\"use client\";\n\n/**\n * @author: @dorianbaffier\n * @description: Slide to Confirm\n * @version: 1.0.0\n * @date: 2026-10-21\n * @license: MIT\n * @website: https://kokonutui.com\n * @github: https://github.com/kokonut-labs/kokonutui\n */\n\nimport { ArrowRight, Check } from \"lucide-react\";\nimport {\n  AnimatePresence,\n  animate,\n  motion,\n  useMotionValue,\n  useReducedMotion,\n  useTransform,\n} from \"motion/react\";\nimport {\n  type MouseEvent,\n  type PointerEvent,\n  useEffect,\n  useLayoutEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nconst EASE_OUT = [0.23, 1, 0.32, 1] as const;\nconst EASE_IN_OUT = [0.65, 0, 0.35, 1] as const;\nconst HEIGHT = 56;\nconst INSET = 4;\nconst THUMB = HEIGHT - INSET * 2;\n/** Share of the track the thumb must cross before letting go commits. */\nconst COMMIT_AT = 0.9;\n/** A flick this fast (px/s) commits from FLICK_FROM of the way along. */\nconst FLICK_SPEED = 900;\nconst FLICK_FROM = 0.45;\n/** How far past either end the thumb stretches, at most. */\nconst OVERDRAG = 8;\n/** A press that moves less than this is a tap. */\nconst TAP_SLOP = 4;\n\ntype Phase = \"idle\" | \"dragging\" | \"confirming\" | \"done\";\n\nexport interface SlideToConfirmProps {\n  /** What sliding does, e.g. \"Slide to pay $48.00\". */\n  label?: string;\n  /** Read out while `onConfirm` runs. */\n  confirmingLabel?: string;\n  /** Shown once it's done, e.g. \"Paid\". */\n  doneLabel?: string;\n  /**\n   * The real work. Return a promise to keep the spinner up until it settles;\n   * a rejected promise sends the thumb back to the start.\n   */\n  onConfirm?: () => Promise<unknown> | unknown;\n  /** Go back to the start this many ms after it's done. `null` stays done. */\n  resetAfter?: number | null;\n  disabled?: boolean;\n  className?: string;\n}\n\nconst wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));\n\n// Past the ends the thumb follows less and less, so it stretches instead of\n// stopping dead against the edge.\nfunction band(raw: number, max: number) {\n  if (raw > max) {\n    return max + OVERDRAG * (1 - Math.exp(-(raw - max) / 40));\n  }\n  if (raw < 0) {\n    return -OVERDRAG * (1 - Math.exp(raw / 40));\n  }\n  return raw;\n}\n\nexport default function SlideToConfirm({\n  label = \"Slide to pay $48.00\",\n  confirmingLabel = \"Paying\",\n  doneLabel = \"Paid\",\n  onConfirm = () => wait(1200),\n  resetAfter = 2600,\n  disabled = false,\n  className,\n}: SlideToConfirmProps) {\n  const [phase, setPhase] = useState<Phase>(\"idle\");\n  const [armed, setArmed] = useState(false);\n  const reduceMotion = useReducedMotion();\n\n  const trackRef = useRef<HTMLDivElement>(null);\n  const dragRef = useRef({ pointer: 0, from: 0, moved: false });\n  const runRef = useRef(0);\n  const confirmRef = useRef(onConfirm);\n  confirmRef.current = onConfirm;\n\n  const x = useMotionValue(0);\n  const width = useMotionValue(0);\n  // 0: the ink ends at the thumb. 1: it fills the whole track, thumb and all.\n  const cover = useMotionValue(0);\n\n  const max = () => Math.max(0, width.get() - THUMB - INSET * 2);\n\n  // The ink fill runs from the left edge to just past the thumb, so the label\n  // turns white as the thumb wipes over it. Once committed it floods the rest.\n  const fillClip = useTransform(\n    [x, width, cover],\n    ([value, total, covered]: number[]) =>\n      `inset(0px ${(1 - covered) * Math.max(0, total - value - INSET - THUMB / 2)}px 0px 0px round ${HEIGHT}px)`\n  );\n  // Hidden at rest, so no ink shows round the thumb before it moves.\n  const fillOpacity = useTransform(x, (value) => Math.min(1, value / 20));\n  const labelOpacity = useTransform([x, width], ([value, total]: number[]) => {\n    const progress = value / Math.max(1, total - THUMB - INSET * 2);\n    return Math.max(0, 1 - progress * 0.55);\n  });\n\n  useLayoutEffect(() => {\n    const track = trackRef.current;\n    if (!track) {\n      return;\n    }\n    const observer = new ResizeObserver(() => {\n      const before = width.get();\n      width.set(track.offsetWidth);\n      // Keep a thumb that sits at the end at the end when the track resizes.\n      if (before > 0 && x.get() >= before - THUMB - INSET * 2) {\n        x.set(track.offsetWidth - THUMB - INSET * 2);\n      }\n    });\n    observer.observe(track);\n    return () => observer.disconnect();\n  }, [width, x]);\n\n  useEffect(\n    () => () => {\n      runRef.current += 1;\n    },\n    []\n  );\n\n  const spring = (velocity = 0) =>\n    reduceMotion\n      ? { duration: 0.15, ease: EASE_OUT }\n      : { type: \"spring\" as const, stiffness: 520, damping: 42, velocity };\n\n  // The ink drains back behind the thumb as it springs home.\n  const settleBack = (velocity = 0) => {\n    setArmed(false);\n    setPhase(\"idle\");\n    animate(cover, 0, { duration: reduceMotion ? 0 : 0.3, ease: EASE_OUT });\n    animate(x, 0, spring(velocity));\n  };\n\n  // The reset nobody asked for runs slower, so it reads as a rewind.\n  const rewind = () => {\n    setArmed(false);\n    setPhase(\"idle\");\n    const duration = reduceMotion ? 0 : 0.6;\n    animate(cover, 0, { duration: duration * 0.6, ease: EASE_OUT });\n    animate(x, 0, { duration, ease: EASE_IN_OUT });\n  };\n\n  // One motion from release to result: the thumb carries on to the end while\n  // the ink floods the track and the label turns to \"Paying\", and the work\n  // starts at once instead of waiting for the thumb to land.\n  const commit = async (velocity: number, glide = false) => {\n    const run = ++runRef.current;\n    const alive = () => runRef.current === run;\n    setPhase(\"confirming\");\n    setArmed(true);\n\n    const landing = animate(\n      x,\n      max(),\n      glide && !reduceMotion\n        ? { duration: 0.42, ease: EASE_IN_OUT }\n        : spring(velocity)\n    );\n    const flooding = animate(cover, 1, {\n      duration: reduceMotion ? 0 : 0.36,\n      ease: EASE_OUT,\n    });\n\n    try {\n      // A floor on the wait, so a fast handler doesn't flash the spinner.\n      await Promise.all([confirmRef.current(), wait(800), landing, flooding]);\n    } catch {\n      if (alive()) {\n        settleBack();\n      }\n      return;\n    }\n    if (!alive()) {\n      return;\n    }\n    setPhase(\"done\");\n    if (resetAfter === null) {\n      return;\n    }\n    await wait(resetAfter);\n    if (alive()) {\n      rewind();\n    }\n  };\n\n  const onPointerDown = (event: PointerEvent<HTMLButtonElement>) => {\n    if (disabled || phase !== \"idle\" || event.button !== 0) {\n      return;\n    }\n    event.currentTarget.setPointerCapture(event.pointerId);\n    x.stop();\n    dragRef.current = { pointer: event.clientX, from: x.get(), moved: false };\n    setPhase(\"dragging\");\n  };\n\n  const onPointerMove = (event: PointerEvent<HTMLButtonElement>) => {\n    if (phase !== \"dragging\") {\n      return;\n    }\n    const drag = dragRef.current;\n    const delta = event.clientX - drag.pointer;\n    if (Math.abs(delta) > TAP_SLOP) {\n      drag.moved = true;\n    }\n    const raw = drag.from + delta;\n    x.set(band(raw, max()));\n    const isArmed = raw >= max() * COMMIT_AT;\n    if (isArmed !== armed) {\n      setArmed(isArmed);\n    }\n  };\n\n  const onPointerUp = () => {\n    if (phase !== \"dragging\") {\n      return;\n    }\n    if (!dragRef.current.moved) {\n      setPhase(\"idle\");\n      return;\n    }\n    const velocity = x.getVelocity();\n    const progress = x.get() / Math.max(1, max());\n    const flicked = velocity > FLICK_SPEED && progress > FLICK_FROM;\n    if (progress >= COMMIT_AT || flicked) {\n      commit(velocity);\n    } else {\n      settleBack(velocity);\n    }\n  };\n\n  const onClick = async (event: MouseEvent<HTMLButtonElement>) => {\n    if (disabled || phase !== \"idle\") {\n      return;\n    }\n    // Enter and Space click with no pointer, and confirm in one go.\n    if (event.detail === 0) {\n      commit(0, true);\n      return;\n    }\n    if (dragRef.current.moved || reduceMotion) {\n      return;\n    }\n    // A tap shows which way to go: a short nudge that springs back.\n    await animate(x, 28, { duration: 0.18, ease: EASE_OUT });\n    if (phase === \"idle\") {\n      animate(x, 0, spring());\n    }\n  };\n\n  const done = phase === \"done\";\n  const busy = phase === \"confirming\";\n  let stage = \"slide\";\n  let stageLabel = label;\n  if (busy) {\n    stage = \"busy\";\n    stageLabel = `${confirmingLabel}…`;\n  } else if (done) {\n    stage = \"done\";\n    stageLabel = doneLabel;\n  }\n\n  return (\n    <div\n      className={cn(\"relative w-full max-w-sm select-none\", className)}\n      data-phase={phase}\n    >\n      <motion.div\n        className={cn(\n          \"relative overflow-hidden rounded-full border\",\n          \"border-zinc-200 bg-zinc-100 dark:border-zinc-800 dark:bg-zinc-900\",\n          disabled && \"opacity-50\"\n        )}\n        ref={trackRef}\n        style={{ height: HEIGHT }}\n      >\n        {/* The label as it reads on the empty track. Nudged off the thumb by\n            half its width, so it sits between the track and free-space\n            centers and reads as centered. */}\n        <motion.span\n          aria-hidden\n          className=\"absolute inset-0 flex items-center justify-center whitespace-nowrap pl-6 font-medium text-sm text-zinc-500 dark:text-zinc-400\"\n          style={{ opacity: labelOpacity }}\n        >\n          {label}\n        </motion.span>\n\n        {/* The ink the thumb leaves behind, with the label in white on it. It\n            turns emerald in place when the work is done. */}\n        <motion.div\n          aria-hidden\n          className={cn(\n            \"absolute inset-0 transition-colors duration-300 ease-out motion-reduce:transition-none\",\n            done\n              ? \"bg-emerald-600 dark:bg-emerald-500\"\n              : \"bg-zinc-900 dark:bg-zinc-100\"\n          )}\n          style={{ clipPath: fillClip, opacity: fillOpacity }}\n        >\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            <motion.span\n              animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n              className={cn(\n                \"absolute inset-0 flex items-center justify-center gap-2 whitespace-nowrap font-medium text-sm\",\n                done\n                  ? \"text-white dark:text-emerald-950\"\n                  : \"text-white dark:text-zinc-900\",\n                phase === \"idle\" || phase === \"dragging\" ? \"pl-6\" : \"pr-6\"\n              )}\n              exit={{ opacity: 0, y: -8, filter: \"blur(2px)\" }}\n              initial={{ opacity: 0, y: 8, filter: \"blur(2px)\" }}\n              key={stage}\n              transition={\n                reduceMotion\n                  ? { duration: 0 }\n                  : { duration: 0.26, ease: EASE_OUT }\n              }\n            >\n              {stageLabel}\n            </motion.span>\n          </AnimatePresence>\n        </motion.div>\n\n        <motion.button\n          aria-disabled={disabled || phase !== \"idle\"}\n          aria-label={`${label}. Press Enter to confirm.`}\n          className={cn(\n            \"absolute flex touch-none items-center justify-center rounded-full outline-none\",\n            \"bg-white text-zinc-900 shadow-[0_1px_2px_rgb(0_0_0/0.12),0_0_0_1px_rgb(0_0_0/0.06)]\",\n            \"dark:bg-zinc-800 dark:text-zinc-100 dark:shadow-[0_1px_2px_rgb(0_0_0/0.5),0_0_0_1px_rgb(255_255_255/0.08)]\",\n            \"focus-visible:ring-2 focus-visible:ring-zinc-400 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900\",\n            phase === \"idle\" && !disabled && \"cursor-grab\",\n            phase === \"dragging\" && \"cursor-grabbing\"\n          )}\n          onClick={onClick}\n          onPointerCancel={onPointerUp}\n          onPointerDown={onPointerDown}\n          onPointerMove={onPointerMove}\n          onPointerUp={onPointerUp}\n          // Offsets are from inside the 1px border.\n          style={{\n            x,\n            left: INSET - 1,\n            top: INSET - 1,\n            width: THUMB,\n            height: THUMB,\n          }}\n          type=\"button\"\n        >\n          <span className=\"relative size-5\">\n            <ArrowRight\n              aria-hidden\n              className={cn(\n                \"absolute inset-0 size-5 transition-[opacity,translate,filter] duration-200 ease-out motion-reduce:transition-none\",\n                armed && !busy && !done && \"translate-x-0.5\",\n                (busy || done) && \"translate-x-1.5 opacity-0 blur-[2px]\"\n              )}\n            />\n            <Check\n              aria-hidden\n              className={cn(\n                \"absolute inset-0 size-5 text-emerald-600 transition-[opacity,scale,filter] duration-200 ease-out motion-reduce:transition-none dark:text-emerald-400\",\n                done ? \"scale-100 opacity-100\" : \"scale-50 opacity-0 blur-[2px]\"\n              )}\n              strokeWidth={2.5}\n            />\n            <svg\n              aria-hidden\n              className={cn(\n                \"absolute -inset-0.5 size-6 animate-spin transition-opacity duration-200 [animation-duration:700ms]\",\n                // Paused, not removed, so it fades out without snapping back.\n                busy\n                  ? \"opacity-100 delay-100\"\n                  : \"opacity-0 [animation-play-state:paused]\"\n              )}\n              viewBox=\"0 0 24 24\"\n            >\n              <title>Working</title>\n              <circle\n                className=\"stroke-zinc-200 dark:stroke-zinc-700\"\n                cx=\"12\"\n                cy=\"12\"\n                fill=\"none\"\n                r=\"9\"\n                strokeWidth=\"2.5\"\n              />\n              <circle\n                className=\"stroke-current\"\n                cx=\"12\"\n                cy=\"12\"\n                fill=\"none\"\n                r=\"9\"\n                strokeDasharray=\"14 43\"\n                strokeLinecap=\"round\"\n                strokeWidth=\"2.5\"\n              />\n            </svg>\n          </span>\n        </motion.button>\n      </motion.div>\n\n      <span aria-live=\"polite\" className=\"sr-only\">\n        {busy && `${confirmingLabel}…`}\n        {done && doneLabel}\n      </span>\n    </div>\n  );\n}\n",
      "path": "/components/kokonutui/slide-to-confirm.tsx",
      "target": "components/kokonutui/slide-to-confirm.tsx"
    }
  ]
}