A button that fills while held, so destructive actions can never happen by accident.
Customize
Usage
import { HoldButton } from "@/components/motionkit/hold-button";
<HoldButton onConfirm={() => deleteProject()}>
Delete project
</HoldButton>Motion
The exact curves this component runs, plotted with Motion's own maths.
Fill
Pointer or Space/Enter held down
Drain
Released before it fills
Works in the App Router as is. Requires Tailwind CSS v4.
npm install motionSave as components/motionkit/hold-button.tsx.
"use client";
import { useEffect, useId, useRef, useState, type KeyboardEvent, type ReactNode } from "react";
import { animate, motion, useMotionValue, type AnimationPlaybackControls } from "motion/react";
type HoldButtonProps = {
children: ReactNode;
/** Called once the hold completes. */
onConfirm?: () => void;
/** Seconds the button must be held. */
duration?: number;
/** Fill colour while holding. */
color?: string;
/** Shown briefly after confirming. */
confirmedLabel?: string;
className?: string;
};
export function HoldButton({
children,
onConfirm,
duration = 1.2,
color = "#8C3216",
confirmedLabel = "Done",
className = "",
}: HoldButtonProps) {
const progress = useMotionValue(0);
const controls = useRef<AnimationPlaybackControls | null>(null);
const holding = useRef(false);
const [confirmed, setConfirmed] = useState(false);
const hintId = useId();
useEffect(() => {
if (!confirmed) return;
const timer = window.setTimeout(() => {
setConfirmed(false);
progress.set(0);
}, 1600);
return () => window.clearTimeout(timer);
}, [confirmed, progress]);
function start() {
if (holding.current || confirmed) return;
holding.current = true;
controls.current?.stop();
controls.current = animate(progress, 1, {
duration: duration * (1 - progress.get()),
ease: "linear",
onComplete: () => {
holding.current = false;
setConfirmed(true);
onConfirm?.();
},
});
}
function cancel() {
if (!holding.current) return;
holding.current = false;
controls.current?.stop();
// drain back quickly so an accidental tap never confirms
controls.current = animate(progress, 0, { duration: 0.3, ease: "easeOut" });
}
// Space and Enter work too: holding the key counts as holding the button
function handleKeyDown(event: KeyboardEvent<HTMLButtonElement>) {
if (event.key !== " " && event.key !== "Enter") return;
event.preventDefault();
if (!event.repeat) start();
}
function handleKeyUp(event: KeyboardEvent<HTMLButtonElement>) {
if (event.key === " " || event.key === "Enter") cancel();
}
return (
<>
<button
type="button"
onPointerDown={start}
onPointerUp={cancel}
onPointerLeave={cancel}
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
onContextMenu={(event) => event.preventDefault()}
aria-describedby={hintId}
className={`relative isolate inline-flex select-none items-center justify-center overflow-hidden rounded-xl border border-white/10 bg-[#1D2727] px-5 py-3 text-sm font-medium text-[#F6F1E8] [touch-action:none] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#5FAEB4] ${className}`}
>
<motion.span
aria-hidden
className="absolute inset-0 -z-10 origin-left"
style={{ scaleX: progress, backgroundColor: color }}
/>
<span className="inline-flex items-center gap-2">{confirmed ? confirmedLabel : children}</span>
</button>
<span id={hintId} className="sr-only">
Press and hold for {duration} seconds to confirm.
</span>
<span className="sr-only" aria-live="polite">
{confirmed ? confirmedLabel : ""}
</span>
</>
);
}
| Prop | Type | Default | Description |
|---|---|---|---|
| duration | number | 1.2 | Seconds the button must be held. |
| onConfirm | () => void | — | Called once the hold completes. |
| color | string | "#8C3216" | Fill colour while holding. |
| confirmedLabel | string | "Done" | Shown briefly after confirming. |
Magnetic Button
A button that leans toward the cursor with a springy, physical pull.
Shimmer Button
A dark button with a light sweep that draws the eye without shouting.
Submit Button
Morphs from its label to a spinner, then to a drawn tick or a shake, so every save feels answered.