Puedes construir un bottom sheet accesible en la web sin escribir lógica de arrastre: deja que el scroll nativo del navegador mueva el panel, usa CSS scroll snap para los puntos de parada y envuélvelo en un <dialog> nativo para el foco y la tecla Escape. pure-web-bottom-sheet empaqueta exactamente eso como un web component que funciona con cualquier framework, y lo instalamos y medimos para ver qué hace de verdad.
¿Qué es un bottom sheet y por qué fallan los que se hacen a mano?
Un bottom sheet es el panel que sube desde la parte inferior de la pantalla del móvil (filtros, menús para compartir, detalles en un mapa) y se detiene en una o varias alturas. La mayoría de las implementaciones web escuchan eventos táctiles y de puntero y mueven el panel con JavaScript. Eso obliga a recrear a mano cosas que el navegador ya hace bien: la inercia, el rebote, el scroll con teclado y la semántica para lectores de pantalla. Cada una que se omite es un bug para algún usuario.
¿Cómo funciona un bottom sheet con CSS scroll snap?
La idea es convertir el panel en un contenedor con scroll en lugar de un objeto que se arrastra. El propio elemento <bottom-sheet> hace scroll vertical con scroll-snap-type: y mandatory (lo confirmamos en los estilos calculados), y cada punto de parada es un elemento en un slot marcado con la propiedad personalizada --snap. Cuando el usuario desliza el dedo, usa la rueda del ratón o pulsa una flecha, el navegador hace scroll y se ajusta al punto más cercano; la posición del panel simplemente sigue a la posición del scroll.
El autor lo describe como un funcionamiento casi sin JavaScript en navegadores modernos: el JS se reserva para compatibilidad hacia atrás y para funciones opcionales como cerrar deslizando hacia abajo. Para los efectos visuales usa también CSS scroll-driven animations.
¿Qué ventaja tiene que sea un web component?
Que funciona igual en React, Vue, Astro o HTML puro. Los web components son elementos HTML personalizados que el navegador registra de forma nativa (con customElements.define), así que no dependen del framework que uses. Para React y Vue, el paquete incluye además envoltorios propios, y para renderizado en servidor ofrece una plantilla de declarative Shadow DOM.
¿Cómo se instala y se usa?
Se instala desde npm:
npm install pure-web-bottom-sheet
La versión modal envuelve un <dialog> nativo dentro de <bottom-sheet-dialog-manager>, que aporta los estilos del diálogo, lo cierra al hacer clic en el fondo e implementa el cierre deslizando hacia abajo. Esta es la estructura del README:
<bottom-sheet-dialog-manager>
<dialog id="bottom-sheet-dialog">
<bottom-sheet swipe-to-dismiss tabindex="0">
<!-- Snap points -->
<div slot="snap" style="--snap: 75%"></div>
<div slot="snap" style="--snap: 50%" class="initial"></div>
<div slot="snap" style="--snap: 25%"></div>
<div slot="header">
<h2>Custom header</h2>
</div>
<div slot="footer">
<h2>Custom footer</h2>
</div>
<p>Custom content</p>
</bottom-sheet>
</dialog>
</bottom-sheet-dialog-manager>
<button id="show-button">Open bottom sheet</button>
<script type="module">
import { registerSheetElements } from "pure-web-bottom-sheet";
registerSheetElements();
document.getElementById("show-button").addEventListener("click", () => {
document.getElementById("bottom-sheet-dialog").showModal();
});
</script>
registerSheetElements() define los dos elementos personalizados. Sin bundler, el README importa la misma función desde https://unpkg.com/pure-web-bottom-sheet.
En React se importan BottomSheet y BottomSheetDialogManager desde pure-web-bottom-sheet/react; en Vue, VBottomSheet y VBottomSheetDialogManager desde pure-web-bottom-sheet/vue. Para SSR y sitios estáticos, pure-web-bottom-sheet/ssr exporta bottomSheetTemplate, que evita el parpadeo sin estilos cuando el panel aparece abierto al cargar la página.
¿Cómo funcionan los snap points?
Cada punto de parada es un <div slot="snap"> con un valor --snap: cualquier longitud CSS (px, vh) o un porcentaje de la altura máxima del panel. Hay tres reglas de la documentación que es fácil pasar por alto:
- Colócalos en el DOM de arriba abajo (el de 75 antes que el de 50, y este antes que el de 25), porque el cálculo del índice asume ese orden.
- Añade
class="initial"a un solo punto para elegir dónde se abre el panel. - Añade
class="top"al punto de altura completa para que se reporte comoexpanded; debe ser el primer snap point del DOM.
Con swipe-to-dismiss existe además un punto implícito en la parte inferior de la pantalla: llegar a él cierra el panel.
Para reaccionar a los cambios de posición, escucha el evento snap-position-change, cuyo detalle incluye snapIndex (0 es colapsado) y sheetState (collapsed, partially-expanded o expanded). Para moverlo desde código, llama a snapToPoint(index).
¿Cómo se hace un modal en HTML que sea accesible?
La forma más fiable es usar el elemento nativo <dialog> abierto con showModal(), y es justo lo que hace este componente en lugar de construir su propio modal. El navegador se encarga del rol de diálogo, de mover el foco al abrir, de cerrar con Escape y de devolver el foco al botón que lo abrió. En nuestra prueba, el árbol de accesibilidad expuso el panel como dialog, con su etiqueta y marcado como modal, sin una sola línea de ARIA escrita a mano. Como alternativa, el componente también funciona con la Popover API.
¿Qué medimos al probarlo?
Instalamos la versión 1.1.1 y ejecutamos la estructura modal del README en Chromium 141 sin interfaz, con un viewport de móvil (390×844) y Playwright. Resultados:
| Prueba | Resultado |
|---|---|
| Instalación | 1 paquete, cero dependencias en tiempo de ejecución |
| Tamaño | 14,5 KB minificado, 4,3 KB con gzip (dist/browser.min.js) |
| Abrir solo con teclado | Enter en el botón lo abre; el foco entra al panel en el snap point initial |
| Árbol de accesibilidad | Expuesto como dialog, con su etiqueta, modal: true |
| Flechas del teclado | Cada pulsación de flecha abajo sube un punto (50 → 75 → expandido); snap-position-change se dispara con el índice correcto |
snapToPoint() |
Llega a cada punto pedido; el evento coincide siempre |
| Escape | Cierra el panel y devuelve el foco al botón que lo abrió |
| JavaScript al moverlo | 30 pasos de rueda, de la mitad a totalmente expandido: 0,64 ms de script, 0 ms de layout |
La última fila es el sentido del diseño: el panel recorrió todo su rango y la página ejecutó bastante menos de un milisegundo de JavaScript. El trabajo lo hizo el navegador.
Lo que no verificamos: gestos táctiles reales (los gestos sintéticos no se registraron en Chromium sin interfaz), ni Safari ni Firefox. El autor indica que está probado en Chrome, Safari y Firefox, en escritorio y móvil.
Cuidado: la tecla Inicio cierra el panel
La dirección del scroll es la contraria a la que podrías esperar. Como el panel es el contenedor con scroll, hacer scroll hacia abajo lo expande y hacer scroll hacia arriba lo baja. Con swipe-to-dismiss, el punto más bajo equivale a “cerrado”, así que en nuestra prueba la tecla Home (Inicio) saltó directamente a ese punto y cerró el diálogo (el foco volvió correctamente al botón). Es coherente con el diseño del componente, pero si tus usuarios navegan con teclado, pruébalo y decide si quieres swipe-to-dismiss en ese panel.
¿Qué atributos conviene conocer?
nested-scroll: el contenido del panel hace scroll de forma independiente de su posición.expand-to-scroll: el contenido solo hace scroll cuando el panel está totalmente expandido (requierenested-scroll).content-height: la altura máxima sigue al contenido en lugar de--sheet-max-height(no es compatible connested-scroll).nested-scroll-optimization: evita reflows al cambiar de tamaño en el modonested-scroll. El README lo marca como experimental.
Los estilos se ajustan con propiedades personalizadas (--sheet-max-height, --sheet-background, --sheet-border-radius) y con partes: ::part(sheet), ::part(handle), ::part(content), ::part(header) y ::part(footer).
¿En qué navegadores funciona?
El browserslist del paquete fija Chrome y Edge 121 o superior, Firefox 123 o superior, y Safari e iOS 18.2 o superior. Safari necesita la 18.2 porque la posición inicial depende de que las propiedades scroll-snap-* admitan animaciones discretas, algo que llegó en esa versión (diciembre de 2024). El paquete declara Node 24 o superior en engines; en nuestra prueba se instaló sin errores en Node 22.
¿Es gratis?
Sí. Tiene licencia MIT, lo mantiene Vili Ketonen y se publica en npm con atestaciones de procedencia generadas desde GitHub Actions. En el README encontrarás el artículo del autor que explica la técnica en detalle.
¿Y tú? ¿Qué componente de interfaz has tenido que rehacer porque no funcionaba bien con teclado o lector de pantalla?
- Con una librería de mi framework (React, Vue…)
- Con código propio de arrastre en JavaScript
- Con CSS scroll snap o un web component como este
- Prefiero otra opción (cuéntanos cuál)
Comenta abajo o, si este artículo te llegó por correo, responde directamente al email: tu respuesta se publica aquí.
Relacionado: Neobrutalism Components: componentes neobrutalistas gratis para React y shadcn, ahora sobre Base UI