Delete this workspace
Members, files and history go with it. There is no undo.
track("workspace.delete.abandoned", { workspaceId })}
onConfirm={async () => {
setPending(true);
await fetch(`/api/workspaces/${workspaceId}`, { method: "DELETE" });
router.push("/workspaces");
}}
>
Delete workspace
);
}
```
### Props
- `onConfirm` (`() => void`)
Fires once, only when the hold reaches full duration. A click never reaches it.
- `children` (`React.ReactNode`)
The resting label. It stays the button's accessible name in every state.
- `onAbort` (`() => void`) — default: `undefined`
Fires the moment a hold is released early, including a stray click. Useful for measuring how often people almost destroyed something.
- `confirmLabel` (`string`) — default: `"Confirmed"`
Shown after commit and announced once through a polite live region.
- `duration` (`number`) — default: `1800`
Milliseconds of continuous hold required. Also the number spoken in the screen reader hint.
- `resetAfter` (`number`) — default: `1600`
Milliseconds the confirmed state is held before the button returns to rest. Set to 0 to keep it confirmed and reset it yourself.
- `steps` (`number`) — default: `20`
Render budget for the hold. Progress is sampled this many times, and the sweep runs as one continuous animation independent of them.
- `releaseRate` (`number`) — default: `2.5`
How many times faster progress drains than it fills when you let go. Re-pressing mid-drain resumes from what is left.
- `disabled` (`boolean`) — default: `false`
Marked with aria-disabled rather than the disabled attribute, so focus is never dropped to the body mid-hold.
- `className` (`string`) — default: `""`
Appended last, so the button's surface, radius and width are overridable from outside.
### Behavior notes
- A click cannot confirm: the click event is prevented at every stage, so a mis-aimed pointer, a double-click on the row underneath, or a stray Enter on a focused button destroys nothing.
- Releasing early does not snap the progress to zero, it drains at a bounded rate, and pressing again resumes from whatever is left rather than restarting the count.
- The label does not change while you hold. A block sweeps across the button and the same text inverts inside it, so the only thing moving is the progress itself, and the button never changes width. Layout never reflows when the state changes.
- Progress arrives as twenty discrete steps rather than a float, so a 1.2 second hold costs twenty renders instead of eighty, and no React state is written per animation frame.
- Losing the window, hiding the tab, dragging past the move tolerance, or blurring the button all release the hold, so a hold can never survive in the background and fire when nobody is watching.
- Screen readers get a static hint naming the required hold time and one polite announcement at commit, never a stream of progress updates, and prefers-reduced-motion removes the springs while leaving the hold itself intact, because the delay is the guard rail and not decoration.
### Source (`components/interior/hold-to-confirm.tsx`)
```tsx
"use client";
import { useCallback, useEffect, useId, useRef, useState } from "react";
import {
animate,
motion,
useMotionValue,
useReducedMotion,
useTransform,
} from "motion/react";
const FACE = { type: "spring", stiffness: 260, damping: 34, mass: 0.8 } as const;
export type HoldPhase = "idle" | "holding" | "releasing" | "committed";
export type UseHoldToConfirmOptions = {
onConfirm: () => void;
onAbort?: () => void;
duration?: number;
steps?: number;
releaseRate?: number;
moveTolerance?: number;
haptic?: boolean;
disabled?: boolean;
};
export function useHoldToConfirm({
onConfirm,
onAbort,
duration = 1800,
steps = 20,
releaseRate = 2.5,
moveTolerance = 10,
haptic = true,
disabled = false,
}: UseHoldToConfirmOptions) {
const [step, setStep] = useState(0);
const [phase, setPhase] = useState