A press-and-hold button that auto-repeats with stepper-physics acceleration and flashes a subtle surface tick on every fire.
npx shadcn@latest add @paragon/hold-repeat-buttonAlso installs: button
"use client";
import * as React from "react";
import type { VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/registry/paragon/ui/button";
const holdRepeatStyles = `
@keyframes pg-hold-repeat-tick {
from { opacity: 0.16; }
to { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
[data-hold-repeat-tick] { animation: none !important; }
}
`;
export interface HoldRepeatButtonProps
extends React.ComponentProps<"button">,
VariantProps<typeof buttonVariants> {
/** Fires once per tick, including the initial press. */
onRepeat: (info: { count: number }) => void;
/** ms before auto-repeat begins after the first fire. */
initialDelay?: number;
/** ms between the first auto-repeats. */
interval?: number;
/** Interval floor once fully accelerated. */
minInterval?: number;
/** Interval multiplier per fire — stepper physics. 1 disables acceleration. */
acceleration?: number;
/** Disables press-scale and the per-fire flash; ticks still fire. */
static?: boolean;
}
/**
* Press-and-hold auto-repeat with acceleration: the first fire lands on
* press, repeats begin after `initialDelay`, and each subsequent tick
* shortens toward `minInterval` — classic stepper physics. Every fire
* flashes a subtle full-surface tick. Works for pointer and held
* Space/Enter; window blur, release, or capture loss all stop the train.
* Wire actions to `onRepeat`, not `onClick`.
*/
export function HoldRepeatButton({
onRepeat,
initialDelay = 400,
interval = 150,
minInterval = 50,
acceleration = 0.85,
static: isStatic = false,
variant,
size,
className,
disabled,
children,
onPointerDown,
onPointerUp,
onPointerCancel,
onKeyDown,
onKeyUp,
...props
}: HoldRepeatButtonProps) {
const [tick, setTick] = React.useState(0);
const [holding, setHolding] = React.useState(false);
const timer = React.useRef<ReturnType<typeof setTimeout>>(null);
const currentInterval = React.useRef(interval);
const count = React.useRef(0);
const onRepeatRef = React.useRef(onRepeat);
onRepeatRef.current = onRepeat;
const stop = React.useCallback(() => {
if (timer.current) clearTimeout(timer.current);
timer.current = null;
setHolding(false);
}, []);
const fire = React.useCallback(() => {
count.current += 1;
setTick((t) => t + 1);
onRepeatRef.current({ count: count.current });
}, []);
const start = React.useCallback(() => {
if (timer.current) return;
setHolding(true);
count.current = 0;
currentInterval.current = interval;
fire();
const schedule = (delay: number) => {
timer.current = setTimeout(() => {
fire();
currentInterval.current = Math.max(
minInterval,
currentInterval.current * acceleration,
);
schedule(currentInterval.current);
}, delay);
};
schedule(initialDelay);
}, [fire, initialDelay, interval, minInterval, acceleration]);
// Unmount and tab-switch hygiene: never leave a repeat train running.
React.useEffect(() => {
const clear = () => stop();
window.addEventListener("blur", clear);
return () => {
window.removeEventListener("blur", clear);
stop();
};
}, [stop]);
return (
<>
<style href="paragon-hold-repeat-button" precedence="paragon">
{holdRepeatStyles}
</style>
<button
type="button"
data-slot="hold-repeat-button"
data-holding={holding || undefined}
disabled={disabled}
onPointerDown={(event) => {
onPointerDown?.(event);
if (event.defaultPrevented || disabled) return;
if (!event.isPrimary || event.button !== 0) return;
event.currentTarget.setPointerCapture(event.pointerId);
start();
}}
onPointerUp={(event) => {
onPointerUp?.(event);
stop();
}}
onPointerCancel={(event) => {
onPointerCancel?.(event);
stop();
}}
onLostPointerCapture={stop}
onKeyDown={(event) => {
onKeyDown?.(event);
if (event.defaultPrevented || disabled) return;
if (event.key !== " " && event.key !== "Enter") return;
// Own the repeat: block native key-repeat and the click on keyup.
event.preventDefault();
if (!event.repeat) start();
}}
onKeyUp={(event) => {
onKeyUp?.(event);
if (event.key === " " || event.key === "Enter") stop();
}}
className={cn(
buttonVariants({ variant, size }),
"relative overflow-hidden",
!isStatic && "active:not-disabled:scale-[0.97]",
!isStatic && holding && "scale-[0.97]",
className,
)}
{...props}
>
{children}
{tick > 0 && !isStatic && (
<span
key={tick}
aria-hidden
data-hold-repeat-tick=""
className="pointer-events-none absolute inset-0 rounded-[inherit] bg-current"
style={{
animation: "pg-hold-repeat-tick 200ms var(--ease-out) forwards",
}}
/>
)}
</button>
</>
);
}