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
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.
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
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
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
| Prop | Type | Default | What it does |
|---|---|---|---|
duration | number | 1200 | How long the button must be held, in milliseconds. |
onConfirm | () => void | none | Called 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. |
confirmedLabel | string | "Confirmed" | Read to screen readers when the hold completes. |
...rest | ComponentProps<"button"> | none | Every native button attribute, including disabled and ref. Your own pointer, key and blur handlers run first. |
Anatomy
| root | .eq-hold-button | The native button. data-state is idle, holding or done. |
|---|---|---|
| fill | .eq-hold-button-fill | Sweeps across over the hold and retracts when let go. |
| check | .eq-hold-button-check | Shown in the done state. |
| label | .eq-hold-button-label | |
| hint | .eq-hold-button-hint | Visually hidden; describes the button as Hold to confirm. |
| status | .eq-hold-button-status | Visually hidden polite live region that announces completion. |
Keyboard
| SpaceEnter | Hold down to fill the button; it confirms when the fill completes. Releasing early cancels. |
|---|---|
| Escape | Cancels 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;
}
}