Skip to content

Hold button

A destructive confirmation that fills while you press and hold.

Status
Stable
Category
Actions
Adoption
Not used yet
import { HoldButton } from "@oration/canon/components/hold-button";
packages/canon/src/components/hold-button.tsx

Delete Northwind Freight

Removes the supplier, its bank details and 38 invoices. Paid remittances stay in your reports.

import { Button } from "@oration/canon/components/button";import { HoldButton } from "@oration/canon/components/hold-button";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function Hero() {    const [deleted, setDeleted] = React.useState(false);    return (        <div className="flex w-full max-w-xl flex-col gap-4 rounded-xl bg-card p-4 text-left shadow-border sm:flex-row sm:items-center sm:justify-between">            <div className="flex min-w-0 flex-col gap-1">                <p className="text-sm font-medium">Delete Northwind Freight</p>                <p className="text-13 text-pretty text-muted-foreground">                    {deleted                        ? "Deleted. Its 38 invoices moved to the archive."                        : "Removes the supplier, its bank details and 38 invoices. Paid remittances stay in your reports."}                </p>            </div>            {deleted ? (                <Button                    type="button"                    variant="outline"                    onClick={() => setDeleted(false)}                >                    Restore supplier                </Button>            ) : (                <HoldButton                    holdingLabel="Keep holding to delete"                    onConfirm={() => {                        setDeleted(true);                        toast.add({                            type: "success",                            title: "Northwind Freight deleted",                            description:                                "Its 38 invoices moved to the archive.",                        });                    }}                >                    Hold to delete supplier                </HoldButton>            )}        </div>    );}

Usage#

Hold button confirms a destructive action by a sustained press: a solid red fill sweeps across the 10% red tint over two seconds, and the action runs only if you hold to the end. Letting go early snaps the fill back. Settings uses it to delete agents, workspaces and accounts, often behind a typed name. It replaces a second confirmation dialog, not the first; the common mistake is using it for everyday deletes that can be undone, where a toast with Undo is kinder.

When to use

  • For a delete that can't be undone: a supplier with its bank details, an agent, a workspace, an account.
  • As the confirming action in a danger zone or a delete dialog, beside Cancel.
  • After a typed confirmation for the most serious deletes, disabled until the name matches.

When not to use

  • For a delete that can be undone. Delete at once and offer Undo in a toast. Use Toast
  • For a destructive action that needs explanation and a choice first. Use Alert dialog
  • For a non-destructive action. Holding is friction reserved for loss. Use Button
  • For an action that waits on the network after one press. Use Pending button

Destructive is a tint

At rest it is the 10% Signal Red tint with red text, like every destructive button. The solid red appears only as progress while held.

The label says hold

The resting label starts with Hold to and names what goes: Hold to delete supplier. Nothing else on screen tells people to keep pressing.

Anatomy#

  1. Container. 32px, 10px corners, 12px side padding, the 10% red tint (20% in dark) and 14px medium red text.
  2. Label. The children, in red on the tint.
  3. Fill. A solid red layer with white text, holdingLabel while held. It is clipped away at rest.
  4. Sweep edge. The fill's leading edge, moving left to right in linear time over duration.

Examples#

Duration

Two seconds by default. Shorter for reversible removals, up to three seconds for the most serious ones. Let go early and the fill snaps back in 200ms.

import { HoldButton } from "@oration/canon/components/hold-button";import { toast } from "@oration/canon/components/toast";export function Durations() {    const durations = [1000, 2000, 3000];    return (        <div className="flex flex-wrap items-center gap-3">            {durations.map((duration) => (                <HoldButton                    key={duration}                    duration={duration}                    onConfirm={() =>                        toast.add({                            title: `Confirmed after ${duration / 1000} ${duration === 1000 ? "second" : "seconds"}`,                        })                    }                >                    Hold {duration / 1000}s to remove                </HoldButton>            ))}        </div>    );}

Holding label

While held, the solid red fill sweeps across with holdingLabel on it, Keep holding by default. Name the consequence when it isn't obvious.

import { HoldButton } from "@oration/canon/components/hold-button";import { toast } from "@oration/canon/components/toast";export function HoldingLabel() {    return (        <div className="flex flex-wrap items-center gap-3">            <HoldButton                onConfirm={() => toast.add({ title: "Bank details removed" })}            >                Hold to remove bank details            </HoldButton>            <HoldButton                holdingLabel="Keep holding to cancel the run"                onConfirm={() =>                    toast.add({                        title: "Payment run canceled",                        description:                            "212 invoices are back in the approval queue.",                    })                }            >                Hold to cancel run            </HoldButton>        </div>    );}

Type to confirm

For deleting a workspace or an account, as settings does: the hold button stays disabled until the typed name matches, and Cancel sits beside it.

