Actions
Grace button
A button that waits a few seconds before acting and offers Undo meanwhile, in place of an are you sure dialog.
- Built on
<button>CSS transitionsaria-live- Client JavaScript
- Yes, for state
- Works without React
- React only (needs JavaScript)
- Source, gzipped
- 3.2 kB
- Dependencies
- React
Try its options
Loading its options.
Code
import { GraceButton } from "@/components/equip/grace-button";
export default function Demo() {
return (
<GraceButton variant="accent" pendingText="Sending" doneLabel="Sent">
Send invoice
</GraceButton>
);
}Your versionchange its options in Look, This part
Examples
Delays
The number counts whole seconds and is what conveys progress when motion is reduced.
Code
import { GraceButton } from "@/components/equip/grace-button";
export default function Example() {
return (
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, alignItems: "center" }}>
<GraceButton delay={3} variant="outline" pendingText="Archiving" doneLabel="Archived">
Archive thread
</GraceButton>
<GraceButton delay={5} pendingText="Publishing" doneLabel="Published">
Publish post
</GraceButton>
<GraceButton delay={10} size="l" variant="outline" pendingText="Emailing" doneLabel="Emailed">
Email 1,240 customers
</GraceButton>
</div>
);
}Destructive, with a result
Clicking again or pressing Escape during the countdown cancels, and nothing has been deleted yet.
feature/billing-v2 has 3 unmerged commits.
Code
"use client";
import { useState } from "react";
import { GraceButton } from "@/components/equip/grace-button";
export default function Example() {
const [result, setResult] = useState("feature/billing-v2 has 3 unmerged commits.");
return (
<div style={{ display: "grid", gap: 8, justifyItems: "start" }}>
<GraceButton
variant="danger"
delay={5}
pendingText="Deleting"
doneLabel="Deleted"
onCommit={() => setResult("feature/billing-v2 was deleted.")}
onUndo={() => setResult("Kept feature/billing-v2.")}
>
Delete branch
</GraceButton>
<p style={{ margin: 0, fontSize: 13, color: "var(--eq-ink-2)" }}>{result}</p>
</div>
);
}Props
| Prop | Type | Default | What it does |
|---|---|---|---|
delay | number | 5 | Seconds between the click and the action, rounded to a whole number. |
onCommit | () => void | none | Runs once when the countdown reaches zero. |
onUndo | () => void | none | Runs when the countdown is cancelled. |
undoLabel | string | "Undo" | Label shown while the countdown runs. |
pendingText | string | "Starting" | Verb read at the start, as in Sending in 5 seconds. Press to undo. |
doneLabel | string | "Done" | Label shown, and read, for a moment after the action runs. |
variant | "solid" | "accent" | "outline" | "ghost" | "danger" | "solid" | Visual weight at rest. While counting down every variant shows as outline. |
size | "s" | "m" | "l" | "m" | Control height. |
...rest | ComponentProps<"button"> | none | Every native button attribute, including disabled and ref. Your onClick runs first and can preventDefault to stop the countdown. |
Anatomy
| root | .eq-grace-button | The native button. data-state is idle, pending or done. |
|---|---|---|
| face | .eq-grace-button-face | Stacks the three labels in one cell so the width never jumps. |
| label | .eq-grace-button-label | The idle label, from children. |
| undo | .eq-grace-button-undo | Shown while pending. |
| count | .eq-grace-button-count | Whole seconds left, in tabular mono. |
| done | .eq-grace-button-done | Shown for a moment after the action runs. |
| fill | .eq-grace-button-fill | Thin bar along the bottom that drains over the delay. |
| status | .eq-grace-button-status | Visually hidden polite live region. |
Keyboard
| EnterSpace | Starts the countdown, or cancels it while it runs. |
|---|---|
| Escape | Cancels a running countdown. |
Notes
- The live region speaks once at the start and once at the end, not on every tick.
- Escape during the countdown is kept from reaching a surrounding dialog, so it cancels the action without closing anything.
- Unmounting during the countdown cancels it and onCommit never runs, so commit server work from onCommit, not from the click.
- With reduced motion the seconds count still shows progress, and the thin bar keeps draining at a slow, even pace.
- Clicks during the brief done state are ignored, so a double click cannot start a second countdown.
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/grace-button.tsx163 lines
"use client";
import { useEffect, useRef, useState } from "react";
import type { ComponentProps, CSSProperties, KeyboardEvent, MouseEvent } from "react";
import "./grace-button.css";
export type GraceButtonProps = ComponentProps<"button"> & {
/** Seconds between the click and the action, during which it can be undone. */
delay?: number;
/** Runs once when the countdown reaches zero. */
onCommit?: () => void;
/** Runs when the countdown is cancelled. */
onUndo?: () => void;
/** Label shown while the countdown runs. */
undoLabel?: string;
/** Verb read to screen readers at the start, as in "Sending in 5 seconds". */
pendingText?: string;
/** Label shown, and read, for a moment after the action runs. */
doneLabel?: string;
variant?: "solid" | "accent" | "outline" | "ghost" | "danger";
size?: "s" | "m" | "l";
};
type GraceState = "idle" | "pending" | "done";
/** How long the done label shows before the button returns to idle. */
const DONE_MS = 1500;
export function GraceButton({
delay = 5,
onCommit,
onUndo,
undoLabel = "Undo",
pendingText = "Starting",
doneLabel = "Done",
variant = "solid",
size = "m",
className,
style,
children,
onClick,
onKeyDown,
...rest
}: GraceButtonProps) {
const seconds = Math.max(1, Math.round(delay));
const [state, setState] = useState<GraceState>("idle");
const [remaining, setRemaining] = useState(seconds);
const [status, setStatus] = useState("");
const stateRef = useRef<GraceState>("idle");
const tick = useRef<ReturnType<typeof setInterval> | null>(null);
const settle = useRef<ReturnType<typeof setTimeout> | null>(null);
const handlers = useRef({ onCommit, onUndo });
useEffect(() => {
handlers.current = { onCommit, onUndo };
});
function go(next: GraceState) {
stateRef.current = next;
setState(next);
}
function clearTimers() {
if (tick.current !== null) clearInterval(tick.current);
if (settle.current !== null) clearTimeout(settle.current);
tick.current = null;
settle.current = null;
}
// Unmounting during a countdown cancels it; nothing runs after the button is gone.
useEffect(() => clearTimers, []);
function start() {
clearTimers();
let left = seconds;
setRemaining(left);
setStatus(`${pendingText} in ${seconds} ${seconds === 1 ? "second" : "seconds"}. Press to undo.`);
go("pending");
tick.current = setInterval(() => {
left -= 1;
setRemaining(left);
if (left > 0) return;
clearTimers();
go("done");
setStatus(doneLabel);
handlers.current.onCommit?.();
settle.current = setTimeout(() => {
settle.current = null;
go("idle");
}, DONE_MS);
}, 1000);
}
function undo() {
clearTimers();
go("idle");
setRemaining(seconds);
setStatus("Cancelled");
handlers.current.onUndo?.();
}
function handleClick(event: MouseEvent<HTMLButtonElement>) {
onClick?.(event);
if (event.defaultPrevented) return;
if (stateRef.current === "idle") start();
else if (stateRef.current === "pending") undo();
}
function handleKeyDown(event: KeyboardEvent<HTMLButtonElement>) {
onKeyDown?.(event);
if (event.defaultPrevented) return;
if (event.key === "Escape" && stateRef.current === "pending") {
// Escape belongs to the countdown here, not to a dialog or page around it.
event.preventDefault();
event.stopPropagation();
undo();
}
}
return (
<>
<button
type="button"
{...rest}
className={["eq-grace-button", className].filter(Boolean).join(" ")}
style={{ ...style, "--_delay": `${seconds}s` } as CSSProperties}
data-variant={variant}
data-size={size}
data-state={state}
onClick={handleClick}
onKeyDown={handleKeyDown}
>
<span className="eq-grace-button-face">
<span className="eq-grace-button-label" data-active={state === "idle" || undefined}>
{children}
</span>
<span className="eq-grace-button-undo" data-active={state === "pending" || undefined}>
{undoLabel}
<span className="eq-grace-button-count">{remaining}</span>
</span>
<span className="eq-grace-button-done" data-active={state === "done" || undefined}>
<svg
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>
{doneLabel}
</span>
</span>
<span className="eq-grace-button-fill" aria-hidden="true" />
</button>
<span className="eq-grace-button-status" aria-live="polite">
{status}
</span>
</>
);
}components/equip/grace-button.css204 lines
@layer equip {
.eq-grace-button {
--_bg: var(--eq-solid);
--_ink: var(--eq-solid-ink);
--_line: transparent;
position: relative;
overflow: hidden;
display: inline-flex;
align-items: center;
justify-content: center;
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;
transition:
background-color var(--eq-dur) var(--eq-ease),
border-color var(--eq-dur) var(--eq-ease),
scale var(--eq-dur) var(--eq-ease);
}
.eq-grace-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);
}
.eq-grace-button[data-size="l"] {
height: var(--eq-h-l);
padding-inline: var(--eq-space-4);
font-size: var(--eq-text-l);
}
.eq-grace-button[data-variant="accent"] {
--_bg: var(--eq-accent);
--_ink: var(--eq-accent-ink);
--_line: color-mix(in srgb, var(--eq-accent-ink) 55%, var(--eq-accent));
}
.eq-grace-button[data-variant="outline"] {
--_bg: transparent;
--_ink: var(--eq-ink);
--_line: var(--eq-line-strong);
}
.eq-grace-button[data-variant="ghost"] {
--_bg: transparent;
--_ink: var(--eq-ink);
}
.eq-grace-button[data-variant="danger"] {
--_bg: var(--eq-danger);
--_ink: var(--eq-solid-ink);
}
/* While the countdown runs the button steps back, so it reads as a way out
rather than the action itself. */
.eq-grace-button[data-state="pending"] {
--_bg: transparent;
--_ink: var(--eq-ink);
--_line: var(--eq-line-strong);
}
@media (hover: hover) {
.eq-grace-button:hover:not(:disabled) {
background: color-mix(in srgb, var(--_bg) 88%, var(--_ink));
}
.eq-grace-button:is([data-variant="outline"], [data-variant="ghost"], [data-state="pending"]):hover:not(:disabled) {
background: var(--eq-tint);
}
}
.eq-grace-button:active:not(:disabled) {
scale: var(--eq-press);
}
.eq-grace-button[data-state="done"] {
cursor: default;
}
.eq-grace-button:focus-visible {
outline: 2px solid var(--eq-focus);
outline-offset: 2px;
}
.eq-grace-button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
/* All three faces share one cell, so the button keeps the width of the widest
and never jumps when the label changes. */
.eq-grace-button-face {
display: grid;
justify-items: center;
}
.eq-grace-button-label,
.eq-grace-button-undo,
.eq-grace-button-done {
grid-area: 1 / 1;
display: inline-flex;
align-items: center;
gap: var(--eq-space-2);
visibility: hidden;
opacity: 0;
transition:
opacity var(--eq-dur) var(--eq-ease),
visibility var(--eq-dur);
}
.eq-grace-button[data-size="s"] :is(.eq-grace-button-undo, .eq-grace-button-done) {
gap: var(--eq-space-1);
}
:is(.eq-grace-button-label, .eq-grace-button-undo, .eq-grace-button-done)[data-active] {
visibility: visible;
opacity: 1;
}
/* The action lands: the check springs up and draws itself. Leaving, it holds still while
the face fades and resets small and undrawn only once it is out of sight. */
.eq-grace-button-done > svg {
flex: none;
width: 1.15em;
height: 1.15em;
scale: 0.6;
transition: scale var(--eq-dur) step-end;
}
.eq-grace-button-done > svg > path {
stroke-dasharray: 1;
stroke-dashoffset: 1;
transition: stroke-dashoffset var(--eq-dur) step-end;
}
.eq-grace-button-done[data-active] > svg {
scale: 1;
transition: scale var(--eq-dur-move) var(--eq-spring);
}
.eq-grace-button-done[data-active] > svg > path {
stroke-dashoffset: 0;
transition: stroke-dashoffset var(--eq-dur-move) var(--eq-ease);
}
.eq-grace-button-count {
min-width: 2ch;
font-family: var(--eq-font-mono);
font-variation-settings: var(--eq-font-mono-settings);
font-variant-numeric: tabular-nums;
text-align: end;
}
/* The fill drains from full to empty over the delay. It stays mounted, full and
hidden at rest, so entering pending is the change that starts the transition. */
.eq-grace-button-fill {
position: absolute;
inset-inline: 0;
bottom: 0;
height: 2px;
background: currentColor;
opacity: 0;
transform: scaleX(1);
transform-origin: left center;
/* Leaving pending, the bar fades where it stands and only then resets to full, out of sight. */
transition:
opacity var(--eq-dur) var(--eq-ease),
transform var(--eq-dur) step-end;
pointer-events: none;
}
.eq-grace-button-fill:dir(rtl) {
transform-origin: right center;
}
.eq-grace-button[data-state="pending"] .eq-grace-button-fill {
opacity: 0.55;
transform: scaleX(0);
transition: transform var(--_delay, 5s) linear;
}
.eq-grace-button-status {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
}