# Hold Button (Moonarc)

A button that asks to be held for a moment before it acts, for the one action on a page that should not happen by a slip: delete, cancel a booking, send for good. While it is held (a pointer down on it, or Space or Enter held on it) a fill crosses it; let go early and the fill drains back and nothing happens; hold to the end and it acts: it submits its form, or a listener on ma:hold takes it from there. The line under it says what to do and says when it is done, to a screen reader too. Without JavaScript it is an ordinary button that acts on a press.

- Import: `import HoldButton from '@moonarc/core/HoldButton'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/hold-button.json`
- Tier B · category ui · trigger click
- Readout: `<HoldButton label="Delete the project" type="submit" />`
- Browser support: widely (every browser); elsewhere: without JavaScript it acts on a press, as any button
- Measured cost: 984 B raw JS · 538 B gzip · + runtime (with dependencies 2.6 kB raw; CSS 5.3 kB raw)
- Page: https://moonarc.dev/components/hold-button/

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `label` | `string` | none | The button's words. |
| `duration` | `number` | `1200` | How long it is held, in ms: 400 to 5000. |
| `hint` | `string` | `'Press and hold'` | The line under it while it waits; the button points at it with aria-describedby. |
| `done` | `string` | `'Done'` | The line once it has acted. |
| `type` | `'button' | 'submit'` | `'button'` | submit sends its form at the end of the hold. |
| `colour` | `string` | `currentColor at 22%` | The fill's colour. |

## Usage

```astro
<form method="post" action="/projects/42/delete">
  <HoldButton label="Delete the project" type="submit" duration={1500} hint="Press and hold to delete" done="Deleted" />
</form>
```

## Reduced motion

The fill still crosses the button, in four steps, because it is the count and not decoration.

## With ClientRouter

Bound per element through the runtime; a fresh page binds again.

## Craft

- A click that did not come through the hold is stopped in the capture phase, so a tap, a slip or a quick Enter does nothing.
- The hold is a setTimeout of the same duration as the fill's transition; the fill is CSS, the timer only decides.
- Pointer capture keeps the hold while the pointer drifts off the button; a release, a cancel, a blur or a lost capture lets go.

## Replaces

- Hold to Confirm (Kokonut)
- Hold button (Animata)

## Source

```astro
---
/**
 * HoldButton — a button that asks to be held for a moment before it acts, for the one action on a page that should not
 * happen by a slip: delete, cancel a booking, send for good. While it is held (a pointer down on it, or Space or Enter
 * held on it) a fill crosses it; let go early and the fill drains back and nothing happens; hold to the end and it acts
 * as a click would (it submits its form, or a listener on `ma:hold` takes it from there). The line under it says what
 * to do, and says when it is done, to a screen reader too. Without JavaScript it is an ordinary button, which acts on a
 * press: a page that must not act without the hold has to wait for the script itself.
 */
import type { HTMLAttributes } from 'astro/types';

interface Props extends HTMLAttributes<'button'> {
  /** The button's words. */
  label: string;
  /** How long it is held, in ms. */
  duration?: number;
  /** The line under it while it waits, and once it has acted. */
  hint?: string;
  done?: string;
  /** The button's type: submit to send its form at the end of the hold. */
  type?: 'button' | 'submit';
  /** The fill's colour. */
  colour?: string;
}

const { label, duration = 1200, hint = 'Press and hold', done = 'Done', type = 'button', colour, class: className, style, ...rest } = Astro.props;
if (duration < 400 || duration > 5000) throw new Error(`HoldButton: duration is ${duration} ms; it takes 400 to 5000.`);
const uid = `ma-hold-${Math.random().toString(36).slice(2, 7)}`;
const vars = [`--ma-hold-ms:${duration}ms`, colour ? `--ma-hold-fill:${colour}` : '', typeof style === 'string' ? style : ''].filter(Boolean).join(';');
---

<span class:list={['ma-hold', className]} data-ma-hold>
  <button class="ma-hold__button" type={type} aria-describedby={`${uid}-hint`} data-ms={duration} style={vars} {...rest}>
    <span class="ma-hold__fill" aria-hidden="true"></span>
    <span class="ma-hold__label">{label}</span>
  </button>
  <span class="ma-hold__hint" id={`${uid}-hint`} aria-live="polite" data-done={done} data-wait={hint}>{hint}</span>
</span>

<script>
  import { onMount } from '../lib/runtime';

  onMount<HTMLElement>('[data-ma-hold]', (root, { signal }) => {
    const b = root.querySelector<HTMLButtonElement>('button')!;
    const hint = root.querySelector<HTMLElement>('.ma-hold__hint')!;
    const on = (t: string, f: (e: any) => void, capture = false) => b.addEventListener(t, f, { signal, capture });
    let timer = 0;
    let passed = false;
    const stop = () => { clearTimeout(timer); delete b.dataset.holding; };
    const start = () => {
      if (b.dataset.holding != null) return;
      b.dataset.holding = '';
      timer = window.setTimeout(() => {
        stop();
        b.dataset.held = '';
        hint.textContent = hint.dataset.done!;
        passed = true;
        b.dispatchEvent(new CustomEvent('ma:hold', { bubbles: true }));
        if (b.type === 'submit') b.form?.requestSubmit(b);
        passed = false;
      }, Number(b.dataset.ms));
    };
    // a click that did not come through the hold is no click at all: a slip, a tap, a quick Enter
    on('click', (e) => { if (!passed) { e.preventDefault(); e.stopImmediatePropagation(); } }, true);
    on('pointerdown', (e) => { if (!e.button) { b.setPointerCapture(e.pointerId); start(); } });
    for (const t of ['pointerup', 'pointercancel', 'lostpointercapture', 'blur']) on(t, stop);
    on('keydown', (e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); if (!e.repeat) start(); } });
    on('keyup', (e) => { if (e.key === ' ' || e.key === 'Enter') stop(); });
    // a form sent back (from the cache, or reset) starts over
    b.form?.addEventListener('reset', () => { delete b.dataset.held; hint.textContent = hint.dataset.wait!; }, { signal });
  });
</script>

<style is:global>
  @layer components {
    :where(.ma-hold) { display: inline-grid; justify-items: start; gap: 0.4rem; }
    :where(.ma-hold__button) {
      --ma-hold-fill: color-mix(in oklab, currentColor 22%, transparent);
      position: relative;
      display: inline-flex;
      align-items: center;
      min-block-size: 2.75rem;
      padding: 0 1.25rem;
      overflow: hidden;
      border: 0;
      border-radius: var(--ma-radius-md, 0.6rem);
      background: var(--primary, var(--ma-ink));
      color: var(--primary-foreground, var(--ma-panel));
      font: inherit;
      font-weight: 650;
      cursor: pointer;
      touch-action: none;
      user-select: none;
      -webkit-user-select: none;
    }
    :where(.ma-hold__button:focus-visible) { outline: 2px solid var(--ring, currentColor); outline-offset: 3px; }
    :where(.ma-hold__label) { position: relative; }
    /* the fill: across the button over the hold, and back fast when it is let go */
    :where(.ma-hold__fill) { position: absolute; inset: 0; background: var(--ma-hold-fill); transform-origin: left; scale: 0 1; transition: scale var(--ma-duration-fast, 200ms) var(--ma-ease-out, ease-out); }
    :where(.ma-hold__fill:dir(rtl)) { transform-origin: right; }
    :where(.ma-hold__button[data-holding] .ma-hold__fill) { scale: 1 1; transition: scale var(--ma-hold-ms, 1200ms) linear; }
    :where(.ma-hold__button[data-held] .ma-hold__fill) { scale: 1 1; transition: none; }
    :where(.ma-hold__hint) { font-size: 0.8125rem; }
    /* the fill is the feedback that the hold is counting, not decoration: with reduced motion it still fills, in steps */
    @media (prefers-reduced-motion: reduce) {
      :where(.ma-hold__fill) { transition: none; }
      :where(.ma-hold__button[data-holding] .ma-hold__fill) { transition: scale var(--ma-hold-ms, 1200ms) steps(4, end); }
    }
  }
</style>

```
