liten

Hold Button

A press-and-hold button whose accent fill wipes in over the label as proof of the hold.

Pressing is slow and deliberate, the fill wipes in linearly across the full hold duration. Letting go is snappy, the fill retreats in 200ms. The fill is not decoration, it is the confirmation mechanism itself, so it survives reduced motion. Built with Motion.

Installation

Complete the shared Setup first, then copy the component into components/ui/hold-button.tsx.

Terminal
bun add motion
components/ui/hold-button.tsx
'use client';import * as React from 'react';import { motion, useReducedMotion } from 'motion/react';import { Check } from 'lucide-react';import { cn } from '@/lib/cn';const EASE_OUT = [0.23, 1, 0.32, 1] as const;const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;const SPRING = { type: 'spring', duration: 0.5, bounce: 0.16 } as const;const EASE_OUT_CSS = `cubic-bezier(${EASE_OUT.join(',')})`;const FOCUS =  'focus-visible:outline-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:[outline-color:var(--hold-accent)]';export type HoldButtonProps = {  onConfirm?: () => void;  holdMs?: number;  accent?: string;} & React.ComponentProps<'button'>;export function HoldButton({  onConfirm,  holdMs = 1500,  accent = '#f0463a',  children = 'Hold to confirm',  className,  style,  disabled,  ...props}: HoldButtonProps) {  const reduce = useReducedMotion();  const [held, setHeld] = React.useState(false);  const [confirmed, setConfirmed] = React.useState(false);  const holdTimer = React.useRef<number | undefined>(undefined);  const revertTimer = React.useRef<number | undefined>(undefined);  const pointerId = React.useRef<number | null>(null);  const confirmRef = React.useRef(onConfirm);  confirmRef.current = onConfirm;  React.useEffect(    () => () => {      window.clearTimeout(holdTimer.current);      window.clearTimeout(revertTimer.current);    },    [],  );  const start = React.useCallback(() => {    if (disabled || confirmed) return;    setHeld((already) => {      if (already) return already;      window.clearTimeout(holdTimer.current);      holdTimer.current = window.setTimeout(() => {        setHeld(false);        setConfirmed(true);        confirmRef.current?.();        window.clearTimeout(revertTimer.current);        revertTimer.current = window.setTimeout(() => setConfirmed(false), 1200);      }, holdMs);      return true;    });  }, [disabled, confirmed, holdMs]);  const cancel = React.useCallback(() => {    window.clearTimeout(holdTimer.current);    setHeld(false);  }, []);  const clip = held ? 'inset(0 0% 0 0)' : 'inset(0 100% 0 0)';  const clipTransition = held    ? `clip-path ${holdMs}ms linear`    : `clip-path 200ms ${EASE_OUT_CSS}`;  const vars = { '--hold-accent': accent } as React.CSSProperties;  const blurOn = reduce ? 'blur(0px)' : 'blur(2px)';  return (    <button      type="button"      disabled={disabled}      style={{        ...vars,        transform: held && !reduce ? 'scale(0.98)' : 'scale(1)',        transition: `transform 120ms ${EASE_OUT_CSS}`,        ...style,      }}      onPointerDown={(e) => {        if (e.button !== 0) return;        if (pointerId.current !== null) return;        pointerId.current = e.pointerId;        e.currentTarget.setPointerCapture(e.pointerId);        start();      }}      onPointerMove={(e) => {        if (e.pointerId !== pointerId.current) return;        const r = e.currentTarget.getBoundingClientRect();        if (          e.clientX < r.left ||          e.clientX > r.right ||          e.clientY < r.top ||          e.clientY > r.bottom        ) {          e.currentTarget.releasePointerCapture(e.pointerId);          pointerId.current = null;          cancel();        }      }}      onPointerUp={(e) => {        if (e.pointerId !== pointerId.current) return;        pointerId.current = null;        cancel();      }}      onPointerCancel={(e) => {        if (e.pointerId !== pointerId.current) return;        pointerId.current = null;        cancel();      }}      onKeyDown={(e) => {        if (e.key !== ' ' && e.key !== 'Enter') return;        e.preventDefault();        if (!e.repeat) start();      }}      onKeyUp={(e) => {        if (e.key !== ' ' && e.key !== 'Enter') return;        e.preventDefault();        cancel();      }}      onBlur={() => {        pointerId.current = null;        cancel();      }}      className={cn(        'bloom-edge rounded-[14px]',        'bg-gradient-to-b from-white to-[#f4f4f5] text-neutral-900',        'dark:from-[#1d1d1d] dark:to-[#161716] dark:text-white',        'shadow-[0_1px_2px_0_rgba(0,0,0,0.05),0_5px_14px_-10px_rgba(0,0,0,0.1)]',        'dark:shadow-[inset_0_1px_0_0_rgba(255,255,255,0.05),0_1px_2px_0_rgba(0,0,0,0.4),0_6px_16px_-10px_rgba(0,0,0,0.45)]',        'relative isolate cursor-pointer touch-none select-none overflow-hidden px-5 py-2.5',        'text-[13px] font-medium tracking-[-0.01em]',        'disabled:cursor-not-allowed disabled:opacity-60',        FOCUS,        className,      )}      {...props}    >      <span        aria-hidden        style={{          clipPath: clip,          transition: clipTransition,          backgroundColor: 'color-mix(in srgb, var(--hold-accent) 14%, transparent)',        }}        className="pointer-events-none absolute inset-0 rounded-[inherit]"      />      <span className="relative grid place-items-center">        <motion.span          initial={false}          animate={{            opacity: confirmed ? 0 : 1,            filter: confirmed ? blurOn : 'blur(0px)',          }}          transition={{            duration: confirmed ? 0.12 : 0.2,            ease: EASE_OUT,          }}          className="relative col-start-1 row-start-1 whitespace-nowrap"        >          <span className="text-neutral-500 dark:text-white/50">{children}</span>          <span            aria-hidden            style={{ clipPath: clip, transition: clipTransition }}            className="absolute inset-0 whitespace-nowrap text-[color:var(--hold-accent)]"          >            {children}          </span>        </motion.span>        <motion.span          aria-hidden={!confirmed}          initial={false}          animate={{            opacity: confirmed ? 1 : 0,            filter: confirmed ? 'blur(0px)' : blurOn,          }}          transition={{            duration: confirmed ? 0.2 : 0.12,            ease: EASE_OUT,          }}          className="col-start-1 row-start-1 flex items-center gap-1.5 whitespace-nowrap text-[color:var(--hold-accent)]"        >          <Check size={16} strokeWidth={2} aria-hidden />          Done        </motion.span>      </span>    </button>  );}

Usage

Example.tsx
import { HoldButton } from '@/components/ui/hold-button';

export default function Example() {
  return (
    <HoldButton onConfirm={() => console.log('project deleted')}>
      Hold to delete project
    </HoldButton>
  );
}

Examples

A faster hold with a warm amber accent.

Props

PropTypeDefaultDescription
onConfirm() => void-Fired once when the hold completes.
holdMsnumber1500How long the button must be held, in milliseconds.
accentstring"#f0463a"Accent used for the fill tint, label, and focus ring.
classNamestring-Forwarded to the button.

The button also accepts every native <button> prop.

On this page0%