Você pode construir um bottom sheet acessível na web sem escrever lógica de arrasto: deixe que o scroll nativo do navegador mova o painel, use CSS scroll snap para os pontos de parada e envolva-o em um <dialog> nativo para o foco e a tecla Escape. pure-web-bottom-sheet empacota exatamente isso como um web component que funciona com qualquer framework, e o instalamos e medimos para ver o que realmente faz.
O que é um bottom sheet e por que falham os que são feitos à mão?
Um bottom sheet é o painel que sobe a partir da parte inferior da tela do celular (filtros, menus para compartilhar, detalhes em um mapa) e para em uma ou várias alturas. A maioria das implementações web escuta eventos de toque e de ponteiro e move o painel com JavaScript. Isso obriga a recriar à mão coisas que o navegador já faz bem: a inércia, o ricochete, o scroll com teclado e a semântica para leitores de tela. Cada uma que é omitida é um bug para algum usuário.
Como funciona um bottom sheet com CSS scroll snap?
A ideia é converter o painel em um contêiner com scroll em vez de um objeto que é arrastado. O próprio elemento <bottom-sheet> faz scroll vertical com scroll-snap-type: y mandatory (confirmamos nos estilos calculados), e cada ponto de parada é um elemento em um slot marcado com a propriedade personalizada --snap. Quando o usuário desliza o dedo, usa a roda do mouse ou pressiona uma seta, o navegador faz scroll e se ajusta ao ponto mais próximo; a posição do painel simplesmente segue a posição do scroll.
O autor descreve isso como um funcionamento quase sem JavaScript em navegadores modernos: o JS é reservado para compatibilidade com versões anteriores e para funções opcionais como fechar deslizando para baixo. Para os efeitos visuais também usa CSS scroll-driven animations.
Qual é a vantagem de ser um web component?
Que funciona igual em React, Vue, Astro ou HTML puro. Os web components são elementos HTML personalizados que o navegador registra nativamente (com customElements.define), então não dependem do framework que você usa. Para React e Vue, o pacote também inclui wrappers próprios, e para renderização no servidor oferece um template de Shadow DOM declarativo.
Como se instala e se usa?
Se instala pelo npm:
npm install pure-web-bottom-sheet
A versão modal envolve um <dialog> nativo dentro de <bottom-sheet-dialog-manager>, que fornece os estilos do diálogo, o fecha ao clicar no fundo e implementa o fechamento deslizando para baixo. Esta é a estrutura do 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 os dois elementos personalizados. Sem bundler, o README importa a mesma função de https://unpkg.com/pure-web-bottom-sheet.
Em React, importam-se BottomSheet e BottomSheetDialogManager de pure-web-bottom-sheet/react; em Vue, VBottomSheet e VBottomSheetDialogManager de pure-web-bottom-sheet/vue. Para SSR e sites estáticos, pure-web-bottom-sheet/ssr exporta bottomSheetTemplate, que evita o piscar sem estilos quando o painel aparece aberto ao carregar a página.
Como funcionam os snap points?
Cada ponto de parada é um <div slot="snap"> com um valor --snap: qualquer comprimento CSS (px, vh) ou uma porcentagem da altura máxima do painel. Há três regras da documentação que é fácil passar despercebido:
- Coloque-os no DOM de cima para baixo (o de 75 antes do de 50, e este antes do de 25), porque o cálculo do índice assume essa ordem.
- Adicione
class="initial"a um só ponto para escolher onde o painel se abre. - Adicione
class="top"ao ponto de altura completa para que seja informado comoexpanded; deve ser o primeiro snap point do DOM.
Com swipe-to-dismiss existe também um ponto implícito na parte inferior da tela: chegar nele fecha o painel.
Para reagir às mudanças de posição, ouça o evento snap-position-change, cujo detalhe inclui snapIndex (0 é recolhido) e sheetState (collapsed, partially-expanded ou expanded). Para movê-lo a partir do código, chame snapToPoint(index).
Como se faz um modal em HTML que seja acessível?
A forma mais confiável é usar o elemento nativo <dialog> aberto com showModal(), e é exatamente o que este componente faz em vez de construir seu próprio modal. O navegador se encarrega do papel de diálogo, de mover o foco ao abrir, de fechar com Escape e de devolver o foco ao botão que o abriu. Em nosso teste, a árvore de acessibilidade expôs o painel como dialog, com seu rótulo e marcado como modal, sem uma única linha de ARIA escrita manualmente. Como alternativa, o componente também funciona com a Popover API.
O que medimos ao testá-lo?
Instalamos a versão 1.1.1 e executamos a estrutura modal do README em Chromium 141 sem interface, com um viewport de celular (390×844) e Playwright. Resultados:
| Teste | Resultado |
|---|---|
| Instalação | 1 pacote, zero dependências em tempo de execução |
| Tamanho | 14,5 KB minificado, 4,3 KB com gzip (dist/browser.min.js) |
| Abrir só com teclado | Enter no botão o abre; o foco entra no painel no snap point initial |
| Árvore de acessibilidade | Exposto como dialog, com seu rótulo, modal: true |
| Setas do teclado | Cada pressionamento de seta para baixo sobe um ponto (50 → 75 → expandido); snap-position-change é disparado com o índice correto |
snapToPoint() |
Chega a cada ponto solicitado; o evento sempre corresponde |
| Escape | Fecha o painel e devolve o foco ao botão que o abriu |
| JavaScript ao movê-lo | 30 passos de roda, do meio para totalmente expandido: 0,64 ms de script, 0 ms de layout |
A última linha é o sentido do design: o painel percorreu todo seu alcance e a página executou bem menos de um milissegundo de JavaScript. O trabalho foi feito pelo navegador.
O que não verificamos: gestos de toque reais (os gestos sintéticos não foram registrados em Chromium sem interface), nem Safari nem Firefox. O autor indica que está testado em Chrome, Safari e Firefox, em desktop e celular.
Cuidado: a tecla Home fecha o painel
A direção do scroll é o oposto do que você poderia esperar. Como o painel é o contêiner com scroll, fazer scroll para baixo o expande e fazer scroll para cima o desce. Com swipe-to-dismiss, o ponto mais baixo equivale a “fechado”, então em nosso teste a tecla Home pulou diretamente para esse ponto e fechou o diálogo (o foco voltou corretamente ao botão). É coerente com o design do componente, mas se seus usuários navegam com teclado, teste-o e decida se você quer swipe-to-dismiss nesse painel.
Quais atributos é bom conhecer?
nested-scroll: o conteúdo do painel faz scroll independentemente de sua posição.expand-to-scroll: o conteúdo só faz scroll quando o painel está totalmente expandido (requernested-scroll).content-height: a altura máxima segue o conteúdo em vez de--sheet-max-height(não é compatível comnested-scroll).nested-scroll-optimization: evita reflows ao mudar de tamanho no modonested-scroll. O README o marca como experimental.
Os estilos se ajustam com propriedades personalizadas (--sheet-max-height, --sheet-background, --sheet-border-radius) e com partes: ::part(sheet), ::part(handle), ::part(content), ::part(header) e ::part(footer).
Em quais navegadores funciona?
A browserslist do pacote fixa Chrome e Edge 121 ou superior, Firefox 123 ou superior, e Safari e iOS 18.2 ou superior. Safari precisa da 18.2 porque a posição inicial depende de as propriedades scroll-snap-* admitirem animações discretas, algo que chegou nessa versão (dezembro de 2024). O pacote declara Node 24 ou superior em engines; em nosso teste foi instalado sem erros no Node 22.
É grátis?
Sim. Tem licença MIT, é mantido por Vili Ketonen e é publicado no npm com atestações de proveniência geradas a partir do GitHub Actions. No README você encontrará o artigo do autor que explica a técnica em detalhes.
E você? Qual componente de interface você teve que refazer porque não funcionava bem com teclado ou leitor de tela?
- Com uma biblioteca do meu framework (React, Vue…)
- Com código próprio de arrasto em JavaScript
- Com CSS scroll snap ou um web component como este
- Prefiro outra opção (me conte qual)
Comente abaixo ou, se este artigo chegou para você por email, responda diretamente ao email: sua resposta é publicada aqui.
Relacionado: Neobrutalism Components: componentes neobrutalistas grátis para React e shadcn, agora sobre Base UI