{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "swipe-to-confirm",
  "type": "registry:ui",
  "title": "Swipe to Confirm",
  "description": "A slide-to-unlock control: drag the knob across the track to confirm, or step it there with the arrow keys.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/wancraft/swipe-to-confirm.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { animate, motion, useMotionValue, useTransform, type PanInfo } from \"motion/react\";\n\nexport interface SwipeToConfirmProps\n  extends Omit<\n    React.ComponentPropsWithoutRef<\"div\">,\n    \"children\" | \"onDrag\" | \"onDragStart\" | \"onDragEnd\" | \"onAnimationStart\" | \"onAnimationEnd\"\n  > {\n  /** Hint shown in the track; also the accessible name of the slider. */\n  label?: string;\n  /** Text shown and announced once confirmed. */\n  confirmedLabel?: string;\n  /** Fraction of the track the knob must pass before release confirms (0.5–0.95). */\n  threshold?: number;\n  /** Reset to the start this many ms after confirming. 0 keeps the confirmed state. */\n  resetDelay?: number;\n  /** Fill and confirmed colour. */\n  accent?: string;\n  /** Text / icon colour on top of the accent. */\n  inkColor?: string;\n  /** Animated shine across the hint text. */\n  shimmer?: boolean;\n  disabled?: boolean;\n  /** Called once when the swipe completes. */\n  onConfirm?: () => void;\n  /** Called when the control returns to its start after a confirmation. */\n  onReset?: () => void;\n}\n\nconst TRACK = 56; // px — track height\nconst PAD = 4; // px — inset of the knob inside the track\nconst KNOB = TRACK - 2 - PAD * 2; // px — knob size (track has a 1px border)\nconst KEY_STEP = 0.1;\nconst PAGE_STEP = 0.25;\n\nfunction subscribeReducedMotion(cb: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", cb);\n  return () => mq.removeEventListener(\"change\", cb);\n}\n\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\n/**\n * Slide-to-confirm: drag the knob across the track to confirm. Releasing before the threshold\n * springs it back. The knob is a role=\"slider\": arrow keys move it in 10% steps and reaching\n * the end confirms, so the control works without a pointer.\n */\nexport function SwipeToConfirm({\n  label = \"Slide to confirm\",\n  confirmedLabel = \"Confirmed\",\n  threshold = 0.8,\n  resetDelay = 2500,\n  accent = \"#FF7448\",\n  inkColor = \"#0B0D0F\",\n  shimmer = true,\n  disabled = false,\n  onConfirm,\n  onReset,\n  className = \"\",\n  style,\n  ...props\n}: SwipeToConfirmProps) {\n  const reduceMotion = usePrefersReducedMotion();\n  const hintId = React.useId();\n  const trackRef = React.useRef<HTMLDivElement>(null);\n  const hintRef = React.useRef<HTMLSpanElement>(null);\n  const resetTimer = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined);\n  /** Where the knob is heading (0–1), so rapid key presses build on the target, not the mid-spring position. */\n  const target = React.useRef(0);\n\n  const [maxX, setMaxX] = React.useState(0);\n  const [confirmed, setConfirmed] = React.useState(false);\n  /** Progress exposed to assistive technology (updated on release / key press, not per frame). */\n  const [percent, setPercent] = React.useState(0);\n  const [announcement, setAnnouncement] = React.useState(\"\");\n\n  const x = useMotionValue(0);\n  const range = useMotionValue(1);\n  const fillWidth = useTransform(x, (v) => v + KNOB);\n  const hintOpacity = useTransform([x, range], ([v, r]: number[]) => Math.max(0, 1 - v / (r * 0.55)));\n  const arrowOpacity = useTransform([x, range], ([v, r]: number[]) => Math.max(0, 1 - v / (r * 0.25)));\n\n  const spring = reduceMotion ? { duration: 0 } : { type: \"spring\" as const, stiffness: 480, damping: 38 };\n  const clampedThreshold = Math.min(0.95, Math.max(0.5, threshold));\n\n  // Measure the travel distance; keep the knob at the end if the track resizes while confirmed.\n  React.useEffect(() => {\n    const track = trackRef.current;\n    if (!track) return;\n    const measure = () => {\n      const travel = Math.max(0, track.clientWidth - PAD * 2 - KNOB);\n      range.set(Math.max(1, travel));\n      setMaxX(travel);\n      if (track.dataset.state === \"confirmed\" || x.get() > travel) x.set(travel);\n    };\n    const ro = new ResizeObserver(measure);\n    ro.observe(track);\n    return () => ro.disconnect();\n  }, [x, range]);\n\n  React.useEffect(() => () => clearTimeout(resetTimer.current), []);\n\n  // Shine sweeping across the hint. Paused offscreen, in hidden tabs and with reduced motion.\n  React.useEffect(() => {\n    const node = hintRef.current;\n    if (!node || !shimmer || reduceMotion || confirmed || disabled || typeof node.animate !== \"function\") return;\n    const anim = node.animate([{ backgroundPosition: \"100% 0\" }, { backgroundPosition: \"0% 0\" }], {\n      duration: 2400,\n      iterations: Infinity,\n      easing: \"ease-in-out\",\n    });\n    let visible = true;\n    const sync = () => (visible && !document.hidden ? anim.play() : anim.pause());\n    const io = new IntersectionObserver(([entry]) => {\n      visible = entry.isIntersecting;\n      sync();\n    });\n    io.observe(node);\n    document.addEventListener(\"visibilitychange\", sync);\n    return () => {\n      io.disconnect();\n      document.removeEventListener(\"visibilitychange\", sync);\n      anim.cancel();\n    };\n  }, [shimmer, reduceMotion, confirmed, disabled]);\n\n  const moveTo = (fraction: number) => {\n    const f = Math.min(1, Math.max(0, fraction));\n    target.current = f;\n    animate(x, f * maxX, spring);\n    setPercent(Math.round(f * 100));\n  };\n\n  const confirm = () => {\n    if (confirmed) return;\n    target.current = 1;\n    animate(x, maxX, spring);\n    setConfirmed(true);\n    setPercent(100);\n    setAnnouncement(confirmedLabel);\n    onConfirm?.();\n    if (resetDelay > 0) {\n      clearTimeout(resetTimer.current);\n      resetTimer.current = setTimeout(() => {\n        setConfirmed(false);\n        setAnnouncement(\"\");\n        moveTo(0);\n        onReset?.();\n      }, resetDelay);\n    }\n  };\n\n  const handleDragEnd = (_: PointerEvent | MouseEvent | TouchEvent, info: PanInfo) => {\n    const progress = maxX > 0 ? x.get() / maxX : 0;\n    // A confident flick past the middle counts too.\n    if (progress >= clampedThreshold || (progress > 0.45 && info.velocity.x > 900)) confirm();\n    else moveTo(0);\n  };\n\n  const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {\n    if (disabled || confirmed) return;\n    const current = target.current;\n    let next: number | null = null;\n    if (event.key === \"ArrowRight\" || event.key === \"ArrowUp\") next = current + KEY_STEP;\n    else if (event.key === \"ArrowLeft\" || event.key === \"ArrowDown\") next = current - KEY_STEP;\n    else if (event.key === \"PageUp\") next = current + PAGE_STEP;\n    else if (event.key === \"PageDown\") next = current - PAGE_STEP;\n    else if (event.key === \"Home\") next = 0;\n    if (next === null) return;\n    event.preventDefault();\n    // Snap to the 10% grid so repeated presses land exactly on 100%.\n    next = Math.round(next * 100) / 100;\n    if (next >= 0.999) confirm();\n    else moveTo(next);\n  };\n\n  const interactive = !disabled && !confirmed;\n\n  return (\n    <div\n      className={`relative w-full min-w-[220px] select-none ${disabled ? \"opacity-50\" : \"\"} ${className}`}\n      style={{ [\"--stc-accent\" as string]: accent, [\"--stc-ink\" as string]: inkColor, ...style }}\n      {...props}\n    >\n      <div\n        ref={trackRef}\n        data-state={confirmed ? \"confirmed\" : \"idle\"}\n        className={`relative overflow-hidden rounded-full border transition-colors duration-300 ${confirmed ? \"border-transparent bg-[var(--stc-accent)]\" : \"border-zinc-900/12 bg-zinc-900/[0.04] dark:border-white/10 dark:bg-white/[0.04]\"}`}\n        style={{ height: TRACK }}\n      >\n        {/* Fill that trails the knob. */}\n        <motion.div\n          aria-hidden=\"true\"\n          className=\"absolute rounded-full bg-[color-mix(in_oklab,var(--stc-accent)_22%,transparent)]\"\n          style={{ left: PAD, top: PAD, height: KNOB, width: fillWidth }}\n        />\n\n        {/* Hint text with an optional shine; fades as the knob travels. */}\n        <motion.span\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute inset-y-0 right-0 flex items-center justify-center pr-4 text-sm font-medium tracking-tight\"\n          style={{ left: KNOB + PAD, opacity: confirmed ? 0 : hintOpacity }}\n        >\n          <span\n            ref={hintRef}\n            className={`[--stc-shine:#18181b] [--stc-text:#71717a] dark:[--stc-shine:#ffffff] dark:[--stc-text:#a1a1aa] ${shimmer && !reduceMotion ? \"bg-clip-text text-transparent\" : \"text-zinc-500 dark:text-zinc-400\"}`}\n            style={\n              shimmer && !reduceMotion\n                ? {\n                    backgroundImage:\n                      \"linear-gradient(100deg, var(--stc-text) 0%, var(--stc-text) 40%, var(--stc-shine) 50%, var(--stc-text) 60%, var(--stc-text) 100%)\",\n                    backgroundSize: \"250% 100%\",\n                    backgroundPosition: \"100% 0\",\n                  }\n                : undefined\n            }\n          >\n            {label}\n          </span>\n        </motion.span>\n\n        {/* Confirmed label, centred over the filled track. */}\n        <span\n          aria-hidden=\"true\"\n          className={`pointer-events-none absolute inset-0 flex items-center justify-center font-mono text-[11px] uppercase tracking-[0.16em] text-[var(--stc-ink)] transition-opacity duration-300 ${confirmed ? \"opacity-100 delay-150\" : \"opacity-0\"}`}\n          style={{ paddingRight: KNOB }}\n        >\n          {confirmedLabel}\n        </span>\n\n        <motion.div\n          role=\"slider\"\n          tabIndex={disabled ? -1 : 0}\n          aria-label={label}\n          aria-describedby={hintId}\n          aria-valuemin={0}\n          aria-valuemax={100}\n          aria-valuenow={percent}\n          aria-valuetext={confirmed ? confirmedLabel : `${percent}%`}\n          aria-disabled={!interactive || undefined}\n          drag={interactive && maxX > 0 ? \"x\" : false}\n          dragConstraints={{ left: 0, right: maxX }}\n          dragElastic={{ left: 0, right: 0.04 }}\n          dragMomentum={false}\n          onDragEnd={handleDragEnd}\n          onKeyDown={handleKeyDown}\n          onBlur={() => {\n            // A partial keyboard swipe does not survive leaving the control.\n            if (!confirmed && x.get() > 0) moveTo(0);\n          }}\n          whileTap={interactive && !reduceMotion ? { scale: 0.96 } : undefined}\n          className={`absolute grid place-items-center rounded-full shadow-[0_1px_2px_rgb(0_0_0/0.12),0_4px_12px_-4px_rgb(0_0_0/0.25)] outline-none transition-[background-color,color] duration-300 focus-visible:ring-2 focus-visible:ring-[var(--stc-accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-[#131619] ${confirmed ? \"bg-[var(--stc-ink)] text-[var(--stc-accent)]\" : \"bg-white text-zinc-900 dark:bg-zinc-100\"} ${interactive ? \"cursor-grab active:cursor-grabbing\" : \"\"}`}\n          style={{ x, left: PAD, top: PAD, width: KNOB, height: KNOB, touchAction: \"pan-y\" }}\n        >\n          {confirmed ? (\n            <svg viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden=\"true\" className=\"size-4\">\n              <motion.path\n                d=\"M3.5 8.5l3 3 6-7\"\n                stroke=\"currentColor\"\n                strokeWidth=\"1.8\"\n                strokeLinecap=\"round\"\n                strokeLinejoin=\"round\"\n                initial={{ pathLength: reduceMotion ? 1 : 0 }}\n                animate={{ pathLength: 1 }}\n                transition={{ duration: 0.35, delay: 0.1, ease: [0.22, 1, 0.36, 1] }}\n              />\n            </svg>\n          ) : (\n            <motion.svg viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden=\"true\" className=\"size-4\" style={{ opacity: arrowOpacity }}>\n              <path d=\"M3 8h9M8.5 4.5 12 8l-3.5 3.5\" stroke=\"currentColor\" strokeWidth=\"1.6\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n            </motion.svg>\n          )}\n        </motion.div>\n      </div>\n\n      <span id={hintId} className=\"sr-only\">\n        Drag the handle to the end, or press the right arrow key until it reaches 100%, to confirm.\n      </span>\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {announcement}\n      </span>\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "buttons"
  ],
  "docs": "Documentation: https://wancraft.my/components/swipe-to-confirm"
}
