Stroke Draw Button
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.
DOM + CSS0 deps1 file · 11 KB TSXreduced-motion aware
Props
"use client";
import { useEffect, useRef, useState } from "react";
import type { ButtonHTMLAttributes } from "react";
export type HoverStrokeDrawVariant = "outline" | "fill" | "glass";
export type HoverStrokeDrawTone = "dark" | "light" | "inherit";
const DEFAULT_ACCENT = "#3d7bff";
const COLOR_RE = /^(#[0-9a-f]{3,8}|(?:rgb|hsl|oklch)a?\([0-9a-z\s.,%/-]+\))$/i;
const EASE_DRAW = "cubic-bezier(0.25,0.8,0.25,1)";
const CSS = [
".hsd{position:relative;isolation:isolate;display:inline-flex;align-items:center;justify-content:center;gap:0.9em;",
"min-height:44px;padding:var(--hsd-py,16px) var(--hsd-px,32px);margin:0;border:0;border-radius:var(--hsd-r,10px);",
"background:none;color:var(--hsd-fg,inherit);cursor:pointer;white-space:nowrap;user-select:none;",
"font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:var(--hsd-fs,12px);line-height:1;",
"letter-spacing:0.18em;text-transform:uppercase;-webkit-tap-highlight-color:transparent;touch-action:manipulation;",
"transition:transform 220ms cubic-bezier(0.2,0.8,0.2,1),opacity 200ms}",
".hsd:active:not(:disabled){transform:scale(0.97)}",
".hsd:disabled{cursor:not-allowed;opacity:0.45}",
".hsd:focus{outline:none}",
".hsd:focus-visible{outline:2px solid var(--hsd-accent)!important;outline-offset:3px!important;border-radius:var(--hsd-r,10px)!important}",
".hsd-bg{position:absolute;inset:0;z-index:0;overflow:hidden;border-radius:inherit;pointer-events:none;",
"box-shadow:inset 0 0 0 1px color-mix(in srgb,currentColor 18%,transparent)}",
".hsd[data-v=fill] .hsd-bg{background:color-mix(in srgb,var(--hsd-accent) 14%,transparent)}",
".hsd[data-v=glass] .hsd-bg{background:color-mix(in srgb,currentColor 6%,transparent);",
"-webkit-backdrop-filter:blur(10px) saturate(1.4);backdrop-filter:blur(10px) saturate(1.4);",
"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)}",
".hsd-spot{position:absolute;inset:0;opacity:0;transition:opacity 300ms ease;",
"background:radial-gradient(120px circle at var(--mx,50%) var(--my,50%),color-mix(in srgb,var(--hsd-accent) 30%,transparent),transparent 70%)}",
".hsd[data-on] .hsd-spot{opacity:1}",
".hsd-wipe{position:absolute;inset:0;background:var(--hsd-accent);clip-path:inset(0 100% 0 0)}",
".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)}",
".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)}",
".hsd-svg{position:absolute;inset:0;z-index:1;width:100%;height:100%;overflow:visible;pointer-events:none}",
".hsd-stroke{fill:none;stroke:var(--hsd-accent);stroke-width:1.5;stroke-dasharray:1 1;stroke-dashoffset:1;",
"transition:stroke-dashoffset var(--hsd-speed,600ms) " + EASE_DRAW + "}",
".hsd[data-on] .hsd-stroke{stroke-dashoffset:0}",
".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;",
"filter:drop-shadow(0 0 3px var(--hsd-accent))}",
".hsd[data-on] .hsd-head{animation:hsd-head var(--hsd-speed,600ms) " + EASE_DRAW + " both}",
"@keyframes hsd-head{0%{stroke-dashoffset:0;opacity:1}78%{opacity:1}100%{stroke-dashoffset:-0.95;opacity:0}}",
".hsd-label,.hsd-arrow{position:relative;z-index:2;transition:color 200ms ease}",
".hsd[data-wipe=in] .hsd-label,.hsd[data-wipe=in] .hsd-arrow{color:#fff}",
".hsd-arrow{flex:none;width:1.35em;height:1.35em;transition:transform 420ms " + EASE_DRAW + ",color 200ms ease}",
".hsd[data-on] .hsd-arrow{transform:translateX(0.3em)}",
".hsd-shaft{stroke-dasharray:1 1;stroke-dashoffset:0.4;transition:stroke-dashoffset 420ms " + EASE_DRAW + "}",
".hsd[data-on] .hsd-shaft{stroke-dashoffset:0}",
"@media (prefers-reduced-motion:reduce){",
".hsd,.hsd-arrow,.hsd-shaft,.hsd-wipe{transition-duration:1ms!important}",
".hsd:active:not(:disabled){transform:none}",
".hsd-stroke{stroke-dashoffset:0!important;opacity:0;transition:opacity 200ms ease!important}",
".hsd[data-on] .hsd-stroke{opacity:1}",
".hsd[data-on] .hsd-head{animation:none}",
".hsd[data-on] .hsd-arrow{transform:none}}",
].join("\n");
function strokePaths(w: number, h: number, radius: number, double: boolean): string[] {
const i = 1;
const r = Math.max(0, Math.min(radius, (w - 2) / 2, (h - 2) / 2));
const cx = w / 2;
const x1 = w - i;
const y1 = h - i;
const n = (v: number) => String(Math.round(v * 100) / 100);
const right =
"M" + n(cx) + " " + i + "H" + n(x1 - r) + "A" + n(r) + " " + n(r) + " 0 0 1 " + n(x1) + " " + n(i + r) +
"V" + n(y1 - r) + "A" + n(r) + " " + n(r) + " 0 0 1 " + n(x1 - r) + " " + n(y1) + "H" + n(cx);
if (double) {
const left =
"M" + n(cx) + " " + i + "H" + n(i + r) + "A" + n(r) + " " + n(r) + " 0 0 0 " + i + " " + n(i + r) +
"V" + n(y1 - r) + "A" + n(r) + " " + n(r) + " 0 0 0 " + n(i + r) + " " + n(y1) + "H" + n(cx);
return [right, left];
}
return [
right +
"H" + n(i + r) + "A" + n(r) + " " + n(r) + " 0 0 1 " + i + " " + n(y1 - r) +
"V" + n(i + r) + "A" + n(r) + " " + n(r) + " 0 0 1 " + n(i + r) + " " + i + "H" + n(cx),
];
}
export type HoverStrokeDrawButtonProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> & {
label?: string;
/** Draw duration in ms. */
speed?: number;
/** Corner radius in px. */
radius?: number;
/** Two strokes meeting at the bottom centre, or one lap of the whole path. */
doubleStroke?: boolean;
/** outline: bare track. fill: faint tint. glass: frosted. */
variant?: HoverStrokeDrawVariant;
/** Text and track colour. "inherit" follows the host page. */
tone?: HoverStrokeDrawTone;
/** Stroke, light and press-fill colour (hex, rgb(), hsl() or oklch()). */
accent?: string;
/** The sliding arrow after the label. */
arrow?: boolean;
};
export function HoverStrokeDrawButton({
label = "Install",
speed = 600,
radius = 10,
doubleStroke = true,
variant = "outline",
tone = "dark",
accent = DEFAULT_ACCENT,
arrow = false,
className,
style,
type = "button",
disabled,
onPointerEnter,
onPointerLeave,
onPointerMove,
onPointerDown,
onPointerUp,
onPointerCancel,
onFocus,
onBlur,
onKeyDown,
onKeyUp,
...rest
}: HoverStrokeDrawButtonProps) {
const ref = useRef<HTMLButtonElement>(null);
const touchTimer = useRef(0);
const [size, setSize] = useState({ w: 0, h: 0 });
const [hover, setHover] = useState(false);
const [focus, setFocus] = useState(false);
const [touch, setTouch] = useState(false);
const [wipe, setWipe] = useState<"idle" | "in" | "out">("idle");
useEffect(() => {
const el = ref.current;
if (!el) return;
const measure = () => {
const w = el.offsetWidth;
const h = el.offsetHeight;
setSize((p) => (p.w === w && p.h === h ? p : { w, h }));
};
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => ro.disconnect();
}, []);
useEffect(() => () => window.clearTimeout(touchTimer.current), []);
const on = !disabled && (hover || focus || touch);
const color = COLOR_RE.test(accent) ? accent : DEFAULT_ACCENT;
const fg = tone === "dark" ? "#fff" : tone === "light" ? "#0a0a0a" : undefined;
const paths = size.w > 0 ? strokePaths(size.w, size.h, radius, doubleStroke) : [];
const press = () => setWipe("in");
const release = () => setWipe((w) => (w === "in" ? "out" : w));
return (
<>
<style>{CSS}</style>
<button
ref={ref}
type={type}
disabled={disabled}
className={className ? "hsd " + className : "hsd"}
data-v={variant}
data-on={on ? "" : undefined}
data-wipe={wipe}
style={{
...style,
["--hsd-accent" as string]: color,
["--hsd-fg" as string]: fg,
["--hsd-r" as string]: radius + "px",
["--hsd-speed" as string]: Math.max(0, speed) + "ms",
}}
onPointerEnter={(e) => {
if (e.pointerType !== "touch") setHover(true);
onPointerEnter?.(e);
}}
onPointerLeave={(e) => {
if (e.pointerType !== "touch") setHover(false);
release();
onPointerLeave?.(e);
}}
onPointerMove={(e) => {
const el = e.currentTarget;
const rect = el.getBoundingClientRect();
if (rect.width > 0) {
const k = el.offsetWidth / rect.width;
el.style.setProperty("--mx", (e.clientX - rect.left) * k + "px");
el.style.setProperty("--my", (e.clientY - rect.top) * k + "px");
}
onPointerMove?.(e);
}}
onPointerDown={(e) => {
if (e.pointerType === "touch") {
window.clearTimeout(touchTimer.current);
setTouch(true);
}
press();
onPointerDown?.(e);
}}
onPointerUp={(e) => {
if (e.pointerType === "touch") {
window.clearTimeout(touchTimer.current);
touchTimer.current = window.setTimeout(() => setTouch(false), 900);
}
release();
onPointerUp?.(e);
}}
onPointerCancel={(e) => {
setTouch(false);
release();
onPointerCancel?.(e);
}}
onFocus={(e) => {
let visible = true;
try {
visible = e.currentTarget.matches(":focus-visible");
} catch {
visible = true;
}
setFocus(visible);
onFocus?.(e);
}}
onBlur={(e) => {
setFocus(false);
release();
onBlur?.(e);
}}
onKeyDown={(e) => {
if ((e.key === " " || e.key === "Enter") && !e.repeat) press();
onKeyDown?.(e);
}}
onKeyUp={(e) => {
if (e.key === " " || e.key === "Enter") release();
onKeyUp?.(e);
}}
{...rest}
>
<span className="hsd-bg" aria-hidden onTransitionEnd={() => setWipe((w) => (w === "out" ? "idle" : w))}>
<span className="hsd-spot" />
<span className="hsd-wipe" />
</span>
{paths.length > 0 && (
<svg className="hsd-svg" width={size.w} height={size.h} aria-hidden focusable="false">
{paths.map((d, i) => (
<path key={"s" + i} className="hsd-stroke" d={d} pathLength={1} />
))}
{paths.map((d, i) => (
<path key={"h" + i} className="hsd-head" d={d} pathLength={1} />
))}
</svg>
)}
<span className="hsd-label">{label}</span>
{arrow && (
<svg className="hsd-arrow" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" aria-hidden focusable="false">
<path className="hsd-shaft" d="M2 8h11" pathLength={1} />
<path d="M9 4l4 4-4 4" />
</svg>
)}
</button>
</>
);
}
Free. Pro unlocks everything, from $27/month.
Details
About this component
The border is SVG, not CSS. A ResizeObserver reads the button’s layout size and builds a rounded path that starts at the top centre; every path carries pathLength=1, so the dash values are plain fractions and the same two numbers draw it at any size. With two strokes, a clockwise and a counter-clockwise half leave the top centre together and meet at the bottom centre; with one stroke, a single line laps the whole perimeter. A short bright dash rides the leading edge as a comet head and fades out as the line closes, on the same easing so the two never drift apart. A faint track is painted underneath, so the control reads as a button before anyone hovers it. A soft light follows the pointer inside the shape, the arrow lengthens and slides, and pressing wipes the accent colour in from the left (the wipe leaves to the right on release). Hover, keyboard focus and a touch each trigger the draw; a tap holds it for a moment so phones see it too. Variants are outline, fill and glass, tone is dark, light or inherit (inherit follows the host page’s text colour), and the accent is any hex, rgb(), hsl() or oklch() value. Size comes from CSS variables (--hsd-fs, --hsd-px, --hsd-py) so a className can scale it without fighting the component.
Curator’s note
Stroke Draw Button, rebuilt in October. The first version drew half an outline; this one draws the whole border from the top centre, with a comet head, a pointer light and a press wipe, still with pathLength={1} doing the heavy lifting.

