Actions

Hold button

A button that fires only after being held for a set time, replacing a confirm dialog for destructive actions.

Built on
<button>Pointer eventsCSS transitionsaria-live
Client JavaScript
Yes, for state
Works without React
React only (needs JavaScript)
Source, gzipped
3.1 kB
Dependencies
React
Hold to confirm
Try its options

Loading its options.

Code
import { HoldButton } from "@/components/equip/hold-button";

export default function Demo() {
  return <HoldButton>Hold to delete project</HoldButton>;
}
Your versionchange its options in Look, This part

Examples

Durations

Match the hold to the weight of the action: under a second for a reversible one, two or more for something final.

Hold to confirmHold to confirmHold to confirm
Code
import { HoldButton } from "@/components/equip/hold-button";

export default function Example() {
  return (
    <div style={{ display: "flex", flexWrap: "wrap", gap: 8, alignItems: "center" }}>
      <HoldButton duration={600} variant="outline">Archive</HoldButton>
      <HoldButton duration={1200}>Remove member</HoldButton>
      <HoldButton duration={2400}>Wipe device</HoldButton>
    </div>
  );
}

Variants and sizes

Hold to confirmHold to confirmHold to confirmHold to confirm
Code
import { HoldButton } from "@/components/equip/hold-button";

export default function Example() {
  return (
    <div style={{ display: "flex", flexWrap: "wrap", gap: 8, alignItems: "center" }}>
      <HoldButton variant="danger" size="s">Discard draft</HoldButton>
      <HoldButton variant="solid">Force push</HoldButton>
      <HoldButton variant="outline" size="l">Reset settings</HoldButton>
      <HoldButton disabled>Delete workspace</HoldButton>
    </div>
  );
}

Revoke a token

Space or Enter held down works the same as a held pointer, and Escape lets go early.

ci-deployLast used 2 hours ago
Hold to confirm
Code
"use client";

import { useState } from "react";
import { HoldButton } from "@/components/equip/hold-button";

export default function Example() {
  const [revoked, setRevoked] = useState(false);
  return (
    <div
      style={{
        display: "flex",
        flexWrap: "wrap",
        gap: 12,
        alignItems: "center",
        justifyContent: "space-between",
        maxWidth: 420,
      }}
    >
      <div style={{ display: "grid", gap: 2, fontSize: 13 }}>
        <strong>ci-deploy</strong>
        <span style={{ color: "var(--eq-ink-2)" }}>{revoked ? "Revoked. Pipelines using it will fail." : "Last used 2 hours ago"}</span>
      </div>
      <HoldButton size="s" disabled={revoked} onConfirm={() => setRevoked(true)}>
        {revoked ? "Revoked" : "Revoke token"}
      </HoldButton>
    </div>
  );
}

Props

PropTypeDefaultWhat it does
durationnumber1200How long the button must be held, in milliseconds.
onConfirm() => voidnoneCalled once when a hold runs to the end.
variant"solid" | "danger" | "outline""danger"Visual weight. Danger for destructive actions, which is what this part is for.
size"s" | "m" | "l""m"Control height.
confirmedLabelstring"Confirmed"Read to screen readers when the hold completes.
...restComponentProps<"button">noneEvery native button attribute, including disabled and ref. Your own pointer, key and blur handlers run first.

Anatomy

root.eq-hold-buttonThe native button. data-state is idle, holding or done.
fill.eq-hold-button-fillSweeps across over the hold and retracts when let go.
check.eq-hold-button-checkShown in the done state.
label.eq-hold-button-label
hint.eq-hold-button-hintVisually hidden; describes the button as Hold to confirm.
status.eq-hold-button-statusVisually hidden polite live region that announces completion.

Keyboard

SpaceEnterHold down to fill the button; it confirms when the fill completes. Releasing early cancels.
EscapeCancels a hold in progress.

