Ir al contenido
Evaluar mi proyecto
Componentes · Modales y capasDisponible

Hoja inferior arrastrable

Bottom sheet estilo iOS que se cierra arrastrando el asa más allá de un umbral, con Escape y clic fuera.

Adjuntar archivo

Código completo

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

capa-hoja.tsx89 líneas
Descargar
"use client";

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

/** Hoja inferior (bottom sheet) sobre <dialog>: se arrastra por el asa y se cierra al soltarla más allá de un umbral; Escape y clic fuera también. Estilo iOS. React + TypeScript, sin dependencias. */
export type CapaHojaProps = {
  trigger?: string;
  title?: string;
  options?: { id: string; label: string; hint?: string }[];
  onPick?: (id: string) => void;
  className?: string;
  style?: CSSProperties;
};

const CSS = `
.gccho-root { box-sizing: border-box; display: inline-block; font: 500 17px/1.35 -apple-system, "SF Pro Text", ui-sans-serif, system-ui, sans-serif; color: #1c1c1e; }
.gccho-root *, .gccho-root *::before, .gccho-root *::after { box-sizing: border-box; }
.gccho-root button { font: inherit; }
.gccho-root :focus-visible { outline: 3px solid #0a84ff; outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) { .gccho-dlg, .gccho-dlg::backdrop { animation: none !important; } .gccho-dlg { transition: none !important; } }
.gccho-open { min-height: 48px; padding: 0 22px; border: 0; border-radius: 14px; background: #0a84ff; color: #fff; font-weight: 600; cursor: pointer; }
.gccho-open:active { transform: scale(.97); }
.gccho-dlg { position: fixed; left: 50%; bottom: 0; top: auto; margin: 0; width: min(520px, 100vw); max-width: none; max-height: 88dvh; padding: 0; border: 0; border-radius: 20px 20px 0 0; background: #f2f2f7; color: #1c1c1e; transform: translate(-50%, var(--y, 0)); animation: gccho-in .42s cubic-bezier(.16, 1, .3, 1); box-shadow: 0 -20px 50px -20px rgba(0, 0, 0, .35); touch-action: none; }
.gccho-dlg[data-drag="false"] { transition: transform .3s cubic-bezier(.16, 1, .3, 1); }
.gccho-dlg::backdrop { background: rgba(0, 0, 0, .38); animation: gccho-fade .3s ease-out; }
.gccho-grab { display: grid; place-items: center; height: 28px; cursor: grab; touch-action: none; }
.gccho-grab::before { content: ""; width: 40px; height: 5px; border-radius: 3px; background: #c7c7cc; }
.gccho-title { margin: 0; padding: 0 20px 10px; text-align: center; font-size: 15px; font-weight: 600; color: #6c6c70; }
.gccho-list { margin: 0 16px 16px; padding: 0; list-style: none; overflow: hidden; border-radius: 14px; background: #fff; }
.gccho-opt { display: flex; justify-content: space-between; align-items: center; width: 100%; min-height: 56px; padding: 0 16px; border: 0; border-top: 1px solid #ececf0; background: #fff; color: inherit; text-align: left; cursor: pointer; }
.gccho-list li:first-child .gccho-opt { border-top: 0; }
.gccho-opt:hover { background: #f7f7fa; }
.gccho-opt small { color: #8e8e93; font-size: 14px; }
.gccho-cancel { display: block; width: calc(100% - 32px); min-height: 56px; margin: 0 16px 20px; border: 0; border-radius: 14px; background: #fff; color: #0a84ff; font-weight: 600; cursor: pointer; }
@keyframes gccho-in { from { transform: translate(-50%, 100%); } }
@keyframes gccho-fade { from { opacity: 0; } }`;

const DEFAULT = [
  { id: "foto", label: "Tomar foto", hint: "Cámara" },
  { id: "galeria", label: "Elegir de la galería", hint: "Fotos" },
  { id: "archivo", label: "Seleccionar archivo", hint: "Documentos" },
];

export function CapaHoja({ trigger = "Adjuntar", title = "Adjuntar archivo", options = DEFAULT, onPick, className = "", style }: CapaHojaProps) {
  const id = useId();
  const ref = useRef<HTMLDialogElement>(null);
  const start = useRef<number | null>(null);

  function down(event: PointerEvent<HTMLDivElement>) {
    start.current = event.clientY;
    event.currentTarget.setPointerCapture(event.pointerId);
    ref.current?.setAttribute("data-drag", "true");
  }
  function move(event: PointerEvent<HTMLDivElement>) {
    if (start.current === null) return;
    ref.current?.style.setProperty("--y", `${Math.max(0, event.clientY - start.current)}px`);
  }
  function up(event: PointerEvent<HTMLDivElement>) {
    if (start.current === null) return;
    const dy = event.clientY - start.current;
    start.current = null;
    ref.current?.setAttribute("data-drag", "false");
    if (dy > 90) ref.current?.close();
    ref.current?.style.setProperty("--y", "0px");
  }

  return (
    <div className={`gccho-root ${className}`} style={style}>
      <style>{CSS}</style>
      <button className="gccho-open" type="button" onClick={() => ref.current?.showModal()}>{trigger}</button>
      <dialog ref={ref} className="gccho-dlg" aria-labelledby={`${id}-t`} onClick={(event) => event.target === ref.current && ref.current?.close()}>
        <div className="gccho-grab" onPointerDown={down} onPointerMove={move} onPointerUp={up} onPointerCancel={up} aria-hidden="true" />
        <h2 className="gccho-title" id={`${id}-t`}>{title}</h2>
        <ul className="gccho-list">
          {options.map((option) => (
            <li key={option.id}>
              <button className="gccho-opt" type="button" onClick={() => { onPick?.(option.id); ref.current?.close(); }}>
                {option.label}
                {option.hint ? <small>{option.hint}</small> : null}
              </button>
            </li>
          ))}
        </ul>
        <button className="gccho-cancel" type="button" onClick={() => ref.current?.close()}>Cancelar</button>
      </dialog>
    </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/capa-hoja

Ejemplo de uso de Hoja inferior arrastrable

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

ejemplo.tsx5 líneas
import { CapaHoja } from "./capa-hoja";

export default function Ejemplo() {
  return <CapaHoja trigger="Ejemplo" />;
}

Propiedades de CapaHoja

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

PropiedadTipoDescripción
trigger?stringOpcional.
title?stringOpcional.
options?{ id: string; label: string; hint?: string }[]Opcional.
onPick?(id: string) => voidOpcional.
className?stringOpcional.
style?CSSPropertiesOpcional.

Personaliza Hoja inferior arrastrable 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
--y0

Accesibilidad y rendimiento

El archivo tiene 89 líneas (5 KB) y define 2 animaciones con @keyframes. Esto es lo que hace Hoja inferior arrastrable 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-*.
  • Usa el elemento <dialog> nativo, con foco y cierre con Escape gestionados por el navegador.
  • 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)