Como fazer um bottom sheet acessível na web com CSS scroll snap: testamos e medimos

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 como expanded; 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 (requer nested-scroll).
  • content-height: a altura máxima segue o conteúdo em vez de --sheet-max-height (não é compatível com nested-scroll).
  • nested-scroll-optimization: evita reflows ao mudar de tamanho no modo nested-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?

Como você implementa hoje os bottom sheets em seus projetos web?
  • 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)
0 votantes

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