# Hold Button (`hold-button`)

> A hold-to-confirm button — press and keep pressing while ink floods across the label; let go early and it snaps back, hold to the end and it commits with a pop.

- **Docs:** https://www.mellowui.com/components/hold-button
- **Markdown:** https://www.mellowui.com/components/hold-button.md
- **Registry:** https://www.mellowui.com/r/hold-button.json
- **Tool prompt:** https://www.mellowui.com/api/prompt/hold-button
- **Categories:** button, interactive, animation
- **Dependencies:** motion

## AI prompt

Add a HoldButton component from the mellow library — a hold-to-confirm button whose ink floods across the label while pressed and snaps back if released early. Pass `onConfirm`, and tune `holdMs`, `confirmedLabel` and `resetMs`. Set `danger` for destructive actions. It accepts the usual button props.

## Install

```bash
npx shadcn@latest add https://mellowui.com/r/hold-button.json
```

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `children` | `React.ReactNode` | — | Button label. |
| `onConfirm` | `() => void` | — | Called once when the hold completes. |
| `holdMs` | `number` | `900` | How long the press must be held, in ms. |
| `confirmedLabel` | `React.ReactNode` | `"Confirmed"` | Label shown briefly after the hold completes. |
| `resetMs` | `number` | `1500` | Ms before the button re-arms after confirming. |
| `danger` | `boolean` | `false` | Destructive styling — red border, label, and ink flood. |
| `className` | `string` | — | Additional CSS classes. |

## Design notes

- Client component (`"use client"`) — required for animation and refs.
- Uses Mellow design tokens: `--ink`, `--ink-rgb`, `--background`, `--background-rgb`, `--rule`, `--font-sans`, `--font-serif`, `--font-mono`. Tokens auto-flip between light and dark themes — never hardcode colors.
- Respects `prefers-reduced-motion: reduce` where applicable.
- Self-contained — no shared utilities. Copy-paste, not a package.

## Source

