Skip to content

Components / UI

Hold Button

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.

Live demo

Press and hold to delete

Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.

Measured

JavaScript of its own

984 B raw

538 B gzip.

Uses the shared runtime (1.9 kB raw, once per site). With those included: 2.6 kB raw.

CSS 5.3 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.

Browser support

Baseline · widely available

every browser. Elsewhere: without JavaScript it acts on a press, as any button.

Install

One command adds the integration, the base tokens and every component. Then import what you use.

npx astro add moonarc
pnpm astro add moonarc
bunx astro add moonarc
src/pages/index.astro
---
import HoldButton from '@moonarc/core/HoldButton';
---
<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>
Copy it into your project instead (shadcn registry)

Owns the file, no dependency. The registry item also installs the shared runtime and the base tokens.

terminal
npx shadcn@latest add https://moonarc.dev/r/hold-button.json

The CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.

Source

The whole component. Self-contained styles in a cascade layer so your classes always win. If you paste it, also copy runtime.ts.

HoldButton.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>

Props

PropTypeDefaultDescription
labelstringnoneThe button's words.
durationnumber1200How long it is held, in ms: 400 to 5000.
hintstring'Press and hold'The line under it while it waits; the button points at it with aria-describedby.
donestring'Done'The line once it has acted.
type'button' | 'submit''button'submit sends its form at the end of the hold.
colourstringcurrentColor at 22%The fill's colour.

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.

Why it is built this way

Replaces: Hold to Confirm (Kokonut) · Hold button (Animata). See the migration table.

dialog

Native, animated, zero script.

Escape, backdrop click and focus are the browser's. The scale and the blur are @starting-style and allow-discrete.

A native modal that scales in over a blurred backdrop and scales out again, closes on Escape, the close button or a backdrop click, and traps focus, all handled by the browser.

newly · Chrome 135 · Firefox 144 · Safari 26.2click

idle → pending → done · one attribute

One submit button with four faces (idle, pending with a spinner, done with a check that draws itself, error) and a width that follows the label.

every browserclick

Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown