{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"hover-stroke-draw-button","type":"registry:component","title":"Stroke Draw Button","description":"A button whose outline draws itself around the shape: two strokes leave the top centre with a bright comet head and meet at the bottom, a soft light follows the pointer, the arrow slides, and a press wipes the accent in. Three variants, light and dark tones, any size. No dependencies.","dependencies":[],"registryDependencies":[],"files":[{"path":"components/viberdy/hover-stroke-draw-button.tsx","content":"\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport type { ButtonHTMLAttributes } from \"react\";\n\nexport type HoverStrokeDrawVariant = \"outline\" | \"fill\" | \"glass\";\nexport type HoverStrokeDrawTone = \"dark\" | \"light\" | \"inherit\";\n\nconst DEFAULT_ACCENT = \"#3d7bff\";\nconst COLOR_RE = /^(#[0-9a-f]{3,8}|(?:rgb|hsl|oklch)a?\\([0-9a-z\\s.,%/-]+\\))$/i;\nconst EASE_DRAW = \"cubic-bezier(0.25,0.8,0.25,1)\";\n\nconst CSS = [\n  \".hsd{position:relative;isolation:isolate;display:inline-flex;align-items:center;justify-content:center;gap:0.9em;\",\n  \"min-height:44px;padding:var(--hsd-py,16px) var(--hsd-px,32px);margin:0;border:0;border-radius:var(--hsd-r,10px);\",\n  \"background:none;color:var(--hsd-fg,inherit);cursor:pointer;white-space:nowrap;user-select:none;\",\n  \"font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:var(--hsd-fs,12px);line-height:1;\",\n  \"letter-spacing:0.18em;text-transform:uppercase;-webkit-tap-highlight-color:transparent;touch-action:manipulation;\",\n  \"transition:transform 220ms cubic-bezier(0.2,0.8,0.2,1),opacity 200ms}\",\n  \".hsd:active:not(:disabled){transform:scale(0.97)}\",\n  \".hsd:disabled{cursor:not-allowed;opacity:0.45}\",\n  \".hsd:focus{outline:none}\",\n  \".hsd:focus-visible{outline:2px solid var(--hsd-accent)!important;outline-offset:3px!important;border-radius:var(--hsd-r,10px)!important}\",\n  \".hsd-bg{position:absolute;inset:0;z-index:0;overflow:hidden;border-radius:inherit;pointer-events:none;\",\n  \"box-shadow:inset 0 0 0 1px color-mix(in srgb,currentColor 18%,transparent)}\",\n  \".hsd[data-v=fill] .hsd-bg{background:color-mix(in srgb,var(--hsd-accent) 14%,transparent)}\",\n  \".hsd[data-v=glass] .hsd-bg{background:color-mix(in srgb,currentColor 6%,transparent);\",\n  \"-webkit-backdrop-filter:blur(10px) saturate(1.4);backdrop-filter:blur(10px) saturate(1.4);\",\n  \"box-shadow:inset 0 0 0 1px color-mix(in srgb,currentColor 18%,transparent),inset 0 1px 0 color-mix(in srgb,currentColor 22%,transparent)}\",\n  \".hsd-spot{position:absolute;inset:0;opacity:0;transition:opacity 300ms ease;\",\n  \"background:radial-gradient(120px circle at var(--mx,50%) var(--my,50%),color-mix(in srgb,var(--hsd-accent) 30%,transparent),transparent 70%)}\",\n  \".hsd[data-on] .hsd-spot{opacity:1}\",\n  \".hsd-wipe{position:absolute;inset:0;background:var(--hsd-accent);clip-path:inset(0 100% 0 0)}\",\n  \".hsd[data-wipe=in] .hsd-wipe{clip-path:inset(0 0 0 0);transition:clip-path 340ms cubic-bezier(0.2,0.8,0.2,1)}\",\n  \".hsd[data-wipe=out] .hsd-wipe{clip-path:inset(0 0 0 100%);transition:clip-path 340ms cubic-bezier(0.2,0.8,0.2,1)}\",\n  \".hsd-svg{position:absolute;inset:0;z-index:1;width:100%;height:100%;overflow:visible;pointer-events:none}\",\n  \".hsd-stroke{fill:none;stroke:var(--hsd-accent);stroke-width:1.5;stroke-dasharray:1 1;stroke-dashoffset:1;\",\n  \"transition:stroke-dashoffset var(--hsd-speed,600ms) \" + EASE_DRAW + \"}\",\n  \".hsd[data-on] .hsd-stroke{stroke-dashoffset:0}\",\n  \".hsd-head{fill:none;stroke:color-mix(in srgb,var(--hsd-accent) 35%,#fff);stroke-width:2;stroke-dasharray:0.05 2;stroke-dashoffset:0;opacity:0;\",\n  \"filter:drop-shadow(0 0 3px var(--hsd-accent))}\",\n  \".hsd[data-on] .hsd-head{animation:hsd-head var(--hsd-speed,600ms) \" + EASE_DRAW + \" both}\",\n  \"@keyframes hsd-head{0%{stroke-dashoffset:0;opacity:1}78%{opacity:1}100%{stroke-dashoffset:-0.95;opacity:0}}\",\n  \".hsd-label,.hsd-arrow{position:relative;z-index:2;transition:color 200ms ease}\",\n  \".hsd[data-wipe=in] .hsd-label,.hsd[data-wipe=in] .hsd-arrow{color:#fff}\",\n  \".hsd-arrow{flex:none;width:1.35em;height:1.35em;transition:transform 420ms \" + EASE_DRAW + \",color 200ms ease}\",\n  \".hsd[data-on] .hsd-arrow{transform:translateX(0.3em)}\",\n  \".hsd-shaft{stroke-dasharray:1 1;stroke-dashoffset:0.4;transition:stroke-dashoffset 420ms \" + EASE_DRAW + \"}\",\n  \".hsd[data-on] .hsd-shaft{stroke-dashoffset:0}\",\n  \"@media (prefers-reduced-motion:reduce){\",\n  \".hsd,.hsd-arrow,.hsd-shaft,.hsd-wipe{transition-duration:1ms!important}\",\n  \".hsd:active:not(:disabled){transform:none}\",\n  \".hsd-stroke{stroke-dashoffset:0!important;opacity:0;transition:opacity 200ms ease!important}\",\n  \".hsd[data-on] .hsd-stroke{opacity:1}\",\n  \".hsd[data-on] .hsd-head{animation:none}\",\n  \".hsd[data-on] .hsd-arrow{transform:none}}\",\n].join(\"\\n\");\n\nfunction strokePaths(w: number, h: number, radius: number, double: boolean): string[] {\n  const i = 1;\n  const r = Math.max(0, Math.min(radius, (w - 2) / 2, (h - 2) / 2));\n  const cx = w / 2;\n  const x1 = w - i;\n  const y1 = h - i;\n  const n = (v: number) => String(Math.round(v * 100) / 100);\n  const right =\n    \"M\" + n(cx) + \" \" + i + \"H\" + n(x1 - r) + \"A\" + n(r) + \" \" + n(r) + \" 0 0 1 \" + n(x1) + \" \" + n(i + r) +\n    \"V\" + n(y1 - r) + \"A\" + n(r) + \" \" + n(r) + \" 0 0 1 \" + n(x1 - r) + \" \" + n(y1) + \"H\" + n(cx);\n  if (double) {\n    const left =\n      \"M\" + n(cx) + \" \" + i + \"H\" + n(i + r) + \"A\" + n(r) + \" \" + n(r) + \" 0 0 0 \" + i + \" \" + n(i + r) +\n      \"V\" + n(y1 - r) + \"A\" + n(r) + \" \" + n(r) + \" 0 0 0 \" + n(i + r) + \" \" + n(y1) + \"H\" + n(cx);\n    return [right, left];\n  }\n  return [\n    right +\n      \"H\" + n(i + r) + \"A\" + n(r) + \" \" + n(r) + \" 0 0 1 \" + i + \" \" + n(y1 - r) +\n      \"V\" + n(i + r) + \"A\" + n(r) + \" \" + n(r) + \" 0 0 1 \" + n(i + r) + \" \" + i + \"H\" + n(cx),\n  ];\n}\n\nexport type HoverStrokeDrawButtonProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, \"children\"> & {\n  label?: string;\n  /** Draw duration in ms. */\n  speed?: number;\n  /** Corner radius in px. */\n  radius?: number;\n  /** Two strokes meeting at the bottom centre, or one lap of the whole path. */\n  doubleStroke?: boolean;\n  /** outline: bare track. fill: faint tint. glass: frosted. */\n  variant?: HoverStrokeDrawVariant;\n  /** Text and track colour. \"inherit\" follows the host page. */\n  tone?: HoverStrokeDrawTone;\n  /** Stroke, light and press-fill colour (hex, rgb(), hsl() or oklch()). */\n  accent?: string;\n  /** The sliding arrow after the label. */\n  arrow?: boolean;\n};\n\nexport function HoverStrokeDrawButton({\n  label = \"Install\",\n  speed = 600,\n  radius = 10,\n  doubleStroke = true,\n  variant = \"outline\",\n  tone = \"dark\",\n  accent = DEFAULT_ACCENT,\n  arrow = false,\n  className,\n  style,\n  type = \"button\",\n  disabled,\n  onPointerEnter,\n  onPointerLeave,\n  onPointerMove,\n  onPointerDown,\n  onPointerUp,\n  onPointerCancel,\n  onFocus,\n  onBlur,\n  onKeyDown,\n  onKeyUp,\n  ...rest\n}: HoverStrokeDrawButtonProps) {\n  const ref = useRef<HTMLButtonElement>(null);\n  const touchTimer = useRef(0);\n  const [size, setSize] = useState({ w: 0, h: 0 });\n  const [hover, setHover] = useState(false);\n  const [focus, setFocus] = useState(false);\n  const [touch, setTouch] = useState(false);\n  const [wipe, setWipe] = useState<\"idle\" | \"in\" | \"out\">(\"idle\");\n\n  useEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    const measure = () => {\n      const w = el.offsetWidth;\n      const h = el.offsetHeight;\n      setSize((p) => (p.w === w && p.h === h ? p : { w, h }));\n    };\n    const ro = new ResizeObserver(measure);\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, []);\n\n  useEffect(() => () => window.clearTimeout(touchTimer.current), []);\n\n  const on = !disabled && (hover || focus || touch);\n  const color = COLOR_RE.test(accent) ? accent : DEFAULT_ACCENT;\n  const fg = tone === \"dark\" ? \"#fff\" : tone === \"light\" ? \"#0a0a0a\" : undefined;\n  const paths = size.w > 0 ? strokePaths(size.w, size.h, radius, doubleStroke) : [];\n\n  const press = () => setWipe(\"in\");\n  const release = () => setWipe((w) => (w === \"in\" ? \"out\" : w));\n\n  return (\n    <>\n      <style>{CSS}</style>\n      <button\n        ref={ref}\n        type={type}\n        disabled={disabled}\n        className={className ? \"hsd \" + className : \"hsd\"}\n        data-v={variant}\n        data-on={on ? \"\" : undefined}\n        data-wipe={wipe}\n        style={{\n          ...style,\n          [\"--hsd-accent\" as string]: color,\n          [\"--hsd-fg\" as string]: fg,\n          [\"--hsd-r\" as string]: radius + \"px\",\n          [\"--hsd-speed\" as string]: Math.max(0, speed) + \"ms\",\n        }}\n        onPointerEnter={(e) => {\n          if (e.pointerType !== \"touch\") setHover(true);\n          onPointerEnter?.(e);\n        }}\n        onPointerLeave={(e) => {\n          if (e.pointerType !== \"touch\") setHover(false);\n          release();\n          onPointerLeave?.(e);\n        }}\n        onPointerMove={(e) => {\n          const el = e.currentTarget;\n          const rect = el.getBoundingClientRect();\n          if (rect.width > 0) {\n            const k = el.offsetWidth / rect.width;\n            el.style.setProperty(\"--mx\", (e.clientX - rect.left) * k + \"px\");\n            el.style.setProperty(\"--my\", (e.clientY - rect.top) * k + \"px\");\n          }\n          onPointerMove?.(e);\n        }}\n        onPointerDown={(e) => {\n          if (e.pointerType === \"touch\") {\n            window.clearTimeout(touchTimer.current);\n            setTouch(true);\n          }\n          press();\n          onPointerDown?.(e);\n        }}\n        onPointerUp={(e) => {\n          if (e.pointerType === \"touch\") {\n            window.clearTimeout(touchTimer.current);\n            touchTimer.current = window.setTimeout(() => setTouch(false), 900);\n          }\n          release();\n          onPointerUp?.(e);\n        }}\n        onPointerCancel={(e) => {\n          setTouch(false);\n          release();\n          onPointerCancel?.(e);\n        }}\n        onFocus={(e) => {\n          let visible = true;\n          try {\n            visible = e.currentTarget.matches(\":focus-visible\");\n          } catch {\n            visible = true;\n          }\n          setFocus(visible);\n          onFocus?.(e);\n        }}\n        onBlur={(e) => {\n          setFocus(false);\n          release();\n          onBlur?.(e);\n        }}\n        onKeyDown={(e) => {\n          if ((e.key === \" \" || e.key === \"Enter\") && !e.repeat) press();\n          onKeyDown?.(e);\n        }}\n        onKeyUp={(e) => {\n          if (e.key === \" \" || e.key === \"Enter\") release();\n          onKeyUp?.(e);\n        }}\n        {...rest}\n      >\n        <span className=\"hsd-bg\" aria-hidden onTransitionEnd={() => setWipe((w) => (w === \"out\" ? \"idle\" : w))}>\n          <span className=\"hsd-spot\" />\n          <span className=\"hsd-wipe\" />\n        </span>\n        {paths.length > 0 && (\n          <svg className=\"hsd-svg\" width={size.w} height={size.h} aria-hidden focusable=\"false\">\n            {paths.map((d, i) => (\n              <path key={\"s\" + i} className=\"hsd-stroke\" d={d} pathLength={1} />\n            ))}\n            {paths.map((d, i) => (\n              <path key={\"h\" + i} className=\"hsd-head\" d={d} pathLength={1} />\n            ))}\n          </svg>\n        )}\n        <span className=\"hsd-label\">{label}</span>\n        {arrow && (\n          <svg className=\"hsd-arrow\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.5} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden focusable=\"false\">\n            <path className=\"hsd-shaft\" d=\"M2 8h11\" pathLength={1} />\n            <path d=\"M9 4l4 4-4 4\" />\n          </svg>\n        )}\n      </button>\n    </>\n  );\n}\n","type":"registry:component","target":"components/viberdy/hover-stroke-draw-button.tsx"}],"meta":{"tier":"free","tags":["button","hover","svg","stroke","outline","draw","arrow","cta"],"href":"https://viberdy.dev/library/hover-stroke-draw-button","aiPrompt":"Create a single-file React component named HoverStrokeDrawButton (\"use client\", React only, no animation library, no Tailwind needed). It is a button whose outline draws itself around the shape.\n\nProps (all optional): label \"Install\"; speed 600 (draw duration, ms); radius 10 (px); doubleStroke true; variant \"outline\" | \"fill\" | \"glass\" (default \"outline\"); tone \"dark\" | \"light\" | \"inherit\" (default \"dark\": white text; \"light\": #0a0a0a; \"inherit\": the host's colour); accent \"#3d7bff\" (validate against a regex for hex / rgb() / hsl() / oklch(), else fall back); arrow false (the preview passes true); className; style; type \"button\"; every other button prop (onClick, disabled, aria-*) passes through, and the component calls the user's pointer, focus and key handlers too.\n\nStructure: inject one plain <style> element beside the button (never inside it) holding all CSS under a .hsd namespace; the button holds an aria-hidden .hsd-bg layer (overflow hidden, inherits the radius, with a 1px inset track in currentColor at 18%; fill = accent at 14%, glass = currentColor 6% + backdrop blur 10px + a top highlight) that contains a pointer light and a press wipe, then an absolutely positioned SVG, the label span, and an optional 16px arrow SVG. Mono uppercase label (ui-monospace stack), 12px, tracking 0.18em, padding 16px 32px, min-height 44px; size comes from CSS variables --hsd-fs, --hsd-px, --hsd-py.\n\nStroke: a ResizeObserver reads offsetWidth/offsetHeight into state; render the SVG only once measured (hydration safe). Build rounded-rect paths in px that start at the TOP CENTRE, inset 1px, radius clamped to min(radius, half the shorter side). doubleStroke: a clockwise right half and a counter-clockwise left half, each ending at the bottom centre; otherwise one clockwise lap. Every path has pathLength={1}, stroke-dasharray \"1 1\", stroke-dashoffset 1 at rest and 0 when \"on\", width 1.5, butt caps, accent colour, transition stroke-dashoffset <speed>ms cubic-bezier(0.16,1,0.3,1). A comet head: the same path, stroke width 2, colour accent mixed 35% with white, dasharray \"0.05 2\", drop-shadow 3px accent, run by a keyframe animation (same duration and easing) from offset 0 / opacity 1 to offset -0.95 / opacity 0, holding opacity until 78%.\n\n\"On\" = pointer hover (mouse/pen only), keyboard :focus-visible, or a touch (pointerdown sets it; it clears 900ms after pointerup). Not when disabled (opacity 0.45, not-allowed).\n\nPointer light: on pointermove write --mx/--my in LAYOUT px ((clientX - rect.left) * offsetWidth / rect.width); a 120px radial circle of accent at 30% fades in over 300ms when on.\n\nArrow: a shaft (pathLength 1, dashoffset 0.4 at rest, 0 on) and a chevron, 1.5 stroke; the arrow translates 0.3em right on, 420ms with the draw easing.\n\nPress: pointerdown or Enter/Space keydown sets wipe \"in\": the accent fills the shape via clip-path inset(0 100% 0 0) to inset(0) in 340ms cubic-bezier(0.2,0.8,0.2,1) and the label/arrow turn white; release, leave or blur sets \"out\" (clip to inset(0 0 0 100%)), and the end of that transition resets to idle with no transition. The button also scales to 0.97 while :active.\n\nFocus: outline none, but :focus-visible shows a 2px accent outline at 3px offset (important, since host pages may set their own). prefers-reduced-motion: the stroke is fully drawn and only fades in over 200ms, no comet head, no arrow slide, transitions collapse to 1ms. Without JS the button is a styled button with the CSS track.","license":"https://viberdy.dev/license"}}