NavigationHome
Drawer navbar
A monogram and a burger that folds into a cross. The menu slides in from the side on glass, and its links follow one by one.
A page under the menu
Scroll this page and the header turns to glass. Open the menu and the drawer slides over it.
More of the page carries on below, so there is something for the glass to blur.
Code 2 files
src/gallery/drawer-nav/DrawerNav.astroAstro243 lines
---
// A monogram and a burger. The burger folds into a cross and a glass drawer
// slides in from the side, its links following one by one. The header turns
// to glass once the page under it scrolls.
// Fills its parent: header, scrim and drawer are absolute, the open state is a
// class on the demo root, so several can run on one page. On a real page make
// them fixed and put the class on <html>.
// Placeholder monogram and links: swap in the real ones.
interface Link { href: string; label: string }
interface Props { mark?: [string, string]; links?: Link[] }
const {
mark = ['A', 'B'],
links = [
{ href: '#', label: 'Home' },
{ href: '#', label: 'Work' },
{ href: '#', label: 'Journey' },
{ href: '#', label: 'About' },
{ href: '#', label: 'Contact' },
],
} = Astro.props;
const icons = [
'M8.5 7 3.5 12l5 5M15.5 7l5 5-5 5',
'M4 4h16v16H4zM8 10v6M8 7.5v.01M12 16v-3.5a2 2 0 0 1 4 0V16M12 10v6',
'M3 6h18v12H3zM3.5 6.5l8.5 6.5 8.5-6.5',
];
---
<div class="drawer-nav" data-drawer-nav>
<div class="page" data-page tabindex="0" role="region" aria-label="Sample page under the menu">
<h3>A page under the menu</h3>
<p>Scroll this page and the header turns to glass. Open the menu and the drawer slides over it.</p>
<div class="blocks" aria-hidden="true">
<span class="b1"></span><span class="b2"></span><span class="b3"></span><span class="b4"></span>
</div>
<p>More of the page carries on below, so there is something for the glass to blur.</p>
<div class="blocks" aria-hidden="true">
<span class="b3"></span><span class="b1"></span><span class="b4"></span><span class="b2"></span>
</div>
</div>
<header class="hdr" data-hdr>
<a href="#" class="mono">
<span aria-hidden="true">{mark[0]}</span><span aria-hidden="true">{mark[1]}</span><span class="visually-hidden">Home</span>
</a>
<button class="burger" type="button" aria-expanded="false" aria-label="Open menu" data-burger>
<span></span><span></span><span></span>
</button>
</header>
<div class="scrim" data-scrim></div>
<nav class="drawer glass" aria-label="Sample menu" hidden data-drawer>
<ol class="links">
{links.map((l, i) => (
<li style={`--i:${i}`}>
<a href={l.href} aria-current={i === 0 ? 'page' : undefined}>{l.label}</a>
</li>
))}
</ol>
<div class="foot" style={`--i:${links.length}`}>
{['GitHub', 'LinkedIn', 'Email'].map((name, i) => (
<a href="#" aria-label={name}>
<svg viewBox="0 0 24 24" aria-hidden="true"><path d={icons[i]} /></svg>
</a>
))}
</div>
</nav>
</div>
<style>
.drawer-nav {
--dn-ink: #f1f1ef; --dn-muted: #a3a49f; --dn-surface: #262626; --dn-accent: #8fa68e;
--dn-ease: cubic-bezier(0.23, 1, 0.32, 1);
--glass-bg: rgb(30 30 30 / 0.55); --glass-ring: rgb(255 255 255 / 0.09); --glass-blur: 18px;
position: absolute; inset: 0; overflow: hidden; color: var(--dn-ink);
container-type: inline-size;
}
.page { position: absolute; inset: 0; overflow-y: auto; padding: 5.5rem 1.5rem 2rem; outline-offset: -2px; }
h3 { font-size: clamp(1.75rem, 7cqw, 3.25rem); font-weight: 500; letter-spacing: -0.03em; line-height: 1; max-width: 14ch; margin: 0; }
.page p { color: var(--dn-muted); max-width: 40ch; margin: 1rem 0 0; }
.blocks { display: grid; grid-template-columns: 2fr 1fr; gap: 0.75rem; margin-top: 1.5rem; }
.blocks span { height: 7rem; border-radius: 6px; background: var(--dn-surface); }
.blocks .b1 { background: var(--dn-accent); }
.blocks .b4 { background: #3a3a3a; }
.glass {
background: var(--glass-bg);
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(140%);
backdrop-filter: blur(var(--glass-blur)) saturate(140%);
box-shadow: inset 0 0 0 1px var(--glass-ring);
}
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
.drawer-nav { --glass-bg: rgb(30 30 30 / 0.94); }
}
.hdr {
position: absolute; top: 0; left: 0; right: 0; z-index: 5;
display: flex; justify-content: space-between; align-items: center;
padding: 1rem 1.25rem;
transition: background-color 300ms ease, padding 300ms var(--dn-ease);
}
.hdr.scrolled {
padding-block: 0.6rem;
background: var(--glass-bg);
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(140%);
backdrop-filter: blur(var(--glass-blur)) saturate(140%);
}
.open .hdr { background: transparent; -webkit-backdrop-filter: none; backdrop-filter: none; }
.mono { display: inline-flex; gap: 0.02em; font-size: 1.5rem; font-weight: 500; letter-spacing: 0.02em; padding: 0.25rem 0.1rem; }
.mono span[aria-hidden] { display: inline-block; transition: transform 500ms var(--dn-ease), color 200ms ease; }
@media (hover: hover) and (pointer: fine) {
.mono:hover span:first-child { transform: translateY(-3px); color: var(--dn-accent); }
.mono:hover span:nth-child(2) { transform: translateY(3px); }
}
.burger {
all: unset; cursor: pointer; position: relative; z-index: 6;
width: 44px; height: 44px; display: grid; place-content: center; gap: 6px; border-radius: 50%;
}
.burger:focus-visible { outline: 2px solid var(--dn-accent); outline-offset: 2px; }
.burger span {
display: block; width: 26px; height: 1.5px; background: var(--dn-ink);
transition: transform 400ms var(--dn-ease), opacity 200ms ease, width 300ms var(--dn-ease);
}
.burger span:nth-child(2) { width: 18px; justify-self: end; }
@media (hover: hover) and (pointer: fine) { .burger:hover span:nth-child(2) { width: 26px; } }
.burger:active { transform: scale(0.94); }
.open .burger span:nth-child(1) { transform: translateY(7.5px) rotate(45deg); }
.open .burger span:nth-child(2) { opacity: 0; }
.open .burger span:nth-child(3) { transform: translateY(-7.5px) rotate(-45deg); }
.scrim {
position: absolute; inset: 0; z-index: 3; background: rgb(10 10 10 / 0.45);
opacity: 0; pointer-events: none; transition: opacity 400ms ease;
}
.open .scrim { opacity: 1; pointer-events: auto; }
.drawer {
position: absolute; top: 0; right: 0; bottom: 0; z-index: 4;
width: min(380px, 86%);
padding: 5.5rem 2.25rem 2rem;
display: flex; flex-direction: column; justify-content: space-between; gap: 1.5rem;
transform: translateX(100%);
transition: transform 600ms cubic-bezier(0.77, 0, 0.175, 1);
}
.drawer[hidden] { display: none; }
.open .drawer { transform: none; }
.links { display: grid; gap: 0.25rem; margin: 0; padding: 0; list-style: none; }
.links li, .foot {
opacity: 0; transform: translateX(40px);
transition: opacity 400ms ease, transform 600ms var(--dn-ease);
}
.open .links li, .open .foot { opacity: 1; transform: none; transition-delay: calc(220ms + var(--i) * 70ms); }
.links a {
position: relative; display: inline-block;
padding: 0.4rem 0 0.4rem 2.2rem;
font-size: clamp(1.375rem, 4cqw, 2rem); letter-spacing: -0.02em; color: var(--dn-muted);
transition: color 200ms ease, transform 300ms var(--dn-ease);
}
.links a::before {
content: ''; position: absolute; left: 0; top: 50%;
width: 1.5rem; height: 1.5px; background: var(--dn-accent);
transform: scaleX(0); transform-origin: left; transition: transform 400ms var(--dn-ease);
}
.links a[aria-current] { color: var(--dn-ink); }
.links a[aria-current]::before { transform: scaleX(1); }
@media (hover: hover) and (pointer: fine) { .links a:hover { color: var(--dn-ink); transform: translateX(4px); } }
.foot { display: flex; gap: 1.25rem; }
.foot a { color: var(--dn-muted); padding: 0.4rem; margin: -0.4rem; transition: color 200ms ease, transform 200ms var(--dn-ease); }
.foot svg { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; display: block; }
@media (hover: hover) and (pointer: fine) { .foot a:hover { color: var(--dn-ink); transform: translateY(-2px); } }
@media (prefers-reduced-motion: reduce) {
.hdr, .drawer, .links li, .foot, .scrim, .burger span { transition: none; }
.links li, .foot { opacity: 1; transform: none; }
}
</style>
<script>
import { playsItself, whenFree } from '../../lib/preview';
const OPEN_FOR = 2800; // ms the drawer stays open while it plays itself
const SHUT_FOR = 1800; // ms it stays shut between openings
document.querySelectorAll<HTMLElement>('[data-drawer-nav]').forEach((root) => {
const page = root.querySelector<HTMLElement>('[data-page]')!;
const hdr = root.querySelector<HTMLElement>('[data-hdr]')!;
const burger = root.querySelector<HTMLButtonElement>('[data-burger]')!;
const drawer = root.querySelector<HTMLElement>('[data-drawer]')!;
const scrim = root.querySelector<HTMLElement>('[data-scrim]')!;
const isOpen = () => root.classList.contains('open');
// Placeholder links go nowhere; stop them jumping the page to the top.
root.querySelectorAll<HTMLAnchorElement>('a[href="#"]').forEach((a) => a.addEventListener('click', (e) => e.preventDefault()));
page.addEventListener('scroll', () => hdr.classList.toggle('scrolled', page.scrollTop > 24), { passive: true });
// The drawer is display:none while closed; it is shown a frame before the slide-in
// starts and hidden again once the slide-out has finished.
let hideTimer = 0;
const setOpen = (open: boolean, moveFocus = true) => {
clearTimeout(hideTimer);
burger.setAttribute('aria-expanded', String(open));
burger.setAttribute('aria-label', open ? 'Close menu' : 'Open menu');
if (open) {
drawer.hidden = false;
requestAnimationFrame(() => requestAnimationFrame(() => root.classList.add('open')));
if (moveFocus) setTimeout(() => drawer.querySelector('a')?.focus({ preventScroll: true }), 250);
} else {
root.classList.remove('open');
hideTimer = window.setTimeout(() => { drawer.hidden = true; }, 650);
}
};
burger.addEventListener('click', () => setOpen(!isOpen()));
scrim.addEventListener('click', () => setOpen(false));
drawer.addEventListener('click', (e) => { if ((e.target as HTMLElement).closest('a')) setOpen(false); });
// Escape and the focus trap only act while focus is inside this demo.
root.addEventListener('keydown', (e) => {
if (!isOpen()) return;
if (e.key === 'Escape') { setOpen(false); burger.focus(); }
if (e.key === 'Tab') {
const list = [burger, ...drawer.querySelectorAll<HTMLElement>('a')];
const i = list.indexOf(document.activeElement as HTMLElement);
if (e.shiftKey && i <= 0) { e.preventDefault(); list[list.length - 1]!.focus(); }
else if (!e.shiftKey && i === list.length - 1) { e.preventDefault(); list[0]!.focus(); }
}
});
// In a grid card: open, hold, close, wait, again. Never moves focus.
if (!playsItself(root)) return;
let timer = 0;
const cycle = () => {
const open = !isOpen();
setOpen(open, false);
timer = window.setTimeout(cycle, open ? OPEN_FOR : SHUT_FOR);
};
whenFree(root, (free) => {
clearTimeout(timer);
if (free) timer = window.setTimeout(cycle, SHUT_FOR);
});
});
</script>src/lib/preview.tsTypeScript24 lines
// Some demos play themselves in a grid card (anything inside [data-preview]).
// This says when they may: on screen, tab visible, no pointer or focus inside,
// so the demo stops the moment someone reaches for it.
export const playsItself = (root: HTMLElement) =>
!!root.closest('[data-preview]') && !matchMedia('(prefers-reduced-motion: reduce)').matches;
export function whenFree(root: HTMLElement, onChange: (free: boolean) => void) {
let visible = false, held = false, free = false;
const sync = () => {
const next = visible && !held && !document.hidden;
if (next === free) return;
free = next;
onChange(free);
};
const hold = (on: boolean) => { held = on; sync(); };
new IntersectionObserver(([e]) => { visible = !!e?.isIntersecting; sync(); }).observe(root);
document.addEventListener('visibilitychange', sync);
root.addEventListener('pointerenter', () => hold(true));
root.addEventListener('pointerleave', () => hold(false));
root.addEventListener('focusin', () => hold(true));
root.addEventListener('focusout', (e) => { if (!root.contains(e.relatedTarget as Node)) hold(false); });
}