# Hold Button

> Hold to confirm: a tube fills, release drains it.

Category: Buttons. Docs: https://neonkit.heyaadi.com/components/hold-button

## Installation

```bash
npx shadcn@latest add https://neonkit.heyaadi.com/r/hold-button.json
```

With the `@neonkit` namespace configured: `npx shadcn@latest add @neonkit/hold-button`.

npm dependencies: none. Also needs `lib/utils.ts` (`cn`) and `lib/neon.ts` (`neonVars`), which the CLI adds.

## Usage

```tsx
import { HoldButton } from "@/components/ui/hold-button"

<HoldButton completeLabel="Promoted" onComplete={promote}>Hold to promote</HoldButton>
```

## Examples

### Promote to production

Hold it: lit gas pushes through the tube behind a hot leading edge and the halo builds. Let go early and it drains; hold to the end and it flashes.

```tsx
"use client";

import * as React from "react";
import { HoldButton } from "@/components/ui/hold-button";

export default function HoldButtonDemo() {
  const [promoted, setPromoted] = React.useState(false);

  return (
    <div className="w-full max-w-sm rounded-2xl border border-white/10 bg-white/[0.03] p-5">
      <div className="flex items-center justify-between gap-3">
        <div className="min-w-0">
          <p className="truncate font-mono text-[13px] text-white/90">checkout-web@a41f9c2</p>
          <p className="mt-0.5 text-[13px] text-white/55">Preview passed 214 checks · 3m ago</p>
        </div>
        <span className="shrink-0 rounded-md border border-white/10 bg-white/[0.04] px-2 py-0.5 text-xs font-medium text-white/70">
          {promoted ? "Production" : "Preview"}
        </span>
      </div>
      <div className="mt-5 flex items-center justify-between gap-4 border-t border-white/[0.06] pt-4">
        <p className="text-[13px] leading-5 text-white/55">
          {promoted ? "Serving 100% of traffic in 12 regions." : "Goes live for every customer."}
        </p>
        <HoldButton
          completeLabel={promoted ? "Promoted" : "Rolled back"}
          resetDelay={2000}
          onComplete={() => setPromoted((value) => !value)}
          className="shrink-0"
        >
          {promoted ? "Hold to roll back" : "Hold to promote"}
        </HoldButton>
      </div>
    </div>
  );
}
```

### Destructive

A longer hold for a bigger consequence. resetDelay={Infinity} keeps it confirmed.

```tsx
"use client";

import * as React from "react";
import { Trash2 } from "@/components/ui/icons";
import { HoldButton } from "@/components/ui/hold-button";

export default function HoldButtonDestructive() {
  const [deleted, setDeleted] = React.useState(false);

  return (
    <div className="flex w-full max-w-sm items-center justify-between gap-4 rounded-xl border border-[#ff4d5e]/20 bg-[#ff4d5e]/[0.04] px-4 py-3.5">
      <div className="min-w-0">
        <p className="text-sm font-medium text-white/90">Delete workspace</p>
        <p className="text-[13px] leading-[18px] text-white/55">
          {deleted ? "Scheduled. You have 30 days to restore it." : "Removes 14 projects and their domains."}
        </p>
      </div>
      <HoldButton
        color="red"
        size="sm"
        duration={2000}
        completeLabel="Deleted"
        resetDelay={Infinity}
        onComplete={() => setDeleted(true)}
      >
        <Trash2 /> Hold to delete
      </HoldButton>
    </div>
  );
}
```

### Hold events

onHoldStart, onHoldCancel (with how far it got) and onComplete. Keyboard: focus it and hold Space or Enter.

```tsx
"use client";

import * as React from "react";
import { HoldButton } from "@/components/ui/hold-button";

export default function HoldButtonEvents() {
  const [log, setLog] = React.useState<{ id: number; text: string }[]>([]);
  const push = (text: string) =>
    setLog((lines) => [{ id: (lines[0]?.id ?? 0) + 1, text }, ...lines].slice(0, 4));

  return (
    <div className="flex w-full max-w-sm flex-col items-center gap-5">
      <HoldButton
        duration={1200}
        onHoldStart={() => push("Holding…")}
        onHoldCancel={(progress) => push(`Let go at ${Math.round(progress * 100)}%, draining`)}
        onComplete={() => push("Confirmed")}
      >
        Hold to sign off
      </HoldButton>
      <ol className="w-full space-y-1 rounded-lg border border-white/10 bg-[#0a0b11] p-3 font-mono text-xs text-white/55" aria-live="polite">
        {log.length === 0 ? (
          <li className="font-sans text-white/55">Press and hold, or focus and hold Space.</li>
        ) : (
          log.map((line, i) => (
            <li key={line.id} className={i === 0 ? "text-white/90" : undefined}>
              {line.text}
            </li>
          ))
        )}
      </ol>
    </div>
  );
}
```

