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.
"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.
npx shadcn@latest add https://www.gassierconsulting.com/r/boton-sostenerEjemplo 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.
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.
| Propiedad | Tipo | Descripción |
|---|---|---|
label? | string | Opcional. |
doneLabel? | string | Opcional. |
holdMs? | number | Milisegundos que hay que mantener pulsado. |
onConfirm? | () => void | Opcional. |
className? | string | Opcional. |
style? | CSSProperties | Opcional. |
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.
| Variable | Valor por defecto |
|---|---|
--gcsos-ink | #111 |
--gcsos-ms | 1200ms |
--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".