Ir al contenido
Evaluar mi proyecto
Componentes · BotonesDisponible

Botón de mantener para confirmar

Acción destructiva que se confirma manteniendo pulsado (ratón, dedo o Espacio), con relleno progresivo y cancelación al soltar.

Mantén pulsado, o mantén Espacio.

Código completo

Es exactamente el archivo que se descarga. Cópialo a tu proyecto y úsalo.

boton-sostener.tsx111 líneas
Descargar
"use client";

import { useEffect, useRef, useState, type CSSProperties, type KeyboardEvent } from "react";

/** Botón minimalista que se confirma manteniéndolo pulsado (ratón, dedo o teclado). React + TypeScript, sin dependencias. */
export type BotonSostenerProps = {
  label?: string;
  doneLabel?: string;
  /** Milisegundos que hay que mantener pulsado. */
  holdMs?: number;
  onConfirm?: () => void;
  className?: string;
  style?: CSSProperties;
};

const CSS = `
.gcsos-root { box-sizing: border-box; display: inline-block; max-width: 100%; font: 500 15px/1.2 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; }
.gcsos-root *, .gcsos-root *::before, .gcsos-root *::after { box-sizing: border-box; }
.gcsos-root button { font: inherit; -webkit-tap-highlight-color: transparent; }
.gcsos-root :focus-visible { outline: 2px solid var(--gcsos-ink, #111); outline-offset: 4px; }
@media (prefers-reduced-motion: reduce) { .gcsos-root *, .gcsos-root *::before, .gcsos-root *::after { animation: none !important; transition-duration: .01ms !important; } }
.gcsos-btn { position: relative; overflow: hidden; display: inline-flex; align-items: center; justify-content: center; gap: 10px; min-width: 220px; min-height: 52px; padding: 0 26px; border: 1px solid var(--gcsos-ink, #111); border-radius: 999px; background: transparent; color: var(--gcsos-ink, #111); cursor: pointer; user-select: none; touch-action: none; -webkit-touch-callout: none; transition: color .2s, transform .2s cubic-bezier(.16, 1, .3, 1); }
.gcsos-fill { position: absolute; inset: 0; background: var(--gcsos-ink, #111); transform: scaleX(0); transform-origin: left; transition: transform .25s cubic-bezier(.16, 1, .3, 1); }
.gcsos-btn[data-holding="true"] .gcsos-fill { transform: scaleX(1); transition: transform var(--gcsos-ms, 1200ms) linear; }
.gcsos-btn[data-holding="true"] { color: var(--gcsos-bg, #fff); transform: scale(.97); }
.gcsos-btn[data-done="true"] { color: var(--gcsos-bg, #fff); }
.gcsos-btn[data-done="true"] .gcsos-fill { transform: scaleX(1); transition: none; }
.gcsos-text { position: relative; display: inline-flex; align-items: center; gap: 10px; }
.gcsos-hint { position: absolute; left: 0; right: 0; top: calc(100% + 8px); font-size: 12px; color: var(--gcsos-muted, #6b6b6b); text-align: center; }
.gcsos-wrap { position: relative; display: inline-block; padding-bottom: 28px; }`;

export function BotonSostener({ label = "Mantén para eliminar", doneLabel = "Eliminado", holdMs = 1200, onConfirm, className = "", style }: BotonSostenerProps) {
  const [holding, setHolding] = useState(false);
  const [done, setDone] = useState(false);
  const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
  const reset = useRef<ReturnType<typeof setTimeout> | null>(null);
  const onConfirmRef = useRef(onConfirm);
  useEffect(() => {
    onConfirmRef.current = onConfirm;
  }, [onConfirm]);

  useEffect(
    () => () => {
      if (timer.current) clearTimeout(timer.current);
      if (reset.current) clearTimeout(reset.current);
    },
    [],
  );

  function start() {
    if (done || holding) return;
    setHolding(true);
    timer.current = setTimeout(() => {
      setHolding(false);
      setDone(true);
      onConfirmRef.current?.();
      reset.current = setTimeout(() => setDone(false), 2200);
    }, holdMs);
  }
  function cancel() {
    if (timer.current) clearTimeout(timer.current);
    timer.current = null;
    setHolding(false);
  }
  function keyDown(event: KeyboardEvent<HTMLButtonElement>) {
    if ((event.key === " " || event.key === "Enter") && !event.repeat) {
      event.preventDefault();
      start();
    }
  }
  function keyUp(event: KeyboardEvent<HTMLButtonElement>) {
    if (event.key === " " || event.key === "Enter") cancel();
  }

  const vars = { "--gcsos-ms": `${holdMs}ms` } as CSSProperties;
  return (
    <span className={`gcsos-root ${className}`} style={{ ...vars, ...style }}>
      <style>{CSS}</style>
      <span className="gcsos-wrap">
        <button
          className="gcsos-btn"
          type="button"
          data-holding={holding ? "true" : undefined}
          data-done={done ? "true" : undefined}
          onPointerDown={start}
          onPointerUp={cancel}
          onPointerLeave={cancel}
          onPointerCancel={cancel}
          onKeyDown={keyDown}
          onKeyUp={keyUp}
          onBlur={cancel}
          onContextMenu={(event) => event.preventDefault()}
        >
          <span className="gcsos-fill" />
          <span className="gcsos-text">
            {done ? (
              <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
                <path d="m5 12 4.5 4.5L19 7" />
              </svg>
            ) : null}
            {done ? doneLabel : label}
          </span>
        </button>
        <span className="gcsos-hint" role="status">
          {done ? "Acción confirmada." : holding ? "Sigue manteniendo…" : "Mantén pulsado, o mantén Espacio."}
        </span>
      </span>
    </span>
  );
}

Úsalo en tu proyecto

Instálalo con la CLI de shadcn.

terminal.sh1 líneas
npx shadcn@latest add https://www.gassierconsulting.com/r/boton-sostener

Ejemplo de uso de Botón de mantener para confirmar

Después de copiar boton-sostener.tsx a tu proyecto, impórtalo y colócalo donde lo necesites.

ejemplo.tsx5 líneas
import { BotonSostener } from "./boton-sostener";

export default function Ejemplo() {
  return <BotonSostener label="Ejemplo" />;
}

Propiedades de BotonSostener

BotonSostener acepta 6 propiedades: todas son opcionales y tienen un valor por defecto, así que funciona sin configurar nada.

PropiedadTipoDescripción
label?stringOpcional.
doneLabel?stringOpcional.
holdMs?numberMilisegundos que hay que mantener pulsado.
onConfirm?() => voidOpcional.
className?stringOpcional.
style?CSSPropertiesOpcional.

Personaliza Botón de mantener para confirmar con variables CSS

Define estas variables en el contenedor o en tu hoja de estilos para cambiar los colores sin tocar el componente. Si no las defines, se usa el valor por defecto.

VariableValor por defecto
--gcsos-ink#111
--gcsos-ms1200ms
--gcsos-bg#fff
--gcsos-muted#6b6b6b

Accesibilidad y rendimiento

El archivo tiene 111 líneas (5 KB) y no necesita @keyframes. Esto es lo que hace Botón de mantener para confirmar por quien lo usa:

  • Detecta prefers-reduced-motion y baja o quita el movimiento cuando la persona lo pide.
  • Muestra un anillo de foco visible al navegar con teclado (:focus-visible).
  • Declara roles ARIA para lectores de pantalla: status.
  • Responde al teclado, no solo al ratón.
  • Es un componente de cliente de Next.js: incluye la directiva "use client".
Licencia
MIT
Dependencias
React >=18
Compatibilidad
React 18 y 19 · Next.js (componente de cliente)