{
	"$schema": "https://ui.shadcn.com/schema/registry-item.json",
	"name": "HoldButton-TS-TW",
	"title": "HoldButton",
	"description": "Hold-to-confirm button whose liquid fill rises while pressed, snaps back on an early release and swaps its label through a blur when the hold completes.",
	"type": "registry:component",
	"files": [
		{
			"type": "registry:component",
			"path": "HoldButton/HoldButton.tsx",
			"content": "'use client';\n\nimport React, { useEffect, useId, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react';\n\nexport type HoldButtonSize = 'sm' | 'md' | 'lg';\nexport type HoldButtonDirection = 'right' | 'up';\n\nexport interface HoldButtonProps {\n  children?: ReactNode;\n  doneLabel?: ReactNode;\n  icon?: ReactNode;\n  doneIcon?: ReactNode;\n  backgroundColor?: string;\n  fillColor?: string;\n  textColor?: string;\n  fillTextColor?: string;\n  size?: HoldButtonSize;\n  radius?: number;\n  fillDirection?: HoldButtonDirection;\n  holdTime?: number;\n  releaseTime?: number;\n  pressScale?: number;\n  wave?: boolean;\n  waveAmplitude?: number;\n  glow?: boolean;\n  resetAfter?: number;\n  disabled?: boolean;\n  onHold?: () => void;\n  onTap?: () => void;\n  className?: string;\n}\n\ntype Phase = 'idle' | 'holding' | 'done';\ntype Input = 'pointer' | 'key' | null;\n\ninterface Motion {\n  raf: number;\n  p: number;\n  from: number;\n  to: number;\n  start: number;\n}\n\ninterface Gesture {\n  pointerId: number | null;\n  start: number;\n  rect: DOMRect | null;\n}\n\ninterface ReleaseOptions {\n  drifted?: boolean;\n}\n\nconst TAP_MS = 250;\nconst HIT_PAD = 10;\nconst LINEAR = (t: number) => t;\nconst EASE_OUT = (t: number) => 1 - Math.pow(1 - t, 3);\n\nconst SIZES: Record<HoldButtonSize, string> = {\n  sm: 'h-9 px-4 text-[13px]',\n  md: 'h-11 px-[22px] text-[15px]',\n  lg: 'h-[52px] px-7 text-[17px]'\n};\n\nconst GLOW = 'inset_0_1px_0_rgba(255,255,255,0.06),0_10px_32px_-6px_color-mix(in_srgb,var(--hb-fill)_70%,transparent)';\n\nconst LABEL_SPAN =\n  '[grid-area:1/1] inline-flex items-center gap-2 whitespace-nowrap [transition:opacity_200ms_ease,filter_200ms_ease]';\n\nconst STYLE = `\n.hb-root{--hb-w:0px;--hb-h:0px;--hb-cycles:2;--hb-p:0}\n.hb-fill{clip-path:inset(0 calc((1 - var(--hb-p)) * (100% + 0.75 * var(--hb-wave)) - var(--hb-p) * 0.25 * var(--hb-wave)) 0 0)}\n.hb-root[data-direction=up] .hb-fill{clip-path:inset(calc((1 - var(--hb-p)) * (100% + 0.75 * var(--hb-wave)) - var(--hb-p) * 0.25 * var(--hb-wave)) 0 0 0)}\n.hb-crest{-webkit-mask-image:url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='200' viewBox='0 0 20 200' preserveAspectRatio='none'%3E%3Cpath d='M0 0H10C18 8 18 25.3 10 33.3S2 58.7 10 66.7S18 92 10 100S2 125.3 10 133.3S18 158.7 10 166.7S2 192 10 200H0Z'/%3E%3C/svg%3E\");mask-image:url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='200' viewBox='0 0 20 200' preserveAspectRatio='none'%3E%3Cpath d='M0 0H10C18 8 18 25.3 10 33.3S2 58.7 10 66.7S18 92 10 100S2 125.3 10 133.3S18 158.7 10 166.7S2 192 10 200H0Z'/%3E%3C/svg%3E\");-webkit-mask-repeat:repeat-y;mask-repeat:repeat-y;-webkit-mask-size:var(--hb-wave) calc(var(--hb-h) * 2);mask-size:var(--hb-wave) calc(var(--hb-h) * 2);-webkit-mask-position-x:calc(-1 * var(--hb-wave) + var(--hb-p) * (var(--hb-w) + var(--hb-wave)));mask-position-x:calc(-1 * var(--hb-wave) + var(--hb-p) * (var(--hb-w) + var(--hb-wave)));-webkit-mask-position-y:calc(-1 * var(--hb-p) * var(--hb-cycles) * var(--hb-h));mask-position-y:calc(-1 * var(--hb-p) * var(--hb-cycles) * var(--hb-h))}\n.hb-root[data-direction=up] .hb-crest{-webkit-mask-image:url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='20' viewBox='0 0 200 20' preserveAspectRatio='none'%3E%3Cpath d='M0 20V10C12 2 38 2 50 10S88 18 100 10S138 2 150 10S188 18 200 10V20Z'/%3E%3C/svg%3E\");mask-image:url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='20' viewBox='0 0 200 20' preserveAspectRatio='none'%3E%3Cpath d='M0 20V10C12 2 38 2 50 10S88 18 100 10S138 2 150 10S188 18 200 10V20Z'/%3E%3C/svg%3E\");-webkit-mask-repeat:repeat-x;mask-repeat:repeat-x;-webkit-mask-size:calc(var(--hb-w) * 2) var(--hb-wave);mask-size:calc(var(--hb-w) * 2) var(--hb-wave);-webkit-mask-position-x:calc(-1 * var(--hb-p) * var(--hb-cycles) * var(--hb-w));mask-position-x:calc(-1 * var(--hb-p) * var(--hb-cycles) * var(--hb-w));-webkit-mask-position-y:calc(var(--hb-h) - var(--hb-p) * (var(--hb-h) + var(--hb-wave)));mask-position-y:calc(var(--hb-h) - var(--hb-p) * (var(--hb-h) + var(--hb-wave)))}\n@keyframes hb-pulse{from{opacity:1;box-shadow:0 0 0 0 color-mix(in srgb,var(--hb-fill) 55%,transparent)}to{opacity:0;box-shadow:0 0 0 14px color-mix(in srgb,var(--hb-fill) 0%,transparent)}}\n@media (prefers-reduced-motion:reduce){\n.hb-root{transform:none!important;transition:background-color 160ms ease,box-shadow var(--hb-release) ease!important}\n.hb-fill{clip-path:inset(0)!important;opacity:0;transition:opacity var(--hb-release) ease!important}\n.hb-crest{display:none}\n.hb-root[data-phase=holding] .hb-fill,.hb-root[data-phase=done] .hb-fill{opacity:1;transition:opacity var(--hb-hold) linear!important}\n.hb-pulse{animation:none!important}\n.hb-label>span{filter:none!important;transition:opacity 200ms ease!important}\n}`;\n\nconst HoldButton: React.FC<HoldButtonProps> = ({\n  children = 'Hold to delete',\n  doneLabel = 'Deleted',\n  icon = null,\n  doneIcon = null,\n  backgroundColor = '#27272a',\n  fillColor = '#5227FF',\n  textColor = '#f5f5f5',\n  fillTextColor = '#ffffff',\n  size = 'md',\n  radius = 14,\n  fillDirection = 'right',\n  holdTime = 2000,\n  releaseTime = 200,\n  pressScale = 0.97,\n  wave = true,\n  waveAmplitude = 6,\n  glow = true,\n  resetAfter = 1200,\n  disabled = false,\n  onHold,\n  onTap,\n  className = ''\n}) => {\n  const [phase, setPhase] = useState<Phase>('idle');\n  const [input, setInput] = useState<Input>(null);\n  const phaseRef = useRef<Phase>('idle');\n  const inputRef = useRef<Input>(null);\n  const buttonRef = useRef<HTMLButtonElement>(null);\n  const gesture = useRef<Gesture>({ pointerId: null, start: 0, rect: null });\n  const timers = useRef({ complete: 0, reset: 0 });\n  const hintId = useId();\n\n  const go = (next: Phase, kind: Input = null) => {\n    phaseRef.current = next;\n    inputRef.current = kind;\n    setPhase(next);\n    setInput(kind);\n  };\n\n  const clearTimers = () => {\n    clearTimeout(timers.current.complete);\n    clearTimeout(timers.current.reset);\n  };\n\n  const motion = useRef<Motion>({ raf: 0, p: 0, from: 0, to: 0, start: 0 });\n  const drive = (to: number, duration: number, ease: (t: number) => number) => {\n    const m = motion.current;\n    cancelAnimationFrame(m.raf);\n    m.from = m.p;\n    m.to = to;\n    m.start = performance.now();\n    const step = (now: number) => {\n      const t = duration > 0 ? Math.min(1, (now - m.start) / duration) : 1;\n      m.p = m.from + (m.to - m.from) * ease(t);\n      buttonRef.current?.style.setProperty('--hb-p', m.p.toFixed(4));\n      if (t < 1) {\n        m.raf = requestAnimationFrame(step);\n        return;\n      }\n      m.raf = 0;\n      if (m.to === 1) complete();\n    };\n    m.raf = requestAnimationFrame(step);\n  };\n\n  const complete = () => {\n    if (phaseRef.current !== 'holding') return;\n    if (performance.now() - gesture.current.start < holdTime - 50) return;\n    clearTimers();\n    go('done', inputRef.current);\n    onHold?.();\n    if (resetAfter > 0) {\n      timers.current.reset = window.setTimeout(() => {\n        go('idle');\n        drive(0, releaseTime, EASE_OUT);\n      }, resetAfter);\n    }\n  };\n\n  const begin = (kind: Input) => {\n    if (disabled || phaseRef.current !== 'idle') return false;\n    const button = buttonRef.current;\n    if (!button) return false;\n    gesture.current.start = performance.now();\n    gesture.current.rect = button.getBoundingClientRect();\n    go('holding', kind);\n    drive(1, holdTime, LINEAR);\n    timers.current.complete = window.setTimeout(complete, holdTime + 100);\n    return true;\n  };\n\n  const release = ({ drifted = false }: ReleaseOptions = {}) => {\n    if (phaseRef.current !== 'holding') return;\n    clearTimers();\n    const held = performance.now() - gesture.current.start;\n    go('idle');\n    drive(0, releaseTime, EASE_OUT);\n    if (!drifted && held < TAP_MS) onTap?.();\n  };\n  const releaseRef = useRef(release);\n  releaseRef.current = release;\n\n  const handlePointerDown = (e: React.PointerEvent<HTMLButtonElement>) => {\n    if (e.button !== 0 || !e.isPrimary || gesture.current.pointerId !== null) return;\n    if (!begin('pointer')) return;\n    gesture.current.pointerId = e.pointerId;\n    try {\n      e.currentTarget.setPointerCapture(e.pointerId);\n    } catch {}\n  };\n\n  const endPointer = (e: React.PointerEvent<HTMLButtonElement>, options?: ReleaseOptions) => {\n    if (e.pointerId !== gesture.current.pointerId) return;\n    gesture.current.pointerId = null;\n    try {\n      if (e.currentTarget.hasPointerCapture(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId);\n    } catch {}\n    release(options);\n  };\n\n  const handlePointerMove = (e: React.PointerEvent<HTMLButtonElement>) => {\n    if (e.pointerId !== gesture.current.pointerId) return;\n    const r = gesture.current.rect;\n    if (!r) return;\n    const out =\n      e.clientX < r.left - HIT_PAD ||\n      e.clientX > r.right + HIT_PAD ||\n      e.clientY < r.top - HIT_PAD ||\n      e.clientY > r.bottom + HIT_PAD;\n    if (out) endPointer(e, { drifted: true });\n  };\n\n  const handlePointerLeave = (e: React.PointerEvent<HTMLButtonElement>) => {\n    if (e.pointerType !== 'touch') endPointer(e, { drifted: true });\n  };\n\n  const handleKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {\n    if (e.key === 'Escape') {\n      if (inputRef.current === 'key') release({ drifted: true });\n      return;\n    }\n    if (e.key === ' ' || e.key === 'Enter') {\n      e.preventDefault();\n      if (!e.repeat) begin('key');\n    }\n  };\n\n  const handleKeyUp = (e: React.KeyboardEvent<HTMLButtonElement>) => {\n    if (e.key === ' ' || e.key === 'Enter') {\n      e.preventDefault();\n      if (inputRef.current === 'key') release();\n    }\n  };\n\n  useLayoutEffect(() => {\n    const button = buttonRef.current;\n    if (!button) return undefined;\n    const measure = () => {\n      button.style.setProperty('--hb-w', `${button.offsetWidth}px`);\n      button.style.setProperty('--hb-h', `${button.offsetHeight}px`);\n    };\n    measure();\n    const ro = new ResizeObserver(measure);\n    ro.observe(button);\n    return () => ro.disconnect();\n  }, []);\n\n  useEffect(() => {\n    if (phase !== 'holding') return undefined;\n    const cancel = () => releaseRef.current({ drifted: true });\n    const onVisibility = () => {\n      if (document.hidden) cancel();\n    };\n    window.addEventListener('blur', cancel);\n    document.addEventListener('visibilitychange', onVisibility);\n    return () => {\n      window.removeEventListener('blur', cancel);\n      document.removeEventListener('visibilitychange', onVisibility);\n    };\n  }, [phase]);\n\n  useEffect(() => {\n    const t = timers.current;\n    const m = motion.current;\n    return () => {\n      clearTimeout(t.complete);\n      clearTimeout(t.reset);\n      cancelAnimationFrame(m.raf);\n    };\n  }, []);\n\n  const direction: HoldButtonDirection = fillDirection === 'up' ? 'up' : 'right';\n  const labels = (\n    <>\n      <span\n        className={`${LABEL_SPAN} group-data-[phase=done]:opacity-0 group-data-[phase=done]:blur-[2px]`}\n        aria-hidden={phase === 'done'}\n      >\n        {icon ? <span className=\"inline-flex flex-none [&>svg]:block\">{icon}</span> : null}\n        {children}\n      </span>\n      <span\n        className={`${LABEL_SPAN} opacity-0 blur-[2px] group-data-[phase=done]:opacity-100 group-data-[phase=done]:blur-0`}\n        aria-hidden={phase !== 'done'}\n      >\n        {doneIcon ? <span className=\"inline-flex flex-none [&>svg]:block\">{doneIcon}</span> : null}\n        {doneLabel}\n      </span>\n    </>\n  );\n\n  const cssVars = {\n    '--hb-radius': `${radius}px`,\n    '--hb-bg': backgroundColor,\n    '--hb-fill': fillColor,\n    '--hb-text': textColor,\n    '--hb-fill-text': fillTextColor,\n    '--hb-hold': `${holdTime}ms`,\n    '--hb-cycles': holdTime / 1100,\n    '--hb-release': `${releaseTime}ms`,\n    '--hb-press': pressScale,\n    '--hb-wave': `${wave ? waveAmplitude : 0}px`,\n    '--hb-ease-out': 'cubic-bezier(0.23, 1, 0.32, 1)'\n  } as CSSProperties;\n\n  return (\n    <button\n      ref={buttonRef}\n      type=\"button\"\n      disabled={disabled}\n      className={`hb-root group relative isolate m-0 inline-grid cursor-pointer touch-manipulation select-none place-items-center border-0 font-medium leading-none tracking-[0.01em] outline-none [-webkit-tap-highlight-color:transparent] [-webkit-touch-callout:none] [background:var(--hb-bg)] [border-radius:var(--hb-radius)] [color:var(--hb-text)] shadow-[inset_0_1px_0_rgba(255,255,255,0.06)] [transition:transform_160ms_var(--hb-ease-out),background-color_160ms_ease,box-shadow_var(--hb-release)_var(--hb-ease-out)] [@media(hover:hover)_and_(pointer:fine)]:enabled:hover:[background:color-mix(in_srgb,var(--hb-bg)_92%,#fff)] data-[phase=holding]:data-[input=pointer]:[transform:scale(var(--hb-press))] data-[glow=true]:data-[phase=holding]:shadow-[${GLOW}] data-[glow=true]:data-[phase=done]:shadow-[${GLOW}] data-[glow=true]:data-[phase=holding]:[transition:transform_160ms_var(--hb-ease-out),background-color_160ms_ease,box-shadow_var(--hb-hold)_linear] focus-visible:[outline:2px_solid_var(--hb-fill)] focus-visible:outline-offset-[3px] disabled:pointer-events-none disabled:cursor-default disabled:opacity-50 contrast-more:[outline:1px_solid_var(--hb-text)] ${SIZES[size] || SIZES.md}${className ? ` ${className}` : ''}`}\n      data-phase={phase}\n      data-input={input ?? undefined}\n      data-direction={direction}\n      data-glow={glow ? 'true' : undefined}\n      aria-describedby={hintId}\n      style={cssVars}\n      onPointerDown={handlePointerDown}\n      onPointerMove={handlePointerMove}\n      onPointerUp={e => endPointer(e)}\n      onPointerCancel={e => endPointer(e, { drifted: true })}\n      onLostPointerCapture={e => endPointer(e, { drifted: true })}\n      onPointerLeave={handlePointerLeave}\n      onKeyDown={handleKeyDown}\n      onKeyUp={handleKeyUp}\n      onContextMenu={e => e.preventDefault()}\n    >\n      <style>{STYLE}</style>\n      <span\n        className=\"hb-pulse pointer-events-none absolute inset-0 z-0 opacity-0 [border-radius:var(--hb-radius)] group-data-[glow=true]:group-data-[phase=done]:[animation:hb-pulse_600ms_var(--hb-ease-out)_forwards]\"\n        aria-hidden=\"true\"\n      />\n      <span className=\"hb-label relative z-[2] grid place-items-center\">{labels}</span>\n      <span\n        className=\"pointer-events-none absolute inset-0 z-[3] [clip-path:inset(0_round_var(--hb-radius))]\"\n        aria-hidden=\"true\"\n      >\n        <span className=\"hb-fill absolute inset-0 grid place-items-center [background:var(--hb-fill)] [color:var(--hb-fill-text)]\">\n          <span className=\"hb-label grid place-items-center\">{labels}</span>\n        </span>\n        <span className=\"hb-crest absolute inset-0 grid place-items-center [background:var(--hb-fill)] [color:var(--hb-fill-text)]\">\n          <span className=\"hb-label grid place-items-center\">{labels}</span>\n        </span>\n      </span>\n      <span id={hintId} className=\"absolute h-px w-px overflow-hidden whitespace-nowrap [clip-path:inset(50%)]\">\n        Press and hold for {Math.round(holdTime / 100) / 10} seconds to confirm\n      </span>\n    </button>\n  );\n};\n\nexport default HoldButton;\n"
		}
	],
	"registryDependencies": [],
	"dependencies": []
}