### Durations

Match the hold to the weight of the action.

```tsx
import { HoldButton } from "@/components/ui/hold-button";

export default function HoldButtonDurations() {
  return (
    <div className="flex flex-wrap items-center justify-center gap-4">
      <HoldButton duration={700} completeLabel="Archived">
        Archive · 0.7s
      </HoldButton>
      <HoldButton completeLabel="Sent">Send · 1.5s</HoldButton>
      <HoldButton duration={3000} completeLabel="Rotated">
        Rotate keys · 3s
      </HoldButton>
    </div>
  );
}
```

## API reference

### HoldButton

Also accepts every native button attribute. The ref points at the button.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `duration` | `number` | `1500` | Milliseconds of holding to confirm. |
| `onComplete` | `() => void` | — | Fires once when the tube fills. |
| `onHoldStart` | `() => void` | — | Fires when a hold begins: pointer down, or Space/Enter down. |
| `onHoldCancel` | `(progress: number) => void` | — | Fires when a hold is released early, with how full the tube got (0 to 1). |
| `completeLabel` | `ReactNode` | — | Replaces the label once confirmed. A string is also announced to screen readers. |
| `resetDelay` | `number` | `1200` | Milliseconds the tube stays lit after release before it drains and re-arms. Infinity keeps it confirmed. |
| `hint` | `string` | `"Press and hold to confirm."` | Instructions linked with aria-describedby. Pass an empty string to drop it. |
| `size` | `"sm" \| "md" \| "lg"` | `"md"` | 32, 40 or 48px tall, like Button. |
| `color` | `NeonColor` | `inherited` | Palette name or hex. Falls back to --neon-accent, then cyan. |
| `disabled` | `boolean` | `false` | Dims the button and ignores holds. |

## Accessibility

- Keyboard: hold Space or Enter. Releasing early, pressing Escape or moving focus away drains the tube.
- Sliding the pointer well off the button lets go, like a native press. Touch scrolling cancels a hold too.
- After a confirm the tube has to drain before it re-arms, so a quick second press can't confirm twice.
- A click alone never confirms. Some assistive tech sends clicks instead of key holds, so for destructive actions keep another path such as a confirmation dialog or an undo.
- Under prefers-reduced-motion the fill still tracks the hold (it is information); the flash and burst ring are skipped.

## Source

`components/ui/hold-button.tsx`