```tsx
"use client";

import React, { useEffect, useRef, useState } from "react";
import {
  AnimatePresence,
  animate,
  motion,
  useMotionValue,
  useReducedMotion,
  useTransform,
  type AnimationPlaybackControls,
} from "motion/react";

export interface HoldButtonProps
  extends Omit<
    React.ButtonHTMLAttributes<HTMLButtonElement>,
    "onDrag" | "onDragStart" | "onDragEnd" | "onAnimationStart" | "onAnimationEnd"
  > {
  children: React.ReactNode;
  /** Called once when the hold completes. */
  onConfirm?: () => void;
  /** How long the press must be held, in ms. */
  holdMs?: number;
  /** Label shown briefly after the hold completes. */
  confirmedLabel?: React.ReactNode;
  /** Ms before the button re-arms after confirming. */
  resetMs?: number;
  /** Destructive styling — red border, label, and ink flood. */
  danger?: boolean;
}

const DANGER = "oklch(0.55 0.21 27)";

/**
 * A hold-to-confirm button — press and keep pressing while ink floods across
 * the label; let go early and it snaps back, hold to the end and it commits
 * with a soft settle into the confirmed state. For actions that deserve a
 * moment of intent.
 */
export function HoldButton({
  children,
  onConfirm,
  holdMs = 900,
  confirmedLabel = "Confirmed",
  resetMs = 1500,
  danger = false,
  className,
  disabled,
  ...rest
}: HoldButtonProps) {
  const reduced = useReducedMotion();
  const progress = useMotionValue(0);
  const [done, setDone] = useState(false);
  const animRef = useRef<AnimationPlaybackControls | null>(null);
  const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  const holdingRef = useRef(false);
  const firedRef = useRef(false);

  const clip = useTransform(progress, (v) => `inset(0 ${(1 - v) * 100}% 0 0)`);

  useEffect(() => {
    return () => {
      animRef.current?.stop();
      if (timerRef.current) clearTimeout(timerRef.current);
    };
  }, []);

  const rearm = () => {
    setDone(false);
    firedRef.current = false;
    if (reduced) {
      progress.set(0);
      return;
    }
    animRef.current?.stop();
    animRef.current = animate(progress, 0, {
      type: "spring",
      stiffness: 260,
      damping: 30,
    });
  };

  const complete = () => {
    if (firedRef.current) return;
    firedRef.current = true;
    holdingRef.current = false;
    setDone(true);
    onConfirm?.();
    timerRef.current = setTimeout(rearm, resetMs);
  };

  const start = () => {
    if (disabled || done || holdingRef.current) return;
    holdingRef.current = true;
    if (reduced) {
      progress.set(1);
      timerRef.current = setTimeout(complete, holdMs);
      return;
    }
    animRef.current?.stop();
    animRef.current = animate(progress, 1, {
      duration: (holdMs / 1000) * (1 - progress.get()),
      ease: "linear",
      onComplete: complete,
    });
  };

  const release = () => {
    if (!holdingRef.current || done) return;
    holdingRef.current = false;
    animRef.current?.stop();
    if (timerRef.current && !firedRef.current) {
      clearTimeout(timerRef.current);
      timerRef.current = null;
    }
    if (reduced) {
      progress.set(0);
      return;
    }
    animRef.current = animate(progress, 0, {
      type: "spring",
      stiffness: 320,
      damping: 32,
    });
  };

  return (
    <motion.button
      type="button"
      disabled={disabled}
      animate={done ? { scale: [1, 1.045, 1] } : { scale: 1 }}
      transition={
        reduced
          ? { duration: 0 }
          : done
            ? { duration: 0.48, times: [0, 0.38, 1], ease: [0.22, 1, 0.36, 1] }
            : { type: "spring", stiffness: 420, damping: 28 }
      }
      onPointerDown={start}
      onPointerUp={release}
      onPointerLeave={release}
      onPointerCancel={release}
      onKeyDown={(e) => {
        if ((e.key === " " || e.key === "Enter") && !e.repeat) {
          e.preventDefault();
          start();
        }
      }}
      onKeyUp={(e) => {
        if (e.key === " " || e.key === "Enter") release();
      }}
      className={[
        "relative cursor-pointer touch-none overflow-hidden border bg-[var(--background)] select-none outline-none focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-50",
        danger
          ? "border-[oklch(0.55_0.21_27_/_0.55)] text-[oklch(0.55_0.21_27)] focus-visible:ring-[oklch(0.55_0.21_27_/_0.35)]"
          : "border-[var(--rule)] text-[var(--ink)] focus-visible:ring-[rgba(var(--ink-rgb),0.3)]",
        className,
      ]
        .filter(Boolean)
        .join(" ")}
      {...rest}
    >
      <HoldLabel done={done} confirmedLabel={confirmedLabel} reduced={!!reduced}>
        {children}
      </HoldLabel>
      {/* ink flood — inverted label revealed by clip */}
      <motion.span
        aria-hidden="true"
        className="absolute inset-0 text-[var(--background)]"
        style={{
          clipPath: clip,
          backgroundColor: danger ? DANGER : "var(--ink)",
          color: danger ? "oklch(0.97 0.01 90)" : undefined,
        }}
      >
        <HoldLabel done={done} confirmedLabel={confirmedLabel} reduced={!!reduced}>
          {children}
        </HoldLabel>
      </motion.span>
    </motion.button>
  );
}

function HoldLabel({
  children,
  confirmedLabel,
  done,
  reduced,
}: {
  children: React.ReactNode;
  confirmedLabel: React.ReactNode;
  done: boolean;
  reduced: boolean;
}) {
  const className =
    "flex items-center justify-center gap-2 px-5 py-3 [font-family:var(--font-mono)] text-[0.6875rem] font-medium tracking-[0.16em] uppercase";

  if (reduced) {
    return (
      <span className={className}>{done ? confirmedLabel : children}</span>
    );
  }

  return (
    <span className={`relative ${className}`}>
      {/* Keep button width stable while labels crossfade. */}
      <span className="invisible flex items-center gap-2" aria-hidden="true">
        {done ? confirmedLabel : children}
      </span>
      <AnimatePresence mode="popLayout" initial={false}>
        <motion.span
          key={done ? "done" : "idle"}
          initial={{ opacity: 0, y: 10, filter: "blur(4px)" }}
          animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
          exit={{ opacity: 0, y: -8, filter: "blur(4px)" }}
          transition={{ duration: 0.34, ease: [0.22, 1, 0.36, 1] }}
          className="absolute inset-0 flex items-center justify-center gap-2"
        >
          {done ? confirmedLabel : children}
        </motion.span>
      </AnimatePresence>
    </span>
  );
}

export default HoldButton;

```

## Demo

```tsx
"use client";

import React, { useState } from "react";
import { HoldButton } from "../mellow/hold-button";

export default function HoldButtonDemo() {
  const [sent, setSent] = useState(0);

  return (
    <div className="flex w-full flex-col items-center gap-6 p-8">
      <div className="flex flex-wrap items-center justify-center gap-4">
        <HoldButton onConfirm={() => setSent((n) => n + 1)}>
          Hold to publish
        </HoldButton>
        <HoldButton danger holdMs={1400} confirmedLabel="Deleted">
          Hold to delete
        </HoldButton>
      </div>
      <p className="text-[0.8125rem] text-[rgba(var(--ink-rgb),0.35)]">
        Press and keep pressing — release early and it snaps back
        {sent > 0 && ` · published ×${sent}`}
      </p>
    </div>
  );
}

```
