Descripción general
Un panel lateral de navegación es un panel que se desliza desde el borde del viewport sobre el contenido de la página y atenúa todo lo que hay detrás. Se abre desde un botón activador y se cierra deslizando el panel fuera de la pantalla, presionando el fondo atenuado o presionando Escape.
En esta guía, se implementa el panel lateral de la siguiente manera:
- Un elemento
popover="manual"que se promociona a la capa superior para que el panel y su::backdropse superpongan a todos los demás elementos de la página, independientemente del contexto de apilamiento. - Un contenedor de desplazamiento horizontal con dos detenciones de ajuste de desplazamiento de CSS, una para "abierto" y otra para "cerrado", de modo que el navegador controle el gesto de deslizamiento de forma nativa. Esto proporciona seguimiento de impulso, velocidad e interrupción de forma gratuita, sin código de evento de puntero de JavaScript.
- Una animación controlada por el desplazamiento que vincula la opacidad del fondo con la posición de desplazamiento, de modo que la atenuación aparezca y desaparezca de forma gradual a medida que el usuario arrastra el panel.
- Un
IntersectionObserveren el panel que detecta cuándo ingresó o salió por completo del viewport y usa esos momentos para actualizar el enfoque,aria-expandedyinert.
Este enfoque se prefiere por sobre las animaciones de transform controladas por JavaScript, ya que el mecanismo de desplazamiento le brinda al usuario un control directo de la posición del panel (su dedo lo controla, no una interpolación) y coincide mucho más con los patrones de interacción a los que los usuarios están acostumbrados en las aplicaciones nativas para dispositivos móviles.
Implementación
1. Marca
El panel lateral es un único cuadro emergente que contiene un elemento de desplazamiento horizontal, que, a su vez, contiene la "hoja" visible. El botón de activación se encuentra en el contenido de la 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
Restablece el cuadro emergente y llena el viewport
El cuadro emergente debe cubrir todo el viewport para que su ::backdrop atenúe toda la página y la superficie de deslizamiento se extienda de borde a borde. Los estilos predeterminados de popover del usuario-agente (centrado, con tamaño automático y con borde) se interponen y se deben restablecer.
.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);
}
Compila la superficie de deslizamiento con ajuste de desplazamiento
El desplazador es una cuadrícula horizontal más ancha que el viewport: la columna 1 contiene la hoja (ancho --drawer-width) y la columna 2 es un espaciador de pseudoelemento vacío del ancho del viewport. El ajuste entre las dos columnas es lo que abre y cierra el panel lateral.
.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;
}
Vincula la opacidad del fondo a la posición de desplazamiento
Una animación controlada por el desplazamiento asigna --drawer-backdrop de 1 (abierto) a 0 (cerrado) en todo el rango del desplazador, por lo que el fondo se atenúa y se muestra en perfecta sincronización con el arrastre.
/* 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. Abre y cierra el panel lateral
La apertura consta de dos pasos: promocionar el cuadro emergente a la capa superior y, luego, desplazar la hoja para que se vea. El cierre es un paso: desplázate hacia atrás hasta el espaciador; un observador (paso 4) oculta el cuadro emergente una vez que la hoja está completamente fuera de la pantalla.
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. Detecta el estado abierto y cerrado
Usa un IntersectionObserver en la hoja, no la posición de desplazamiento, como fuente de información para el estado del panel lateral. El observador se activa independientemente de cómo se haya movido la hoja (deslizamiento del usuario, desplazamiento programático, ajuste de posición), por lo que todas las rutas de descarte convergen en la misma devolución de llamada.
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. Conecta los controladores de activación y descarte
// 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();
});
Estrategias de resguardo
La API de Popover, la animación controlada por el desplazamiento que atenúa el fondo y scroll-initial-target son mejoras progresivas con resguardos simples que se pueden implementar fácilmente si se requiere una amplia compatibilidad con navegadores.
Atenuación del fondo de resguardo (sin compatibilidad con animation-timeline):
Detecta con CSS.supports('animation-timeline: scroll()') y escribe --drawer-backdrop desde un objeto de escucha de eventos scroll si no es compatible. El bloque @supports de CSS del paso 2 garantiza que los fotogramas clave nunca se apliquen en navegadores no compatibles, por lo que el valor de JavaScript es el único escritor.
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);
});
}
Resguardo de la posición de desplazamiento inicial (sin compatibilidad con scroll-initial-target):
Detecta con CSS.supports('scroll-initial-target', 'nearest') y, dentro de openDrawer(), desplaza el elemento de desplazamiento a la posición cerrada inmediatamente después de showPopover(). Sin esto, el panel lateral aparecería de forma instantánea en la posición abierta sin animación de deslizamiento.
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'});
}
Resguardo de @property (sin propiedades personalizadas registradas):
@property solo es necesario porque la animación controlada por el desplazamiento interpola --drawer-backdrop entre fotogramas clave. Sin el registro, la propiedad se trataría como una cadena y saltaría entre 0 y 1 sin desvanecimiento. Si se implementa el resguardo de animación controlada por el desplazamiento anterior, ese JavaScript escribe una cadena numérica nueva en --drawer-backdrop en cada fotograma de desplazamiento y nunca interpola, por lo que no se necesita un resguardo de @property independiente, ya que todos los navegadores que admiten animaciones controladas por el desplazamiento también admiten @property.
Resguardo de la API de Popover (sin compatibilidad con el atributo popover):
Debido a que este componente usa popover="manual" y, además, implementa el cierre por completo desde JavaScript, no depende de los comportamientos que definen la API de popover: el cierre ligero, el atributo popovertarget, el control de Escape administrado por la capa superior o la administración del enfoque. Las únicas funciones de cuadro emergente que usa son la promoción de la capa superior (a través de showPopover()) y el pseudoelemento ::backdrop.
Si se necesita una mayor compatibilidad con navegadores, no crees ramas en la detección de funciones; simplemente no uses popover en absoluto. Descarta el atributo popover="manual", reemplaza la promoción de la capa superior por position: fixed y un z-index alto, reemplaza ::backdrop por un elemento hermano con un diseño idéntico (con la misma propiedad personalizada --drawer-backdrop) y activa o desactiva la visibilidad desde una clase en lugar de showPopover()/hidePopover(). El resto del componente (ajuste de desplazamiento, la animación de fondo controlada por el desplazamiento, el IntersectionObserver y los controladores de descarte) no cambia.