Dialog
UIA 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.
Components / UI
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, from the library itself: scroll, hover, navigate away and back. The demo is never gated.
Adjust · props are custom properties, set inline, so you edit the running instance
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
every browser. Elsewhere: without JavaScript it acts on a press, as any button.
One command adds the integration, the base tokens and every component. Then import what you use.
npx astro add moonarcpnpm astro add moonarcbunx astro add moonarc---
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>Owns the file, no dependency. The registry item also installs the shared runtime and the base tokens.
npx shadcn@latest add https://moonarc.dev/r/hold-button.jsonThe CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.
The whole component. Self-contained styles in a cascade layer so your classes always win. If you paste it, also copy runtime.ts.
---
/**
* 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>| 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. |
The fill still crosses the button, in four steps, because it is the count and not decoration.
ClientRouterBound per element through the runtime; a fresh page binds again.
Replaces: Hold to Confirm (Kokonut) · Hold button (Animata). See the migration table.
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.
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.
Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown