Ir al contenido
Evaluar mi proyecto
Componentes · MicrointeraccionesDisponible

Lista de deslizar para borrar

Arrastra una fila para revelar la acción roja; pasado el umbral la fila se colapsa. Con botón de borrar para teclado.

  • Cotización #204Enviada hoy · 10:42
  • Reunión de arranqueMañana · 9:00
  • Factura de marzoVence en 5 días
  • Revisión de accesosPendiente

Código completo

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

deslizar-borrar.tsx117 líneas
Descargar
"use client";

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

/** Lista con deslizar para borrar: arrastra una fila para revelar la acción roja; suelta pasado el umbral y la fila se colapsa. Cada fila tiene también un botón de borrar para teclado. React + TypeScript, sin dependencias. */
export type DeslizarBorrarItem = { id: string; title: string; meta: string };

export type DeslizarBorrarProps = {
  items?: DeslizarBorrarItem[];
  onDelete?: (id: string) => void;
  className?: string;
  style?: CSSProperties;
};

const CSS = `
.gcmdb-root { box-sizing: border-box; width: 100%; max-width: 380px; font: 500 15px/1.3 -apple-system, "Helvetica Neue", ui-sans-serif, system-ui, sans-serif; color: #1c1c1e; }
.gcmdb-root *, .gcmdb-root *::before, .gcmdb-root *::after { box-sizing: border-box; }
.gcmdb-root button { font: inherit; }
.gcmdb-root :focus-visible { outline: 3px solid #0a84ff; outline-offset: -3px; }
@media (prefers-reduced-motion: reduce) { .gcmdb-root * { transition-duration: .01ms !important; } }
.gcmdb-list { margin: 0; padding: 0; list-style: none; overflow: hidden; border-radius: 16px; background: #fff; box-shadow: 0 1px 2px rgba(0, 0, 0, .08), 0 10px 24px -14px rgba(0, 0, 0, .25); }
.gcmdb-wrap { display: grid; grid-template-rows: 1fr; transition: grid-template-rows .35s cubic-bezier(.16, 1, .3, 1), opacity .3s; }
.gcmdb-wrap[data-gone="true"] { grid-template-rows: 0fr; opacity: 0; }
.gcmdb-wrap > div { overflow: hidden; }
.gcmdb-row { position: relative; background: #ff3b30; border-top: 1px solid #ececf0; }
.gcmdb-wrap:first-child .gcmdb-row { border-top: 0; }
.gcmdb-del { position: absolute; right: 0; top: 0; bottom: 0; width: 96px; display: grid; place-items: center; align-content: center; gap: 2px; border: 0; background: transparent; color: #fff; font-size: 13px; font-weight: 600; cursor: pointer; }
.gcmdb-front { position: relative; display: flex; align-items: center; gap: 14px; padding: 14px 16px; background: #fff; touch-action: pan-y; user-select: none; cursor: grab; transition: transform .3s cubic-bezier(.16, 1, .3, 1); }
.gcmdb-front[data-drag="true"] { transition: none; cursor: grabbing; }
.gcmdb-avatar { flex: none; display: grid; place-items: center; width: 40px; height: 40px; border-radius: 50%; background: #e8f0ff; color: #2456c8; font-weight: 700; }
.gcmdb-title { display: block; font-weight: 600; }
.gcmdb-meta { display: block; margin-top: 2px; font-size: 13px; color: #6c6c70; }`;

const DEFAULT_ITEMS: DeslizarBorrarItem[] = [
  { id: "1", title: "Cotización #204", meta: "Enviada hoy · 10:42" },
  { id: "2", title: "Reunión de arranque", meta: "Mañana · 9:00" },
  { id: "3", title: "Factura de marzo", meta: "Vence en 5 días" },
  { id: "4", title: "Revisión de accesos", meta: "Pendiente" },
];

function Row({ item, onDelete }: { item: DeslizarBorrarItem; onDelete: (id: string) => void }) {
  const [x, setX] = useState(0);
  const [drag, setDrag] = useState(false);
  const [gone, setGone] = useState(false);
  const start = useRef<number | null>(null);
  const OPEN = -96;

  function remove() {
    setX(-420);
    setGone(true);
    window.setTimeout(() => onDelete(item.id), 340);
  }
  function down(event: PointerEvent<HTMLDivElement>) {
    start.current = event.clientX - x;
    event.currentTarget.setPointerCapture(event.pointerId);
    setDrag(true);
  }
  function move(event: PointerEvent<HTMLDivElement>) {
    if (start.current === null) return;
    const next = Math.max(-300, Math.min(0, event.clientX - start.current));
    setX(next < OPEN ? OPEN + (next - OPEN) * 0.5 : next);
  }
  function up() {
    if (start.current === null) return;
    start.current = null;
    setDrag(false);
    if (x < -200) remove();
    else setX(x < OPEN / 2 ? OPEN : 0);
  }

  return (
    <li className="gcmdb-wrap" data-gone={gone}>
      <div>
        <div className="gcmdb-row">
          <button className="gcmdb-del" type="button" aria-label={`Borrar ${item.title}`} tabIndex={x === OPEN ? 0 : 0} onClick={remove}>
            <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
              <path d="M4 7h16M10 11v6M14 11v6M6 7l1 13h10l1-13M9 7V4h6v3" />
            </svg>
            Borrar
          </button>
          <div className="gcmdb-front" data-drag={drag} style={{ transform: `translateX(${x}px)` }} onPointerDown={down} onPointerMove={move} onPointerUp={up} onPointerCancel={up}>
            <span className="gcmdb-avatar" aria-hidden="true">
              {item.title[0]}
            </span>
            <span>
              <span className="gcmdb-title">{item.title}</span>
              <span className="gcmdb-meta">{item.meta}</span>
            </span>
          </div>
        </div>
      </div>
    </li>
  );
}

export function DeslizarBorrar({ items = DEFAULT_ITEMS, onDelete, className = "", style }: DeslizarBorrarProps) {
  const [list, setList] = useState(items);
  return (
    <div className={`gcmdb-root ${className}`} style={style}>
      <style>{CSS}</style>
      <ul className="gcmdb-list" aria-label="Pendientes">
        {list.map((item) => (
          <Row key={item.id} item={item} onDelete={(id) => { setList((prev) => prev.filter((value) => value.id !== id)); onDelete?.(id); }} />
        ))}
      </ul>
      {list.length === 0 ? (
        <p style={{ textAlign: "center", color: "#6c6c70", marginTop: 16 }}>
          Sin pendientes.{" "}
          <button type="button" style={{ border: 0, background: "none", color: "#0a84ff", cursor: "pointer", font: "inherit" }} onClick={() => setList(items)}>
            Restaurar lista
          </button>
        </p>
      ) : null}
    </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/deslizar-borrar

Ejemplo de uso de Lista de deslizar para borrar

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

ejemplo.tsx5 líneas
import { DeslizarBorrar } from "./deslizar-borrar";

export default function Ejemplo() {
  return <DeslizarBorrar />;
}

Propiedades de DeslizarBorrar

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

PropiedadTipoDescripción
items?DeslizarBorrarItem[]Opcional.
onDelete?(id: string) => voidOpcional.
className?stringOpcional.
style?CSSPropertiesOpcional.

Accesibilidad y rendimiento

El archivo tiene 117 líneas (6 KB) y no necesita @keyframes. Esto es lo que hace Lista de deslizar para borrar 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).
  • Expone su estado a tecnologías de apoyo con atributos aria-*.
  • 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)