```tsx
"use client";

import * as React from "react";

import { cn } from "@/lib/utils";
import { neonVars, type NeonColor } from "@/lib/neon";

// Progress lives in one CSS variable written from a rAF loop; every layer reads
// it, so React only renders when the phase changes.
const css = `
@keyframes nk-hold-button-flash{0%{opacity:0}10%{opacity:.9}100%{opacity:0}}
@keyframes nk-hold-button-burst{0%{outline-offset:0;opacity:.9}100%{outline-offset:var(--nk-hold-button-reach);opacity:0}}
.nk-hold-button-flash{animation:nk-hold-button-flash 560ms cubic-bezier(0.23,1,0.32,1) both}
.nk-hold-button-burst{animation:nk-hold-button-burst 700ms cubic-bezier(0.23,1,0.32,1) both}
@media (prefers-reduced-motion:reduce){.nk-hold-button-flash,.nk-hold-button-burst{animation:none;opacity:0}}
`;

const sizes = {
  sm: { root: "h-8 gap-1.5 rounded-md px-3 text-xs", reach: "10px" },
  md: { root: "h-10 gap-2 rounded-lg px-4 text-sm", reach: "13px" },
  lg: { root: "h-12 gap-2.5 rounded-xl px-6 text-base", reach: "16px" },
} as const;

type Phase = "idle" | "holding" | "draining" | "complete";

const P = "var(--nk-hold-button-progress,0)";

export interface HoldButtonProps
  extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "color"> {
  /** Palette name or any hex color. Inherits `--neon-accent` when omitted. */
  color?: NeonColor;
  size?: keyof typeof sizes;
  /** Milliseconds of holding to confirm. */
  duration?: number;
  /** Fires once when the tube fills. */
  onComplete?: () => void;
  /** Fires when a hold starts (pointer down, or Space/Enter down). */
  onHoldStart?: () => void;
  /** Fires when a hold is released early, with how full the tube got (0 to 1). */
  onHoldCancel?: (progress: number) => void;
  /** Label shown, and announced, once confirmed. */
  completeLabel?: React.ReactNode;
  /** Milliseconds the tube stays lit after confirming before it drains and re-arms. `Infinity` keeps it confirmed. */
  resetDelay?: number;
  /** Screen reader instructions, linked with aria-describedby. */
  hint?: string;
}

const HoldButton = React.forwardRef<HTMLButtonElement, HoldButtonProps>(
  (
    {
      className,
      style,
      color,
      size = "md",
      duration = 1500,
      onComplete,
      onHoldStart,
      onHoldCancel,
      completeLabel,
      resetDelay = 1200,
      hint = "Press and hold to confirm.",
      disabled,
      children,
      onPointerDown,
      onPointerUp,
      onPointerCancel,
      onPointerMove,
      onKeyDown,
      onKeyUp,
      onBlur,
      onContextMenu,
      "aria-describedby": describedBy,
      ...props
    },
    ref,
  ) => {
    const hintId = React.useId();
    const s = sizes[size];
    const [phase, setPhase] = React.useState<Phase>("idle");
    const [completions, setCompletions] = React.useState(0);

    const rootRef = React.useRef<HTMLButtonElement | null>(null);
    const engine = React.useRef({
      phase: "idle" as Phase,
      progress: 0,
      frame: 0,
      last: 0,
      held: false,
      // Draining after a confirm: it has to empty before it re-arms.
      cooling: false,
      timer: undefined as ReturnType<typeof setTimeout> | undefined,
    });
    // The loop outlives renders, so it reads props through a ref.
    const latest = React.useRef({ duration, resetDelay, onComplete, onHoldStart, onHoldCancel });
    React.useEffect(() => {
      latest.current = { duration, resetDelay, onComplete, onHoldStart, onHoldCancel };
    });

    React.useEffect(() => {
      const e = engine.current;
      return () => {
        cancelAnimationFrame(e.frame);
        clearTimeout(e.timer);
      };
    }, []);

    const setRefs = React.useCallback(
      (node: HTMLButtonElement | null) => {
        rootRef.current = node;
        if (typeof ref === "function") ref(node);
        else if (ref) ref.current = node;
      },
      [ref],
    );

    const enter = (next: Phase) => {
      engine.current.phase = next;
      setPhase(next);
    };

    const write = () => {
      rootRef.current?.style.setProperty("--nk-hold-button-progress", engine.current.progress.toFixed(4));
    };

    const stop = () => {
      engine.current.frame = 0;
      engine.current.last = 0;
    };

    const tick = (now: number) => {
      const e = engine.current;
      const dt = Math.min((now - (e.last || now)) / 1000, 0.05);
      e.last = now;
      const seconds = Math.max(latest.current.duration, 1) / 1000;

      if (e.phase === "holding") {
        e.progress = Math.min(1, e.progress + dt / seconds);
        if (e.progress >= 1) {
          write();
          stop();
          complete();
          return;
        }
      } else if (e.phase === "draining") {
        // Fast at first, then trailing off, like gas leaving a tube: about a third of `duration` from full.
        e.progress = Math.max(0, e.progress - (5.8 / seconds) * (e.progress + 0.15) * dt);
        if (e.progress <= 0) {
          write();
          stop();
          e.cooling = false;
          enter("idle");
          return;
        }
      } else {
        stop();
        return;
      }
      write();
      e.frame = requestAnimationFrame(tick);
    };

    const run = () => {
      if (!engine.current.frame) engine.current.frame = requestAnimationFrame(tick);
    };

    const scheduleReset = () => {
      const e = engine.current;
      clearTimeout(e.timer);
      if (!Number.isFinite(latest.current.resetDelay)) return;
      e.timer = setTimeout(() => {
        e.cooling = true;
        enter("draining");
        run();
      }, latest.current.resetDelay);
    };

    const complete = () => {
      enter("complete");
      setCompletions((count) => count + 1);
      if (typeof navigator !== "undefined") navigator.vibrate?.(12);
      latest.current.onComplete?.();
      if (!engine.current.held) scheduleReset();
    };

    const start = () => {
      const e = engine.current;
      if (disabled || e.held || e.cooling || e.phase === "complete") return;
      e.held = true;
      enter("holding");
      latest.current.onHoldStart?.();
      run();
    };

    const release = () => {
      const e = engine.current;
      if (!e.held) return;
      e.held = false;
      if (e.phase === "holding") {
        latest.current.onHoldCancel?.(e.progress);
        enter("draining");
        run();
      } else if (e.phase === "complete") {
        scheduleReset();
      }
    };

    const completed = phase === "complete";
    // Read at render, so a label that changes in onComplete is the one announced.
    const announcement = completed && typeof completeLabel === "string" ? completeLabel : "";

    return (
      <>
        <button
          ref={setRefs}
          type="button"
          disabled={disabled}
          data-state={phase}
          aria-describedby={[describedBy, hint ? hintId : undefined].filter(Boolean).join(" ") || undefined}
          onPointerDown={(event) => {
            onPointerDown?.(event);
            if (event.defaultPrevented || event.button !== 0) return;
            event.currentTarget.setPointerCapture?.(event.pointerId);
            start();
          }}
          onPointerUp={(event) => {
            onPointerUp?.(event);
            release();
          }}
          onPointerCancel={(event) => {
            onPointerCancel?.(event);
            release();
          }}
          onPointerMove={(event) => {
            onPointerMove?.(event);
            if (!engine.current.held || engine.current.phase !== "holding") return;
            // Sliding well off the button lets go, like a native press.
            const rect = event.currentTarget.getBoundingClientRect();
            const slack = 16;
            if (
              event.clientX < rect.left - slack ||
              event.clientX > rect.right + slack ||
              event.clientY < rect.top - slack ||
              event.clientY > rect.bottom + slack
            ) {
              release();
            }
          }}
          onKeyDown={(event) => {
            onKeyDown?.(event);
            if (event.defaultPrevented) return;
            if (event.key === " " || event.key === "Enter") {
              event.preventDefault();
              if (!event.repeat) start();
            } else if (event.key === "Escape") {
              release();
            }
          }}
          onKeyUp={(event) => {
            onKeyUp?.(event);
            if (event.key === " " || event.key === "Enter") {
              event.preventDefault();
              release();
            }
          }}
          onBlur={(event) => {
            onBlur?.(event);
            release();
          }}
          onContextMenu={(event) => {
            onContextMenu?.(event);
            // Long-press on touch opens a menu; it would interrupt the hold.
            if (engine.current.held) event.preventDefault();
          }}
          style={neonVars(color, { "--nk-hold-button-reach": s.reach, ...style } as React.CSSProperties)}
          className={cn(
            "[--neon:var(--neon-accent,34_211_238)]",
            "group/hold relative isolate inline-flex shrink-0 cursor-pointer select-none items-center justify-center whitespace-nowrap font-medium outline-none touch-manipulation [-webkit-tap-highlight-color:transparent] [-webkit-touch-callout:none]",
            "border border-[rgb(var(--neon)/0.32)] [background:linear-gradient(180deg,rgb(var(--neon)/0.08),rgb(var(--neon)/0.02)),#07080d]",
            "[color:color-mix(in_srgb,rgb(var(--neon))_25%,white)] shadow-[inset_0_1px_0_rgb(255_255_255/0.06)]",
            "transition-[border-color,transform,scale] duration-150 ease-out hover:border-[rgb(var(--neon)/0.55)]",
            "data-[state=holding]:scale-[0.98]",
            "focus-visible:ring-2 focus-visible:ring-[rgb(var(--neon)/0.6)] focus-visible:ring-offset-2 focus-visible:ring-offset-[#05060a]",
            "disabled:pointer-events-none disabled:opacity-50",
            s.root,
            className,
          )}
          {...props}
        >
          <style>{css}</style>

          {/* Halo: grows with the square of progress, so the last stretch feels loaded. */}
          <span
            aria-hidden
            style={{ opacity: `calc(${P} * ${P})` }}
            className="pointer-events-none absolute -inset-px rounded-[inherit] shadow-[0_0_14px_rgb(var(--neon)/CALC(0.55*var(--neon-glow,1))),0_0_36px_rgb(var(--neon)/CALC(0.3*var(--neon-glow,1)))]"
          />

          {/* The tube: lit gas pushes in from the left with a hot leading edge. */}
          <span aria-hidden className="pointer-events-none absolute -inset-px overflow-hidden rounded-[inherit]">
            <span
              style={{
                transform: `translateX(calc((${P} - 1) * 100%))`,
                opacity: `calc(0.6 + ${P} * 0.4)`,
              }}
              className="absolute inset-0 bg-[linear-gradient(90deg,rgb(var(--neon)/0.08),rgb(var(--neon)/0.26)_65%,rgb(var(--neon)/0.5))]"
            >
              <span
                style={{ opacity: completed ? 0 : `clamp(0, ${P} * 24, 1)` }}
                className="absolute inset-y-0 right-0 w-[2px] bg-[color-mix(in_srgb,rgb(var(--neon))_40%,white)] shadow-[0_0_6px_1px_rgb(var(--neon)/var(--neon-glow,1)),0_0_18px_4px_rgb(var(--neon)/CALC(0.5*var(--neon-glow,1)))]"
              />
            </span>
          </span>

          {/* The glass edge lights as the tube fills. */}
          <span
            aria-hidden
            style={{ opacity: P }}
            className="pointer-events-none absolute -inset-px rounded-[inherit] border border-[rgb(var(--neon))]"
          />

          {/* Confirmed: the whole tube burns evenly. */}
          <span
            aria-hidden
            className={cn(
              "pointer-events-none absolute -inset-px rounded-[inherit] bg-[rgb(var(--neon)/0.2)] shadow-[inset_0_0_14px_rgb(var(--neon)/CALC(0.35*var(--neon-glow,1)))] transition-opacity duration-300 ease-out",
              completed ? "opacity-100" : "opacity-0",
            )}
          />

          {completions > 0 && (
            <React.Fragment key={completions}>
              <span
                aria-hidden
                className="nk-hold-button-flash pointer-events-none absolute -inset-px rounded-[inherit] bg-[color-mix(in_srgb,rgb(var(--neon))_55%,white)] shadow-[0_0_24px_rgb(var(--neon)/var(--neon-glow,1)),0_0_60px_rgb(var(--neon)/CALC(0.6*var(--neon-glow,1)))]"
              />
              <span
                aria-hidden
                className="nk-hold-button-burst pointer-events-none absolute -inset-px rounded-[inherit] [outline:1.5px_solid_rgb(var(--neon)/0.8)]"
              />
            </React.Fragment>
          )}

          <span className="relative grid place-items-center gap-[inherit] [&>*]:col-start-1 [&>*]:row-start-1">
            <span
              aria-hidden={completed && completeLabel !== undefined ? true : undefined}
              className={cn(
                "inline-flex items-center gap-[inherit] transition-[opacity,transform,translate] duration-150 ease-out [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
                completed && completeLabel !== undefined && "-translate-y-1 opacity-0",
              )}
            >
              {children}
            </span>
            {completeLabel !== undefined && (
              <span
                aria-hidden={completed ? undefined : true}
                className={cn(
                  "inline-flex items-center gap-[inherit] [text-shadow:0_0_10px_rgb(var(--neon)/CALC(0.8*var(--neon-glow,1)))] transition-[opacity,transform,translate] duration-150 ease-out [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
                  completed ? "opacity-100" : "translate-y-1 opacity-0",
                )}
              >
                {completeLabel}
              </span>
            )}
          </span>
        </button>
        {hint && (
          <span id={hintId} hidden>
            {hint}
          </span>
        )}
        <span role="status" className="sr-only">
          {announcement}
        </span>
      </>
    );
  },
);
HoldButton.displayName = "HoldButton";

export { HoldButton };
```
