Skip to content

HoldToConfirm

Irreversible actions, with accessible fallbacks.
Implemented
Status
Implemented
Family
8 of 13
Registry
@lopifi/hold-to-confirm
npx shadcn@latest add @lopifi/theme @lopifi/hold-to-confirm

The component, rendering

Real data, the shipped component. Switch the theme to check the claims the spec makes below.

Preview

The four-layer spec

clip-path: inset() fill, 1500ms linear press, 200ms --ease-out release, scale(0.97) on the button. Slow where the user is deciding, fast where the system is responding.

Accessible alternatives are mandatory: Space to hold with aria-live progress at 0/50/100%, Escape to abort, and a typed-confirmation fallback. Gating a financial action behind a motor-skill requirement is an accessibility failure, so hold-to-confirm is never the only path.

Lifted verbatim from System & states. The doc and this page are the same paragraphs, so they cannot disagree.

What lands in your repository

One component is never one file. Everything below is copied into your project, where you own it.

Files

components/ledger/hold-to-confirm.tsx

Lopifi dependencies · 3
buttoncnuse-media-query
npm packages
class-variance-authorityclsxtailwind-merge
Registry entry
/r/hold-to-confirm.json

The whole component

No compiled bundle, no black box. This is the file the registry copies, and the file you will edit.

components/ledger/hold-to-confirm.tsx
"use client";

import * as React from "react";
import { useReducedMotion } from "@/hooks/ledger/use-media-query";
import { cn } from "@/lib/ledger/cn";
import { Button } from "@/components/ledger/button";

export interface HoldToConfirmProps {
  className?: string;
  disabled?: boolean;
  durationMs?: number;
  label: string;
  onConfirm: () => void;
  /** The exact string a keyboard/typed fallback must match. */
  typedConfirmation?: string;
}

/**
 * Slow where the user is deciding, fast where the system is responding:
 * a 1500ms linear fill, a 200ms ease-out release.
 *
 * Gating a financial action behind a motor-skill requirement is an
 * accessibility failure, so hold-to-confirm is never the only path.
 * Space holds with aria-live progress at 0/50/100%, Escape aborts, and a
 * typed confirmation is always available.
 */
export function HoldToConfirm({
  label,
  onConfirm,
  durationMs = 1500,
  typedConfirmation,
  className,
  disabled = false,
}: HoldToConfirmProps) {
  const [progress, setProgress] = React.useState(0);
  const [announced, setAnnounced] = React.useState<string>("");
  const [typed, setTyped] = React.useState("");
  const [showTyped, setShowTyped] = React.useState(false);
  const raf = React.useRef<number | null>(null);
  const start = React.useRef<number>(0);
  const reduced = useReducedMotion();

  const stop = React.useCallback(() => {
    if (raf.current !== null) cancelAnimationFrame(raf.current);
    raf.current = null;
    setProgress(0);
    setAnnounced("");
  }, []);

  const begin = React.useCallback(() => {
    if (disabled || raf.current !== null) return;
    if (reduced) {
      // No motor-timed gesture under reduced motion; confirm on activation.
      onConfirm();
      return;
    }
    start.current = performance.now();
    setAnnounced("Holding, 0 percent");
    const step = (now: number) => {
      const p = Math.min(1, (now - start.current) / durationMs);
      setProgress(p);
      if (p >= 0.5 && p < 0.55) setAnnounced("Holding, 50 percent");
      if (p >= 1) {
        setAnnounced("Confirmed");
        raf.current = null;
        setProgress(0);
        onConfirm();
        return;
      }
      raf.current = requestAnimationFrame(step);
    };
    raf.current = requestAnimationFrame(step);
  }, [disabled, durationMs, onConfirm, reduced]);

  React.useEffect(() => () => stop(), [stop]);

  return (
    <div className={cn("flex flex-col items-stretch gap-2", className)}>
      <button
        aria-describedby="hold-help"
        className={cn(
          "relative h-12 select-none overflow-hidden rounded-sm bg-accent px-6",
          "text-label font-medium text-accent-fg",
          "transition-transform duration-1 ease-out",
          progress > 0 && "scale-[0.97]",
          disabled && "pointer-events-none opacity-50",
        )}
        // It scales while held, from its own progress; the global press would add a second.
        data-press="none"
        disabled={disabled}
        onKeyDown={(e) => {
          if (e.key === " " && !e.repeat) {
            e.preventDefault();
            begin();
          }
          if (e.key === "Escape") stop();
        }}
        onKeyUp={(e) => {
          if (e.key === " ") stop();
        }}
        onPointerCancel={stop}
        onPointerDown={(e) => {
          e.currentTarget.setPointerCapture(e.pointerId);
          begin();
        }}
        onPointerLeave={stop}
        onPointerUp={stop}
        type="button"
      >
        <span
          aria-hidden="true"
          className="absolute inset-0 bg-accent-active"
          style={{
            clipPath: `inset(0 ${100 - progress * 100}% 0 0)`,
            transition: progress === 0 ? "clip-path 200ms var(--ease-out)" : "none",
          }}
        />
        <span className="relative">{label}</span>
      </button>

      <span aria-live="polite" className="sr-only">
        {announced}
      </span>
      <span className="text-caption text-text-tertiary" id="hold-help">
        Press and hold, or hold Space. Escape cancels.
      </span>

      {typedConfirmation ? (
        showTyped ? (
          <div className="flex items-center gap-2">
            <input
              aria-label={`Type ${typedConfirmation} to confirm`}
              className="h-10 flex-1 rounded-sm border border-border bg-surface px-3 text-body outline-none focus-visible:border-accent"
              onChange={(e) => setTyped(e.target.value)}
              placeholder={typedConfirmation}
              value={typed}
            />
            <Button disabled={typed !== typedConfirmation} onClick={onConfirm} variant="primary">
              Confirm
            </Button>
          </div>
        ) : (
          <button
            className="self-start text-caption text-accent hover:underline"
            onClick={() => setShowTyped(true)}
            type="button"
          >
            Confirm by typing instead
          </button>
        )
      ) : null}
    </div>
  );
}

Also in System & states

See also

A component is specified in one place, defined in another, and running in a third. All three, from here.

Elsewhere in the system

Where HoldToConfirm sits in the rest of the system — the live section it renders in, the calculators that compute what it displays, and the ledger payloads it is fed from.

Components are copied into your repository and meant to be edited — rounding policy, fiscal calendar and compliance copy differ per company.

Told when System & states changes

Components are copied into your repo, so a fix here does not reach you on its own. One email when a component in this family changes in a way that needs a migration — and nothing when it does not.

One address, no list rental, unsubscribe in one click.

Works in your QuickBooks Online or Xero file

Get your books closed every month

Lopifi is building AI bookkeeping that keeps your books closed on US GAAP as transactions happen, with a CPA review every quarter, for $200 a month. We’re not open yet.

$200/month when we open. No card now.