# Hold Button (Lumen Halo): prompt.md (v1.0.0)

- id: `lumen-hold-button` · version 1.0.0 · component · free
- category: Actions
- build: Base UI (one set of files; its dependencies follow the build)
- install (this build): `npx shadcn@latest add https://beautiful-ui.dev/r/lumen-hold-button.json`
- npm dependencies: none
- registry dependencies: utils, @beautiful-ui/lumen-button
- docs: https://beautiful-ui.dev/components/lumen-hold-button
- The install command carries everything this item needs (files, CSS, tokens, npm and registry dependencies). Prefer it to copying source by hand.

For actions that shouldn't happen by accident: press and keep holding until twelve cells fill. Only a full, unbroken hold confirms; let go early and it drains and tells you what's needed. A promise from onConfirm shows it working, then done.

## Build it
- Stack: React 19 (`ref` is a plain prop), TypeScript, Tailwind CSS v4 utilities, a shadcn-initialised project with the `@/*` alias.
- Packages: none beyond React.
- Files: `components/ui/lumen/controls/hold-button.tsx`.
- Registry dependencies, installed with it automatically: shadcn `utils` (cn), `lumen-button`.
- Builds: one set of files for both, but its dependencies come in Base UI and Radix builds. Install the one that matches the project (see Install): a free item's bare URL installs the Base UI build of it and its dependencies.
- Exports to keep: `HoldButton`, and every exported type.
- CSS: the install adds the lumen foundation (tokens, keyframes, motion levels) to your global stylesheet once. Nothing to import by hand.
- Re-running `add` (or `--overwrite`) re-applies those rules: put overrides in your own CSS, never in the installed rules.
- Tokens: retheme with the `--lumen-*` custom properties (`--lumen-accent`, `--lumen-accent-foreground`, `--lumen-accent-text`, `--lumen-bad`, `--lumen-bad-text`, `--lumen-chart-accent`, `--lumen-chart-accent-foreground`, `--lumen-chart-accent-text`, `--lumen-focus`, `--lumen-good`, `--lumen-good-text`, `--lumen-hairline`, `--lumen-ink`, `--lumen-muted-ink`, `--lumen-series-1`, `--lumen-series-2`, `--lumen-series-3`, `--lumen-series-4`, `--lumen-series-5`, `--lumen-series-6`, `--lumen-warn`, `--lumen-warn-text`). Never add Tailwind colour classes inside the component.

```tsx
import { HoldButton } from "@/components/ui/lumen/controls/hold-button";
```

## Parts

| Part | data-slot | What it is for |
|---|---|---|
| `HoldButton` | — | The button. |

## Sound
- Keep every `data-slot` and `data-sound` attribute: the sound layer reads them.
- Installing this item adds no audio. Nothing plays until the app mounts `GlassSoundProvider` once (install: `npx shadcn@latest add https://beautiful-ui.dev/r/glass-sound.json`, import from `@/components/beautiful-ui/glass-sound`); `GlassSoundToggle` is its mute control. Without a provider the audio engine never loads.

