Ir al contenido
Evaluar mi proyecto
Componentes · BotonesDisponible

Deslizar para confirmar

Control skeuomórfico de cristal oscuro con destello en la etiqueta; se arrastra con puntero y se completa con Enter desde el teclado.

deslizar para confirmar

Código completo

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

boton-deslizar.tsx95 líneas
Descargar
"use client";

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

/** Deslizar para confirmar, estilo skeuomórfico de cristal oscuro. Se arrastra con ratón o dedo; con teclado, Enter o Espacio lo completan. React + TypeScript, sin dependencias. */
export type BotonDeslizarProps = {
  label?: string;
  doneLabel?: string;
  onConfirm?: () => void;
  className?: string;
  style?: CSSProperties;
};

const CSS = `
.gcdes-root { box-sizing: border-box; display: block; width: 100%; max-width: 340px; font: 500 17px/1.2 -apple-system, "Helvetica Neue", ui-sans-serif, system-ui, sans-serif; }
.gcdes-root *, .gcdes-root *::before, .gcdes-root *::after { box-sizing: border-box; }
.gcdes-root button { font: inherit; -webkit-tap-highlight-color: transparent; }
.gcdes-root :focus-visible { outline: 3px solid #6aa8ff; outline-offset: 3px; }
@media (prefers-reduced-motion: reduce) { .gcdes-root *, .gcdes-root *::before, .gcdes-root *::after { animation: none !important; transition-duration: .01ms !important; } }
.gcdes-track { position: relative; height: 64px; border-radius: 16px; overflow: hidden; touch-action: none; user-select: none; background: linear-gradient(180deg, #1b1d22, #2b2e36 60%, #23252c); box-shadow: inset 0 2px 6px rgba(0, 0, 0, .85), inset 0 -1px 0 rgba(255, 255, 255, .08), 0 1px 0 rgba(255, 255, 255, .7), 0 6px 12px rgba(0, 0, 0, .25); }
.gcdes-track::before { content: ""; position: absolute; inset: 0 0 50%; background: linear-gradient(180deg, rgba(255, 255, 255, .12), transparent); pointer-events: none; }
.gcdes-progress { position: absolute; inset: 0 auto 0 0; background: linear-gradient(180deg, #4aa0ff, #1b64d6); opacity: .85; width: 100%; }
.gcdes-label { position: absolute; inset: 0; display: grid; place-items: center; padding-left: 56px; color: #6d7280; font-size: 18px; pointer-events: none; }
.gcdes-label::after { content: attr(data-text); position: absolute; inset: 0; display: grid; place-items: center; padding-left: 56px; color: #fff; -webkit-mask: linear-gradient(100deg, transparent 38%, #000 50%, transparent 62%) 0 0 / 220% 100%; mask: linear-gradient(100deg, transparent 38%, #000 50%, transparent 62%) 0 0 / 220% 100%; animation: gcdes-shine 2.6s linear infinite; }
.gcdes-track[data-done="true"] .gcdes-label { color: #fff; }
.gcdes-track[data-done="true"] .gcdes-label::after { animation: none; -webkit-mask: none; mask: none; }
.gcdes-thumb { position: absolute; top: 5px; left: 5px; width: 66px; height: 54px; display: grid; place-items: center; border: 0; padding: 0; border-radius: 12px; cursor: grab; color: #3d4350; background: linear-gradient(180deg, #fdfdfd 0%, #d8dbe1 50%, #bdc1ca 51%, #e5e7ec 100%); box-shadow: inset 0 1px 0 #fff, 0 2px 4px rgba(0, 0, 0, .5), 0 0 0 1px rgba(0, 0, 0, .45); touch-action: none; }
.gcdes-thumb:active { cursor: grabbing; }
.gcdes-thumb[data-snap="true"] { transition: transform .35s cubic-bezier(.16, 1, .3, 1); }
@keyframes gcdes-shine { to { -webkit-mask-position: -220% 0; mask-position: -220% 0; } }`;

export function BotonDeslizar({ label = "deslizar para confirmar", doneLabel = "Confirmado", onConfirm, className = "", style }: BotonDeslizarProps) {
  const track = useRef<HTMLDivElement>(null);
  const start = useRef<{ x: number; id: number } | null>(null);
  const [x, setX] = useState(0);
  const [drag, setDrag] = useState(false);
  const [done, setDone] = useState(false);

  const max = () => (track.current ? track.current.clientWidth - 66 - 10 : 200);

  function complete() {
    setDone(true);
    setX(max());
    onConfirm?.();
    window.setTimeout(() => {
      setDone(false);
      setX(0);
    }, 2200);
  }
  function down(event: PointerEvent<HTMLButtonElement>) {
    if (done) return;
    event.currentTarget.setPointerCapture(event.pointerId);
    start.current = { x: event.clientX - x, id: event.pointerId };
    setDrag(true);
  }
  function move(event: PointerEvent<HTMLButtonElement>) {
    if (!start.current) return;
    setX(Math.max(0, Math.min(max(), event.clientX - start.current.x)));
  }
  function up() {
    if (!start.current) return;
    start.current = null;
    setDrag(false);
    if (x >= max() * 0.88) complete();
    else setX(0);
  }
  function key(event: KeyboardEvent<HTMLButtonElement>) {
    if (done) return;
    if (event.key === "Enter" || event.key === " " || event.key === "ArrowRight") {
      event.preventDefault();
      complete();
    }
  }

  return (
    <div className={`gcdes-root ${className}`} style={style}>
      <style>{CSS}</style>
      <div className="gcdes-track" ref={track} data-done={done ? "true" : undefined}>
        <div className="gcdes-progress" style={{ transform: `translateX(calc(-100% + ${x + 66 + 5}px))`, transition: drag ? "none" : "transform .35s cubic-bezier(.16,1,.3,1)" }} />
        <div className="gcdes-label" data-text={done ? doneLabel : label} style={{ opacity: done ? 1 : Math.max(0, 1 - x / 120) }}>
          {done ? doneLabel : label}
        </div>
        <button className="gcdes-thumb" type="button" aria-label={done ? doneLabel : `${label}. Pulsa Enter para confirmar`} data-snap={drag ? undefined : "true"} style={{ transform: `translateX(${x}px)` }} onPointerDown={down} onPointerMove={move} onPointerUp={up} onPointerCancel={up} onKeyDown={key}>
          <svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
            {done ? <path d="m5 12.5 4.5 4.5L19 7.5" /> : <path d="M5 12h13M13 6l6 6-6 6" />}
          </svg>
        </button>
      </div>
      <span style={{ position: "absolute", width: 1, height: 1, overflow: "hidden", clip: "rect(0 0 0 0)" }} role="status">
        {done ? doneLabel : ""}
      </span>
    </div>
  );
}

Ú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-deslizar

Ejemplo de uso de Deslizar para confirmar

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

ejemplo.tsx5 líneas
import { BotonDeslizar } from "./boton-deslizar";

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

Propiedades de BotonDeslizar

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

PropiedadTipoDescripción
label?stringOpcional.
doneLabel?stringOpcional.
onConfirm?() => voidOpcional.
className?stringOpcional.
style?CSSPropertiesOpcional.

Accesibilidad y rendimiento

El archivo tiene 95 líneas (6 KB) y define 1 animación con @keyframes. Esto es lo que hace Deslizar 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.
  • Reacciona al puntero con eventos pointer (ratón, lápiz y táctil).
  • 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)