Visão geral
Uma gaveta de navegação é um painel que desliza da borda da janela de visualização sobre o conteúdo da página, escurecendo tudo o que está atrás dele. Ele é aberto por um botão de acionamento e dispensado ao deslizar o painel para fora da tela, tocar no plano de fundo esmaecido ou pressionar Esc.
Este guia implementa o painel como:
- Um elemento
popover="manual"promovido para a camada superior para que o painel e o::backdropse sobreponham a todos os outros elementos na página, independentemente do contexto de empilhamento. - Um contêiner de rolagem horizontal com duas paradas de ajuste de rolagem CSS (uma para "aberto" e outra para "fechado") para que o gesto de deslizar seja processado de forma nativa pelo navegador. Isso oferece rastreamento de impulso, velocidade e interrupção sem custo financeiro, sem código de evento de ponteiro JavaScript.
- Uma animação de rolagem que vincula a opacidade do plano de fundo à posição de rolagem, para que o escurecimento apareça e desapareça gradualmente conforme o usuário arrasta o painel.
- Um
IntersectionObserverno painel que detecta quando ele entrou ou saiu totalmente da janela de visualização e usa esses momentos para atualizar o foco,aria-expandedeinert.
Essa abordagem é preferível às animações transform baseadas em JavaScript, porque o mecanismo de rolagem dá ao usuário controle direto sobre a posição do painel (o dedo dele o move, não uma interpolação) e se assemelha muito mais aos padrões de interação com que os usuários estão acostumados em apps nativos para dispositivos móveis.
Implementação
1. Marcação
O painel é um único popover com um controle de rolagem horizontal, que contém a "página" visível. O botão de acionamento fica no conteúdo da página.
<!-- popover="manual" is REQUIRED. Do not use popover="auto" or "hint". -->
<div class="Drawer" id="drawer" popover="manual">
<div class="Drawer-scroller">
<nav class="Drawer-sheet" tabindex="-1">
<!-- tabindex="-1" makes the sheet programmatically focusable so we
can move focus into it when the drawer opens, without adding it
to the natural tab order. -->
<ul>
<li><a href="/page-1">Page 1</a></li>
<li><a href="/page-2">Page 2</a></li>
<!-- ... -->
</ul>
</nav>
</div>
</div>
<main>
<header>
<!-- aria-controls links the trigger to the drawer; aria-expanded
reflects the current state for assistive tech. -->
<button id="drawer-open"
aria-label="Menu"
aria-expanded="false"
aria-controls="drawer">
<!-- MANDATORY: Inline decorative SVGs MUST define aria-hidden="true" -->
<svg aria-hidden="true" viewBox="0 0 24 24">...</svg>
</button>
</header>
<!-- Page content. -->
</main>
2. Estilos
Redefinir o popover e preencher a janela de visualização
O popover precisa cobrir toda a janela de visualização para que o ::backdrop escureça a página inteira e a superfície de deslizar se estenda de ponta a ponta. Os estilos padrão de popover do user agent (centralizado, dimensionado automaticamente, com bordas) atrapalham e precisam ser redefinidos.
.Drawer {
/* min() caps the sheet width on large screens but on a phone leaves
a 20% peek of page content visible, which is the affordance that
tells the user they can tap outside to dismiss. */
--drawer-width: min(20em, 80dvw);
/* Custom property driven by the scroll-driven animation below.
0 = drawer fully closed (transparent backdrop).
1 = drawer fully open (visible backdrop). */
--drawer-backdrop: 0;
/* Reset UA popover style that would constrain the element. */
width: auto;
height: auto;
background: transparent;
border: 0;
overflow: visible;
}
/* Style the popover's ::backdrop to achieve the overlay effect and
provide visual affordances indicating that the rest of the page is inert */
.Drawer::backdrop {
background: #000;
/* Use calc() to limit the opacity range so the content beneath is visible */
opacity: calc(var(--drawer-backdrop) / 2);
}
Criar a superfície de deslizar com ajuste de rolagem
O scroller é uma grade horizontal mais larga que a janela de visualização: a coluna 1 contém a página (largura --drawer-width), e a coluna 2 é um espaçador de pseudoelemento vazio com a largura da janela de visualização. O ajuste entre as duas colunas é o que abre e fecha o painel.
.Drawer-scroller {
position: relative;
display: grid;
/* Sheet on the left, full-viewport spacer on the right. The user
scrolls between the two snap stops to open and close. */
grid-template-columns: var(--drawer-width) 100%;
overflow-x: scroll;
/* Stop the swipe from chaining into the page's vertical scroll
when the user reaches either snap edge. */
overscroll-behavior: none;
scrollbar-width: none;
/* `mandatory` guarantees the drawer always settles fully open or
fully closed — never half-open after a partial swipe. */
scroll-snap-type: x mandatory;
}
/* Enable smooth scrolling natively, but only if the user has not
requested reduced motion. */
@media (prefers-reduced-motion: no-preference) {
.Drawer-scroller {
scroll-behavior: smooth;
}
}
/* The empty spacer that creates the "closed" snap stop. */
.Drawer-scroller::after {
content: '';
scroll-snap-align: end;
/* Open the popover already scrolled to this stop (drawer off-screen),
so the JS only needs to scroll to the open position to
animate it in. */
scroll-initial-target: nearest;
}
.Drawer-sheet {
display: grid;
grid-template-rows: auto 1fr;
/* Use `svh` (small viewport height) — not `vh` or `dvh` — so the
sheet height does not jump when the iOS Safari address bar
resizes mid-swipe. */
height: 100svh;
background: #333;
color: #fff;
overflow-y: auto;
scroll-snap-align: start;
scrollbar-width: none;
}
Vincular a opacidade do plano de fundo à posição de rolagem
Uma animação de rolagem mapeia --drawer-backdrop de 1 (aberto) para 0 (fechado) em todo o intervalo do controle de rolagem, de modo que o plano de fundo apareça e desapareça em perfeita sincronia com o arrasto.
/* MANDATORY: Wrap this entire block in @supports. Browsers that don't
support animation-timeline still parse the @keyframes and would
apply the animation's `0%` value (--drawer-backdrop: 1) at all
times, leaving the backdrop permanently opaque. The @supports gate
ensures the animation is only registered where it actually works. */
@supports (animation-timeline: scroll()) {
.Drawer {
/* timeline-scope lets .Drawer reference a scroll-timeline that
is defined on its descendant (the scroller). Without this, the
timeline name is not visible to the .Drawer element. */
timeline-scope: --drawer-fade;
animation: fade-drawer-backdrop linear both;
animation-timeline: --drawer-fade;
}
.Drawer-scroller {
/* The horizontal scroll position of this element drives the
timeline named `--drawer-fade`. */
scroll-timeline: --drawer-fade x;
}
/* @property is REQUIRED. Without registering --drawer-backdrop with
a `<number>` syntax, the browser treats it as a string and cannot
interpolate it — the backdrop would jump from 0 to 1 with no
fade. */
@property --drawer-backdrop {
syntax: '<number>';
inherits: true;
initial-value: 0;
}
@keyframes fade-drawer-backdrop {
/* Scroll position 0 = drawer fully open = backdrop visible. */
0% { --drawer-backdrop: 1 }
/* Scroll position 100% = drawer fully closed = backdrop hidden. */
100% { --drawer-backdrop: 0 }
}
}
3. Abrir e fechar a gaveta
A abertura é feita em duas etapas: promover o popover para a camada superior e, em seguida, rolar a página para que ela fique visível. O fechamento é uma etapa: role de volta para o espaçador. Um observador (etapa 4) oculta o popover quando a página está totalmente fora da tela.
const drawer = document.getElementById('drawer');
const openBtn = document.getElementById('drawer-open');
const scroller = drawer.querySelector('.Drawer-scroller');
const sheet = drawer.querySelector('.Drawer-sheet');
function openDrawer() {
// Show the popover first so the element is in the top layer before
// we trigger any scrolling. `scroll-initial-target` (set on the
// ::after spacer) places the initial scroll position at the closed
// stop, so the drawer enters the top layer already off-screen.
drawer.showPopover();
// Scroll the sheet into view. The `behavior: 'auto'` option defers
// to the CSS `scroll-behavior` property, which will be smooth unless
// the user prefers reduced motion. Snap takes over at the end and
// locks the drawer fully open.
scroller.scrollTo({left: 0, behavior: 'auto'});
}
function closeDrawer() {
// Scroll back to the spacer. Do NOT call hidePopover() here —
// doing so would remove the element from the top layer mid-animation
// and the close animation would not be visible. The
// IntersectionObserver in step 4 hides the popover once the sheet
// has actually left the viewport.
scroller.scrollTo({left: scroller.offsetWidth, behavior: 'auto'});
}
4. Detectar estado aberto e fechado
Use um IntersectionObserver na página, não a posição de rolagem, como a fonte de verdade para o estado da gaveta. O observador é acionado independentemente de como a página se moveu (deslizar do usuário, rolagem programática, ajuste de rolagem), então todos os caminhos de dispensa convergem no mesmo callback.
function onDrawerOpened() {
// Mark the rest of the page inert so keyboard and screen-reader
// users cannot tab into content hidden behind the drawer.
document.querySelector('main').inert = true;
openBtn.setAttribute('aria-expanded', 'true');
// Move focus into the drawer for keyboard users.
sheet.focus();
}
function onDrawerClosed() {
// Hide the popover only after the close animation completes,
// so the slide-out is visible to the user.
drawer.hidePopover();
document.querySelector('main').inert = false;
openBtn.setAttribute('aria-expanded', 'false');
}
// Treat "any pixel of the sheet visible inside the popover root" as
// "open enough to count as not closed". This threshold is intentionally
// tiny so the closed callback only fires once the sheet is truly gone.
const visibleThreshold = 1 / window.innerWidth;
const observer = new IntersectionObserver(
(entries) => {
// During programmatic scrolling the observer can deliver multiple
// entries in one batch. Only the most recent describes the
// current state; earlier entries are intermediate positions.
const entry = entries.at(-1);
if (entry.intersectionRatio < visibleThreshold) onDrawerClosed();
if (entry.intersectionRatio === 1) onDrawerOpened();
},
// root: drawer makes the popover element the intersection root,
// so the ratio reflects the sheet's visibility within the popover
// (i.e. how much of it has been swiped on-screen).
{root: drawer, threshold: [visibleThreshold, 1]},
);
observer.observe(sheet);
5. Conectar os gerenciadores de acionamento e dispensa
// Open trigger.
openBtn.addEventListener('click', openDrawer);
// Light-dismiss: a tap on the dimmed area (anywhere inside the
// popover but outside the sheet) closes the drawer. We implement
// this manually because popover="manual" disables the browser's
// built-in light-dismiss (which would also fire mid-swipe — see step 1).
drawer.addEventListener('click', (event) => {
if (!sheet.contains(event.target)) closeDrawer();
});
// Escape key. Listen on document because focus may be inside the
// drawer when the user presses Escape.
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') closeDrawer();
});
Estratégias de fallback
A API Popover, a animação orientada por rolagem que esmaece o plano de fundo e scroll-initial-target são aprimoramentos progressivos com substitutos simples que podem ser facilmente implementados se for necessário um amplo suporte do navegador.
Esmaecimento do plano de fundo substituto (sem suporte a animation-timeline):
Detecte com CSS.supports('animation-timeline: scroll()') e grave --drawer-backdrop de um listener de eventos scroll se não houver suporte. O bloco CSS @supports na etapa 2 garante que os frames-chave nunca sejam aplicados em navegadores incompatíveis. Assim, o valor do JavaScript é o único gravador.
if (!CSS.supports('animation-timeline: scroll()')) {
scroller.addEventListener('scroll', () => {
// Same mapping as the @keyframes: 0 scroll = 1 (open),
// sheet-width scroll = 0 (closed).
const ratio = 1 - scroller.scrollLeft / sheet.offsetWidth;
drawer.style.setProperty('--drawer-backdrop', ratio);
});
}
Substituição da posição de rolagem inicial (sem suporte a scroll-initial-target):
Detecte com CSS.supports('scroll-initial-target', 'nearest') e, dentro de openDrawer(), role o scroller para a posição fechada imediatamente após showPopover(). Sem isso, o painel apareceria instantaneamente na posição aberta, sem animação de deslizamento.
async function openDrawer() {
drawer.showPopover();
if (!CSS.supports('scroll-initial-target', 'nearest')) {
// Jump-scroll to the closed stop so the scroll below
// animates the drawer in from off-screen.
scroller.scrollTo({left: scroller.offsetWidth, behavior: 'instant'});
// Wait two animation frames for the jump-scroll to commit.
// A single rAF is not enough — the second `scrollTo` would
// cancel the first before the browser has a chance to apply it.
await new Promise((r) =>
requestAnimationFrame(() => requestAnimationFrame(r))
);
}
scroller.scrollTo({left: 0, behavior: 'auto'});
}
@property substituto (sem propriedades personalizadas registradas):
@property só é necessário porque a animação orientada por rolagem interpola --drawer-backdrop entre frames-chave. Sem o registro, a propriedade seria tratada como uma string e pularia entre 0 e 1 sem esmaecimento. Se o substituto de animação de rolagem acima estiver em vigor, esse JavaScript vai gravar uma nova string numérica em --drawer-backdrop em cada frame de rolagem e nunca vai interpolar. Portanto, nenhum substituto de @property separado é necessário, já que todos os navegadores que oferecem suporte a animações de rolagem também oferecem suporte a @property.
Substituto da API Popover (sem suporte ao atributo popover):
Como esse componente usa popover="manual" e implementa a dispensa inteiramente em JavaScript, ele não depende dos comportamentos de definição da API Popover: dispensa leve, o atributo popovertarget, o processamento de Escape gerenciado pela camada superior ou o gerenciamento de foco. Os únicos recursos de popover que ele realmente usa são a promoção de camada superior (via showPopover()) e o pseudoelemento ::backdrop.
Se for necessário um suporte mais amplo do navegador, não use a detecção de recursos. Simplesmente não use pop-over. Remova o atributo popover="manual", substitua a promoção da camada superior por position: fixed e um z-index alto, substitua ::backdrop por um elemento irmão com o mesmo estilo (usando a mesma propriedade personalizada --drawer-backdrop) e alterne a visibilidade de uma classe em vez de showPopover()/hidePopover(). O restante do componente (ajuste de rolagem, animação de plano de fundo orientada por rolagem, IntersectionObserver e gerenciadores de dispensação) não muda.