Buttons
StitchButton
A denim patch, half sewn, with the needle parked in the seam. Hover or focus and it sews the rest of the edge stitch by stitch. Leave and it unpicks.
Download
- React
- Tailwind CSS v4
- motion
components/ui/stitch-button.tsx
"use client";
import { useEffect, useId, useRef, useState, type ReactNode } from "react";
import { animate, motion, useMotionValue, useMotionValueEvent, useReducedMotion } from "motion/react";
type StitchButtonProps = {
children: ReactNode;
className?: string;
onClick?: () => void;
/** Colour of the thread. */
thread?: string;
/** Share of the edge already sewn at rest, 0 to 1. */
rest?: number;
/** Seconds to sew the whole edge. */
duration?: number;
};
const INSET = 7;
/* StitchButton: a cloth patch, half sewn. Hover and a needle finishes the edge stitch by stitch. Leave and it unpicks. */
export function StitchButton({
children,
className = "rounded-[14px] px-10 py-5 text-[15px] font-semibold tracking-wide text-[#f3ead7]",
onClick,
thread = "#f2c14e",
rest = 0.4,
duration = 1.1,
}: StitchButtonProps) {
const reduce = useReducedMotion();
const box = useRef<HTMLButtonElement>(null);
const line = useRef<SVGRectElement>(null);
const needle = useRef<SVGGElement>(null);
const [size, setSize] = useState({ w: 0, h: 0 });
const progress = useMotionValue(rest);
const maskId = useId();
useEffect(() => {
const el = box.current;
if (!el) return;
const ro = new ResizeObserver(() => setSize({ w: el.offsetWidth, h: el.offsetHeight }));
ro.observe(el);
return () => ro.disconnect();
}, []);
const placeNeedle = (p: number) => {
const path = line.current;
if (!path || !needle.current || !size.w) return;
const total = path.getTotalLength();
const a = path.getPointAtLength(total * p);
const b = path.getPointAtLength((total * p + 2) % total);
const turn = (Math.atan2(b.y - a.y, b.x - a.x) * 180) / Math.PI;
needle.current.setAttribute("transform", `translate(${a.x} ${a.y}) rotate(${turn})`);
};
useMotionValueEvent(progress, "change", placeNeedle);
useEffect(() => placeNeedle(progress.get()));
const sew = (on: boolean) => {
const target = on ? 1 : rest;
if (reduce) progress.set(target);
else animate(progress, target, { duration: duration * Math.abs(target - progress.get()), ease: "linear" });
};
const rectProps = {
x: INSET,
y: INSET,
width: Math.max(0, size.w - INSET * 2),
height: Math.max(0, size.h - INSET * 2),
rx: 8,
fill: "none",
};
return (
<button
ref={box}
type="button"
onClick={onClick}
onPointerEnter={(e) => e.pointerType === "mouse" && sew(true)}
onPointerLeave={() => sew(false)}
onPointerDown={() => sew(true)}
onFocus={() => sew(true)}
onBlur={() => sew(false)}
className={`relative isolate ${className}`}
style={{
backgroundColor: "#2b4a6f",
backgroundImage:
"repeating-linear-gradient(45deg, rgba(255,255,255,0.06) 0 2px, transparent 2px 4px), repeating-linear-gradient(-45deg, rgba(0,0,0,0.14) 0 2px, transparent 2px 4px)",
boxShadow: "0 3px 0 #1a3049, 0 10px 18px rgba(0,0,0,0.4)",
}}
>
<svg aria-hidden className="pointer-events-none absolute inset-0 size-full overflow-visible">
<defs>
<mask id={maskId} maskUnits="userSpaceOnUse" x={-20} y={-20} width={size.w + 40} height={size.h + 40}>
<motion.rect {...rectProps} stroke="#fff" strokeWidth={8} style={{ pathLength: progress }} />
</mask>
</defs>
{/* Needle holes where the thread has not reached yet. */}
<rect {...rectProps} stroke="rgba(0,0,0,0.45)" strokeWidth={1.6} strokeDasharray="1.6 13.4" strokeLinecap="round" />
<rect ref={line} {...rectProps} stroke={thread} strokeWidth={2.6} strokeDasharray="10 5" strokeLinecap="round" mask={`url(#${maskId})`} />
<g ref={needle}>
<path d="M-13 0 L9 -1.2 L13 0 L9 1.2 Z" fill="#dfe5ec" stroke="#6d7886" strokeWidth={0.6} />
<circle cx={-9.5} r={0.9} fill="#2b4a6f" />
</g>
</svg>
<span className="relative">{children}</span>
</button>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/stitch-button.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/stitch-button-demo.tsx
import { StitchButton } from "@/components/ui/stitch-button"; export default function StitchButtonExample() { return ( <div className="grid h-full place-items-center bg-[radial-gradient(circle_at_50%_60%,#1d2433,#0d0c14_70%)]"> <StitchButton>Finish the seam</StitchButton> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| children | ReactNode | — | Button label. |
| onClick | () => void | — | Click handler. |
| thread | string | "#f2c14e" | Thread colour. |
| rest | number | 0.4 | Share of the edge sewn at rest, 0 to 1. |
| duration | number | 1.1 | Seconds to sew the whole edge. |
| className | string | rounded patch | Shape, padding and text. The cloth stays. |