Notes

  • Holding Space or Enter works exactly like holding the pointer, and key auto-repeat is ignored.
  • Releasing early, moving the pointer off the button, losing focus or Escape all cancel, and the fill retracts.
  • Only the primary pointer button starts a hold, and a touch long press does not open the context menu.
  • A single click does nothing, so the native click that a screen reader's browse mode sends cannot confirm; offer another path where that matters.
  • With reduced motion the fill moves in four steps instead of gliding, so progress stays visible.
  • The hint and status live beside the button, not inside it, so they stay out of its accessible name.

Source

Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.

components/equip/hold-button.tsx202 lines
"use client";

import { useEffect, useId, useRef, useState } from "react";
import type { ComponentProps, CSSProperties, FocusEvent, KeyboardEvent, MouseEvent, PointerEvent } from "react";
import "./hold-button.css";

export type HoldButtonProps = ComponentProps<"button"> & {
  /** How long the button must be held, in milliseconds. */
  duration?: number;
  /** Called once when a hold runs to the end. */
  onConfirm?: () => void;
  variant?: "solid" | "danger" | "outline";
  size?: "s" | "m" | "l";
  /** Read to screen readers when the hold completes. */
  confirmedLabel?: string;
};

type HoldState = "idle" | "holding" | "done";

/** How long the done state shows before the button settles back to idle. */
const DONE_MS = 1200;