import { Button } from "@oration/canon/components/button";import {  Dialog,  DialogContent,  DialogDescription,  DialogFooter,  DialogHeader,  DialogTitle,  DialogTrigger,} from "@oration/canon/components/dialog";import { Field, FieldLabel } from "@oration/canon/components/field";import { HoldButton } from "@oration/canon/components/hold-button";import { Input } from "@oration/canon/components/input";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function TypeToConfirm() {    const id = React.useId();    const [open, setOpen] = React.useState(false);    const [typed, setTyped] = React.useState("");    const name = "cedarline-ap";    const matches = typed.trim() === name;    return (        <Dialog            open={open}            onOpenChange={(next) => {                setOpen(next);                if (!next) setTyped("");            }}        >            <DialogTrigger                render={<Button type="button" variant="destructive" />}            >                Delete workspace            </DialogTrigger>            <DialogContent className="sm:max-w-md">                <DialogHeader>                    <DialogTitle>                        Delete the Cedarline AP workspace?                    </DialogTitle>                    <DialogDescription>                        Members lose access right away, and every supplier,                        invoice and payment run is removed. This can't be                        undone.                    </DialogDescription>                </DialogHeader>                <Field>                    <FieldLabel htmlFor={id} className="font-normal">                        Type <code className="font-mono text-13">{name}</code>{" "}                        to confirm                    </FieldLabel>                    <Input                        id={id}                        autoComplete="off"                        spellCheck={false}                        value={typed}                        onChange={(event) => setTyped(event.target.value)}                    />                </Field>                <DialogFooter className="items-center">                    <Button                        type="button"                        variant="outline"                        onClick={() => setOpen(false)}                    >                        Cancel                    </Button>                    <HoldButton                        disabled={!matches}                        holdingLabel="Keep holding to delete"                        onConfirm={() => {                            setOpen(false);                            setTyped("");                            toast.add({                                type: "info",                                title: "Cedarline AP wasn't deleted",                                description:                                    "This is a demo, so nothing was removed.",                            });                        }}                    >                        Hold to delete workspace                    </HoldButton>                </DialogFooter>            </DialogContent>        </Dialog>    );}

States#

Rest
Hover
Focus
Holding
Disabled
import { HoldButton } from "@oration/canon/components/hold-button";export function StatesRow() {    const states = [        { name: "Rest", className: "", disabled: false },        { name: "Hover", className: "bg-destructive/15", disabled: false },        {            name: "Focus",            className: "ring-3 ring-destructive/25",            disabled: false,        },        {            name: "Holding",            className: "[&>span[aria-hidden]]:[clip-path:inset(0_45%_0_0)]",            disabled: false,        },        { name: "Disabled", className: "", disabled: true },    ];    return (        <div className="grid w-full grid-cols-2 gap-4 sm:grid-cols-5" inert>            {states.map((state) => (                <div                    key={state.name}                    className="flex flex-col items-center gap-2"                >                    <span className="text-xs text-muted-foreground">                        {state.name}                    </span>                    <HoldButton                        onConfirm={() => undefined}                        disabled={state.disabled}                        className={state.className}                    >                        Hold to delete                    </HoldButton>                </div>            ))}        </div>    );}
States
StateTreatment
RestThe 10% red tint with red text.
HoverThe tint deepens to 15% over 150ms.
Focus visibleA 3px red ring at 25%.
Holdingdata-holding is set; the fill sweeps across in linear time over duration, showing holdingLabel. The button scales to 0.97 when motion is allowed.
Released earlyThe fill snaps back in 200ms ease-out and nothing happens.
ConfirmedonConfirm runs once and the button resets. Close the dialog or replace the button with the result.
Disabled50% opacity and no pointer events, for example until a typed name matches.

Behavior#

  • Pressing with a pointer, or holding Enter or Space, starts a timer for duration (2000ms). Key repeats are ignored.
  • Lifting the pointer or key, leaving the button, a pointer cancel or losing focus all cancel the hold.
  • When the timer completes, onConfirm runs once and the hold resets. The timer is cleared on unmount.
  • The fill is a clip-path transition, linear while holding and 200ms ease-out on release, so it always shows how far along the hold is.
  • It renders a native <button type="button">; it never submits a form.

Do and don't#

Do. Say Hold to in the label and name what goes, with a holding label that names the outcome.
Don't. Label it Delete. People click, nothing happens, and they click again.

Content#

  • Resting label: Hold to delete supplier, Hold to delete workspace. Verb, object, no punctuation.
  • Holding label: Keep holding to delete, or the specific outcome such as Keep holding to cancel the run.
  • The text around it states the loss in numbers: Removes the supplier, its bank details and 38 invoices.
  • After it confirms, say what happened in a toast, and what remains: Paid remittances stay in your reports.

Accessibility#

  • It is a native button named by its children. The fill and the holding label are aria-hidden, so screen readers hear only the resting label; it must say Hold to.
  • Keyboard users hold Enter or Space for the same duration. Moving focus away cancels.
  • Holding is a timing requirement. Keep duration at two seconds or less for most actions, and never use a hold for anything that isn't destructive.
  • Progress isn't announced. Confirm the outcome with a toast or by moving focus to the result.
  • The sweep is the only feedback that the hold is working, so it runs even with reduced motion; the press scale doesn't.
Keyboard interactions
KeysAction
TabMoves focus to the button.
EnterHold for the duration to confirm. Releasing early cancels.
SpaceHold for the duration to confirm. Releasing early cancels.

Design tokens#

Design tokens
TokenUsed for
--destructiveText, 10% to 20% tints, the solid fill and the 25% focus ring
--destructive-foregroundHolding label on the fill
--radius-lg10px corners

API reference#

HoldButton

A destructive button that confirms after a sustained press. It takes only the props below.

Props of HoldButton
PropTypeDefaultDescription
childrenRequiredReactNodeNo defaultThe resting label. Start with Hold to.
onConfirmRequired() => voidNo defaultRuns once when the hold completes.
durationnumber2000How long to hold, in milliseconds.
holdingLabelReactNode"Keep holding"Shown on the fill while held.
disabledbooleanNo defaultDims to 50% and blocks the hold.
classNamestringNo defaultMerged onto the button.

Known gaps#

Where the implementation and the system disagree today. Follow the system, not the gap.

Any pointer button starts the hold, including a right-click, because onPointerDown doesn't check event.button.

The focus ring is red at 25% while destructive Buttons use 20%, and there is no size prop: it is 32px only.

There is no accessible hint or progress: screen reader users depend on the label saying Hold to, and nothing announces that the hold has started.

It takes no ref, id or aria-* props, so it can't be described by nearby text with aria-describedby.