Hold Repeat Button
Buttons

Hold Repeat Button

A press-and-hold button that auto-repeats with stepper-physics acceleration and flashes a subtle surface tick on every fire.

Install

npx shadcn@latest add @paragon/hold-repeat-button

Also installs: button

hold-repeat-button.tsx

"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>
    </>
  );
}