export function HoldButton({
  duration = 1200,
  onConfirm,
  variant = "danger",
  size = "m",
  confirmedLabel = "Confirmed",
  className,
  style,
  children,
  disabled,
  onPointerDown,
  onPointerUp,
  onPointerLeave,
  onPointerCancel,
  onKeyDown,
  onKeyUp,
  onBlur,
  onContextMenu,
  "aria-describedby": describedBy,
  ...rest
}: HoldButtonProps) {
  const hintId = `${useId()}-hint`;
  const [state, setState] = useState<HoldState>("idle");
  const [status, setStatus] = useState("");
  const stateRef = useRef<HoldState>("idle");
  const commitTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
  const doneTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
  const confirmRef = useRef(onConfirm);

  useEffect(() => {
    confirmRef.current = onConfirm;
  });

  function go(next: HoldState) {
    stateRef.current = next;
    setState(next);
  }

  function clearTimers() {
    if (commitTimer.current !== null) clearTimeout(commitTimer.current);
    if (doneTimer.current !== null) clearTimeout(doneTimer.current);
    commitTimer.current = null;
    doneTimer.current = null;
  }

  useEffect(() => clearTimers, []);

  // A button disabled mid-hold lets go.
  useEffect(() => {
    if (disabled && stateRef.current === "holding") cancel();
  });

  function start() {
    if (disabled || stateRef.current !== "idle") return;
    clearTimers();
    setStatus("");
    go("holding");
    commitTimer.current = setTimeout(() => {
      commitTimer.current = null;
      go("done");
      setStatus(confirmedLabel);
      confirmRef.current?.();
      doneTimer.current = setTimeout(() => {
        doneTimer.current = null;
        go("idle");
      }, DONE_MS);
    }, duration);
  }

  function cancel() {
    if (stateRef.current !== "holding") return;
    if (commitTimer.current !== null) clearTimeout(commitTimer.current);
    commitTimer.current = null;
    go("idle");
  }

  function isActivationKey(key: string) {
    return key === " " || key === "Enter";
  }

  function handlePointerDown(event: PointerEvent<HTMLButtonElement>) {
    onPointerDown?.(event);
    if (event.defaultPrevented || event.button !== 0) return;
    // Touch pointers are captured on press, which would hide a finger sliding off.
    const el = event.currentTarget;
    if (el.hasPointerCapture(event.pointerId)) el.releasePointerCapture(event.pointerId);
    start();
  }

  function handlePointerUp(event: PointerEvent<HTMLButtonElement>) {
    onPointerUp?.(event);
    cancel();
  }

  function handlePointerLeave(event: PointerEvent<HTMLButtonElement>) {
    onPointerLeave?.(event);
    cancel();
  }

  function handlePointerCancel(event: PointerEvent<HTMLButtonElement>) {
    onPointerCancel?.(event);
    cancel();
  }

  function handleKeyDown(event: KeyboardEvent<HTMLButtonElement>) {
    onKeyDown?.(event);
    if (event.defaultPrevented) return;
    if (isActivationKey(event.key)) {
      // Stops the native click, which would otherwise fire on press or release.
      event.preventDefault();
      if (!event.repeat) start();
    } else if (event.key === "Escape" && stateRef.current === "holding") {
      cancel();
    }
  }

  function handleKeyUp(event: KeyboardEvent<HTMLButtonElement>) {
    onKeyUp?.(event);
    if (isActivationKey(event.key)) {
      event.preventDefault();
      cancel();
    }
  }

  function handleBlur(event: FocusEvent<HTMLButtonElement>) {
    onBlur?.(event);
    cancel();
  }

  function handleContextMenu(event: MouseEvent<HTMLButtonElement>) {
    onContextMenu?.(event);
    // A long press on touch opens a context menu; it must not interrupt the hold.
    if (stateRef.current === "holding") event.preventDefault();
  }

  return (
    <>
      <button
        type="button"
        {...rest}
        className={["eq-hold-button", className].filter(Boolean).join(" ")}
        style={{ ...style, "--_hold": `${duration}ms` } as CSSProperties}
        data-variant={variant}
        data-size={size}
        data-state={state}
        disabled={disabled}
        aria-describedby={[describedBy, hintId].filter(Boolean).join(" ")}
        onPointerDown={handlePointerDown}
        onPointerUp={handlePointerUp}
        onPointerLeave={handlePointerLeave}
        onPointerCancel={handlePointerCancel}
        onKeyDown={handleKeyDown}
        onKeyUp={handleKeyUp}
        onBlur={handleBlur}
        onContextMenu={handleContextMenu}
      >
        <span className="eq-hold-button-fill" aria-hidden="true" />
        <svg
          className="eq-hold-button-check"
          viewBox="0 0 16 16"
          fill="none"
          stroke="currentColor"
          strokeWidth="1.5"
          strokeLinecap="round"
          strokeLinejoin="round"
          aria-hidden="true"
        >
          <path d="M3.5 8.5l3 3 6-7" pathLength={1} />
        </svg>
        <span className="eq-hold-button-label">{children}</span>
      </button>
      <span className="eq-hold-button-hint" id={hintId}>
        Hold to confirm
      </span>
      <span className="eq-hold-button-status" aria-live="polite">
        {status}
      </span>
    </>
  );
}
components/equip/hold-button.css177 lines
@layer equip {
  .eq-hold-button {
    --_bg: var(--eq-danger);
    --_ink: var(--eq-solid-ink);
    --_line: transparent;

    position: relative;
    isolation: isolate;
    overflow: hidden;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: var(--eq-space-2);
    min-width: 24px;
    height: var(--eq-h-m);
    padding: 0 var(--eq-space-3);
    border: 1px solid var(--_line);
    border-radius: var(--eq-radius-m);
    background: var(--_bg);
    color: var(--_ink);
    font: var(--eq-weight-strong) var(--eq-text-m) / 1 var(--eq-font);
    letter-spacing: -0.005em;
    white-space: nowrap;
    cursor: pointer;
    user-select: none;
    -webkit-user-select: none;
    -webkit-touch-callout: none;
    -webkit-tap-highlight-color: transparent;
    transition:
      background-color var(--eq-dur) var(--eq-ease),
      scale var(--eq-dur) var(--eq-ease);
  }

  .eq-hold-button[data-size="s"] {
    height: var(--eq-h-s);
    padding-inline: var(--eq-space-2);
    border-radius: var(--eq-radius-s);
    font-size: var(--eq-text-s);
    gap: var(--eq-space-1);
  }

  .eq-hold-button[data-size="l"] {
    height: var(--eq-h-l);
    padding-inline: var(--eq-space-4);
    font-size: var(--eq-text-l);
  }

  .eq-hold-button[data-variant="solid"] {
    --_bg: var(--eq-solid);
    --_ink: var(--eq-solid-ink);
  }

  .eq-hold-button[data-variant="outline"] {
    --_bg: transparent;
    --_ink: var(--eq-ink);
    --_line: var(--eq-line-strong);
  }

  @media (hover: hover) {
    .eq-hold-button[data-state="idle"]:hover:not(:disabled) {
      background: color-mix(in srgb, var(--_bg) 90%, var(--_ink));
    }

    .eq-hold-button[data-variant="outline"][data-state="idle"]:hover:not(:disabled) {
      background: var(--eq-tint);
    }
  }

  .eq-hold-button[data-state="holding"] {
    scale: var(--eq-press);
  }

  /* The hold lands: the button springs back out of the press as the check draws in. */
  .eq-hold-button[data-state="done"] {
    transition:
      background-color var(--eq-dur) var(--eq-ease),
      scale var(--eq-dur-move) var(--eq-spring);
  }

  .eq-hold-button:focus-visible {
    outline: 2px solid var(--eq-focus);
    outline-offset: 2px;
  }

  .eq-hold-button:disabled {
    opacity: 0.45;
    cursor: not-allowed;
  }

  /* The fill sweeps in over the hold and retracts quickly when the hold is let go.
     A transition takes the duration of the state it is heading to, so the long
     linear sweep sits on holding and the short retract sits on the base rule. */
  .eq-hold-button-fill {
    position: absolute;
    inset: 0;
    z-index: -1;
    background: color-mix(in srgb, var(--_ink) 26%, transparent);
    transform: scaleX(0);
    transform-origin: left center;
    /* Let go early and the fill glides back rather than snapping away. */
    transition: transform var(--eq-dur-move) var(--eq-ease);
    pointer-events: none;
  }

  .eq-hold-button-fill:dir(rtl) {
    transform-origin: right center;
  }

  .eq-hold-button[data-state="holding"] .eq-hold-button-fill {
    transform: scaleX(1);
    transition: transform var(--_hold, 1200ms) linear;
  }

  .eq-hold-button[data-state="done"] .eq-hold-button-fill {
    transform: scaleX(1);
    transition: none;
  }

  /* With reduced motion the sweep moves in four clear jumps instead of gliding,
     so the hold still shows how far along it is. */
  @media (prefers-reduced-motion: reduce) {
    .eq-hold-button[data-state="holding"] .eq-hold-button-fill {
      transition-timing-function: steps(4, end);
    }
  }

  .eq-hold-button-check {
    display: none;
    flex: none;
    width: 1.15em;
    height: 1.15em;
  }

  .eq-hold-button[data-state="done"] .eq-hold-button-check {
    display: block;
    scale: 1;
    transition: scale var(--eq-dur-move) var(--eq-spring);
  }

  .eq-hold-button-check > path {
    stroke-dasharray: 1;
    stroke-dashoffset: 0;
  }

  .eq-hold-button[data-state="done"] .eq-hold-button-check > path {
    transition: stroke-dashoffset var(--eq-dur-move) var(--eq-ease);
  }

  @starting-style {
    .eq-hold-button[data-state="done"] .eq-hold-button-check {
      scale: 0.6;
    }

    .eq-hold-button[data-state="done"] .eq-hold-button-check > path {
      stroke-dashoffset: 1;
    }
  }

  .eq-hold-button-label {
    display: inline-flex;
    align-items: center;
    gap: inherit;
  }

  .eq-hold-button-hint,
  .eq-hold-button-status {
    position: absolute;
    width: 1px;
    height: 1px;
    margin: -1px;
    padding: 0;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
    border: 0;
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index