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

Você pode construir um bottom sheet acessível na web sem escrever lógica de arrastar: 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 instalamos e medimos para ver o que realmente faz.

:white_check_mark: yoDEV Tested · 12 de outubro de 2026
Instalamos pure-web-bottom-sheet 1.1.1 e o medimos no Chromium 141 sem interface com Playwright, em um viewport móvel de 390×844. Os resultados que reportamos como nossos saem desse teste.

O que é um bottom sheet e por que os feitos à mão falham?

Um bottom sheet é o painel que sobe desde a 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 tácteis e de apontador 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 se omite é 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 se arrasta. O próprio elemento <bottom-sheet> faz scroll vertical com scroll-snap-type: y mandatory (o 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 o descreve como um funcionamento quase sem JavaScript em navegadores modernos: o JS se reserva para compatibilidade regressiva e para funções opcionais como fechar deslizando para baixo. Para os efeitos visuais usa também CSS scroll-driven animations.

Que vantagem tem 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 de forma nativa (com customElements.define), então não dependem do framework que você usa. Para React e Vue, o pacote inclui além disso envolvedores próprios, e para renderização no servidor oferece um modelo de declarative Shadow DOM.

Como se instala e se usa?

Se instala desde npm:

npm install pure-web-bottom-sheet

A versão modal envolve um <dialog> nativo dentro de <bottom-sheet-dialog-manager>, que contribui com 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 desde https://unpkg.com/pure-web-bottom-sheet.

Em React se importam BottomSheet e BottomSheetDialogManager desde pure-web-bottom-sheet/react; em Vue, VBottomSheet e VBottomSheetDialogManager desde 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 um percentual da altura máxima do painel. Há três regras da documentação que é fácil passar batido:

  • 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 único ponto para escolher onde o painel se abre.
  • Adicione class="top" ao ponto de altura completa para que seja reportado como expanded; deve ser o primeiro snap point do DOM.

Com swipe-to-dismiss existe além disso um ponto implícito na parte inferior da tela: chegar a ele fecha o painel.

Para reagir aos mudanças de posição, escute o evento snap-position-change, cujo detalhe inclui snapIndex (0 é colapsado) e sheetState (collapsed, partially-expanded ou expanded). Para movê-lo a partir de 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 faz este componente 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 sua etiqueta e marcado como modal, sem uma única linha de ARIA escrita à mão. 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 sua etiqueta, modal: true
Setas do teclado Cada pressão de seta para baixo sobe um ponto (50 → 75 → expandido); snap-position-change se dispara com o índice correto
snapToPoint() Chega a cada ponto pedido; o evento coincide sempre
Escape Fecha o painel e devolve o foco ao botão que o abriu
JavaScript ao movê-lo 30 passos de roda, da metade a 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 bastante menos de um milissegundo de JavaScript. O trabalho o fez o navegador.

O que não verificamos: gestos tácteis reais (os gestos sintéticos não se registraram no 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 é a contrária à que você poderia esperar. Como o painel é o contêiner com scroll, fazer scroll para baixo o expande e fazer scroll para cima o abaixa. Com swipe-to-dismiss, o ponto mais baixo equivale a “fechado”, então em nosso teste a tecla Home (Início) saltou 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 quer swipe-to-dismiss nesse painel.

Que atributos convém conhecer?

  • nested-scroll: o conteúdo do painel faz scroll de forma independente 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 que navegadores funciona?

O 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 que as propriedades scroll-snap-* admitam animações discretas, algo que chegou nessa versão (dezembro de 2024). O pacote declara Node 24 ou superior em engines; em nosso teste se instalou sem erros no Node 22.

É grátis?

Sim. Tem licença MIT, o mantém Vili Ketonen e se publica no npm com atestações de procedência geradas desde GitHub Actions. No README você encontrará o artigo do autor que explica a técnica em detalhe.


E você? Qual componente de interface 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 arrastar em JavaScript
  • Com CSS scroll snap ou um web component como este
  • Prefiro outra opção (conte-nos qual)
0 votantes

Comente abaixo ou, se este artigo chegou para você por email, responda diretamente ao email: sua resposta se publica aqui.

Relacionado: Neobrutalism Components: componentes neobrutalistas grátis para React e shadcn, agora sobre Base UI