## Match the original
- Read `components/ui/lumen/controls/hold-button.tsx` as the reference implementation before changing or recreating anything, and match it: sizes, colours per theme, motion timings, copy and behaviour.
- If you deviate (a prop you can't honour, a style you changed, a dependency you swapped), say so in your reply, part by part.
- Keep the accessibility contract, the keyboard map and the motion levels listed below.

## Use it when
- hold to confirm, press and hold, hold to delete, long press confirm, destructive confirm, Lumen
- One destructive action where a dialog is too heavy: delete a file, revoke a key, leave a team

### Not when
- Actions people do many times a day: the hold slows them down
- Actions that need a second look at what's affected: use Safe Action or Alert Dialog

## Mistakes
- Say what will happen in the label (Hold to delete), not just Hold

## Usage

```tsx
"use client";
import { HoldButton } from "@/components/ui/lumen/controls/hold-button";

async function deleteFile(id: string) {
  await fetch(`/api/files/${id}`, { method: "DELETE" });
}

export function DeleteFileButton({ id }: { id: string }) {
  // Return the promise: the button shows it working, then "Deleted" (or "Try again" if it throws).
  return (
    <HoldButton icon="trash" onConfirm={() => deleteFile(id)} doneLabel="Deleted">
      Hold to delete
    </HoldButton>
  );
}
```

## Props

| Prop | Type | Default | What it does |
|---|---|---|---|
| `motion` | `"full" \| "subtle" \| "off"` | `"full"` | As on Button: subtle is shorter on a calm curve; off is instant. Reduced motion is respected automatically. |
| `onConfirm` | `() => unknown` |  | Runs after a full hold. Return a promise to show it working; a rejection shows errorLabel. |
| `children` | `ReactNode` |  | The label, such as "Hold to delete". |
| `holdMs` | `number` | `1200` | How long the hold takes. |
| `doneLabel / errorLabel` | `string / ReactNode` | `"Done" / "Try again"` | After the work finishes. |
| `holdingLabel / hintLabel` | `string` | `"Keep holding" / "Press and hold to confirm"` | While held, and for a moment after letting go too early (also the spoken description). |
| `variant / size / icon` | `Button props` | `"destructive"` | As on Button. |
| `onError` | `(error) => void` |  | Receives the failure. |

Full docs: https://beautiful-ui.dev/components/lumen-hold-button

## Customising
- Motion: `motion="full"` (default) | `"subtle"` | `"off"`. prefers-reduced-motion is always respected.
- Colours: set the `--lumen-*` tokens on `:root`, or on a container with the `lumen-scope` class to retheme one area. Add the `lumen-inherit` class to follow your shadcn palette instead (`--chart-N`, `--destructive`).
- Dark mode follows the `.dark` class on an ancestor (the shadcn and next-themes convention).
- Update later by re-running the install with `--overwrite` (review the diff if you edited it). Changelog: https://beautiful-ui.dev/r/changelog.json

## Keyboard

| Keys | Action |
|---|---|
| Hold Space or Enter | Fill and confirm |
| Escape | Let go |

## Motion inventory

| Interaction | What moves |
|---|---|
| Hold | Twelve cells fill over holdMs; a quiet tick at each quarter |
| Let go early | The cells drain and the label says to keep holding |
| Done | A check draws in; a delete stays red |

## Accessibility contract (preserve when editing)
- The hold has a spoken description (Press and hold to confirm)
- Space or Enter held does the same as a pointer; Escape lets go

## Install

Base UI project (a base-* style in components.json):

```bash
npx shadcn@latest add https://beautiful-ui.dev/r/lumen-hold-button.json
```

Radix project (a radix-*, new-york or default style):

```bash
npx shadcn@latest add https://beautiful-ui.dev/r/radix-nova/lumen-hold-button.json
```

Or add the `@beautiful-ui` registry to components.json and run `npx shadcn@latest add @beautiful-ui/lumen-hold-button`: the CLI picks the build from your style.

## Credits
- Built on shadcn/ui (https://ui.shadcn.com)

## Source (Base UI build)

### components/ui/lumen/controls/hold-button.tsx

```tsx
"use client";
// Generated from src/registry/core/controls/hold-button.tsx by scripts/gen-systems.ts. Edit the core file, not this one.

import * as React from "react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/lumen/button";

/*
 * Hold Button: for actions that shouldn't happen by accident. Press and keep holding (finger, mouse,
 * or Space/Enter): twelve cells fill, and only a full, unbroken hold confirms. Let go early and the
 * cells drain and the label says what's needed; an earlier attempt never counts toward the next.
 * If onConfirm returns a promise, the button shows it working, then done (still red for a delete).
 * It's Lumen Button's hold, packaged with the async part.
 *
 *   <HoldButton onConfirm={() => api.deleteProject(id)} doneLabel="Deleted">Hold to delete</HoldButton>
 */

type Phase = "idle" | "loading" | "success" | "error";

function HoldButton({
  onConfirm,
  children,
  holdMs = 1200,
  doneLabel = "Done",
  errorLabel = "Try again",
  holdingLabel = "Keep holding",
  hintLabel = "Press and hold to confirm",
  resetMs = 2000,
  variant = "destructive",
  onError,
  className,
  ...props
}: Omit<React.ComponentProps<typeof Button>, "onClick" | "onConfirm" | "holdToConfirm" | "state" | "children" | "labels" | "asChild" | "render"> & {
  /** Runs after a full hold. Return a promise to show it working. */
  onConfirm: () => unknown;
  children: React.ReactNode;
  /** How long the hold takes. Default 1200ms. */
  holdMs?: number;
  doneLabel?: string;
  errorLabel?: React.ReactNode;
  /** Shown while held. */
  holdingLabel?: string;
  /** Shown for a moment after letting go too early, and read as the button's description. */
  hintLabel?: string;
  /** How long done or error shows. Default 2000ms. */
  resetMs?: number;
  onError?: ((error: unknown) => void) | undefined;
}) {
  const [phase, setPhase] = React.useState<Phase>("idle");
  const mounted = React.useRef(true);
  const timer = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
  React.useEffect(() => {
    mounted.current = true;
    return () => {
      mounted.current = false;
      clearTimeout(timer.current);
    };
  }, []);

  const confirm = async () => {
    setPhase("loading");
    let failed = false;
    try {
      await onConfirm();
    } catch (err) {
      failed = true;
      // Your logging can't break the button: it still shows the failure and resets.
      try {
        onError?.(err);
      } catch (cbErr) {
        console.error(cbErr);
      }
    }
    if (!mounted.current) return;
    setPhase(failed ? "error" : "success");
    clearTimeout(timer.current);
    timer.current = setTimeout(() => setPhase("idle"), resetMs);
  };

  return (
    <Button
      data-slot="hold-button"
      variant={variant}
      holdToConfirm={phase === "idle" ? holdMs : undefined}
      onConfirm={confirm}
      state={phase}
      confirmResetMs={resetMs}
      labels={{ holdHint: hintLabel, holding: holdingLabel, confirmed: doneLabel }}
      className={cn(`lumen-hold-button`, className)}
      {...props}
    >
      {phase === "success" ? doneLabel : phase === "error" ? errorLabel : children}
    </Button>
  );
}

export { HoldButton };
```
