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

PropTypeDefaultWhat it does
delaynumber5Seconds between the click and the action, rounded to a whole number.
onCommit() => voidnoneRuns once when the countdown reaches zero.
onUndo() => voidnoneRuns when the countdown is cancelled.
undoLabelstring"Undo"Label shown while the countdown runs.
pendingTextstring"Starting"Verb read at the start, as in Sending in 5 seconds. Press to undo.
doneLabelstring"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.
...restComponentProps<"button">noneEvery native button attribute, including disabled and ref. Your onClick runs first and can preventDefault to stop the countdown.

Anatomy

root.eq-grace-buttonThe native button. data-state is idle, pending or done.
face.eq-grace-button-faceStacks the three labels in one cell so the width never jumps.
label.eq-grace-button-labelThe idle label, from children.
undo.eq-grace-button-undoShown while pending.
count.eq-grace-button-countWhole seconds left, in tabular mono.
done.eq-grace-button-doneShown for a moment after the action runs.
fill.eq-grace-button-fillThin bar along the bottom that drains over the delay.
status.eq-grace-button-statusVisually hidden polite live region.

Keyboard

EnterSpaceStarts the countdown, or cancels it while it runs.
EscapeCancels 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;
  }
}

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