Full-screen navigation overlays — the kind that fade in and cover the whole viewport when you tap the hamburger — are a staple of modern local business sites. They look great on mobile, feel native-app-like, and keep the header clean. But the implementation details trip people up every time: the scroll locks that make the page jump, the ARIA attributes screen readers depend on, the CSS transition that flickers on first load.
This guide walks through the whole thing from scratch, using Tailwind CSS v4 and either vanilla JavaScript or Alpine.js. Both approaches are shown so you can pick what fits your stack.
What we are building
A site header with a hamburger button. When triggered, a full-screen overlay slides or fades in over the page, showing navigation links and a close button. Pressing Escape or clicking outside the links dismisses it. The page beneath cannot be scrolled while the overlay is open. Screen readers get proper aria-expanded and aria-hidden signals.
The HTML structure
Start with semantic markup. The toggle button lives in your <header>; the overlay is a sibling — not a child — of the header, so it can cover the full viewport without being clipped.
<header class="flex items-center justify-between px-6 py-4">
<a href="/" class="text-xl font-bold">Acme Co</a>
<button
id="nav-toggle"
aria-expanded="false"
aria-controls="nav-overlay"
aria-label="Open navigation"
class="relative z-50 flex h-10 w-10 flex-col items-center justify-center gap-1.5"
>
<span class="hamburger-bar block h-0.5 w-6 bg-current transition-transform duration-300"></span>
<span class="hamburger-bar block h-0.5 w-6 bg-current transition-opacity duration-300"></span>
<span class="hamburger-bar block h-0.5 w-6 bg-current transition-transform duration-300"></span>
</button>
</header>
<div
id="nav-overlay"
aria-hidden="true"
role="dialog"
aria-label="Navigation"
class="fixed inset-0 z-40 flex flex-col items-center justify-center
bg-slate-900 text-white
opacity-0 pointer-events-none
transition-opacity duration-300"
>
<nav>
<ul class="flex flex-col items-center gap-8 text-4xl font-semibold">
<li><a href="/" class="hover:text-slate-300 transition-colors">Home</a></li>
<li><a href="/services" class="hover:text-slate-300 transition-colors">Services</a></li>
<li><a href="/about" class="hover:text-slate-300 transition-colors">About</a></li>
<li><a href="/contact" class="hover:text-slate-300 transition-colors">Contact</a></li>
</ul>
</nav>
</div>
A few things worth noting here. The overlay starts with opacity-0 pointer-events-none — invisible and inert — rather than display: none. That is intentional: using display: none would kill the CSS transition-opacity animation entirely (you cannot transition from display: none to display: flex). The opacity approach gives us a smooth fade for free. The pointer-events-none class ensures the hidden overlay does not intercept clicks on the page underneath it.
The Tailwind CSS v4 setup
In Tailwind v4 the JavaScript config file is gone. Configuration lives directly in your CSS entry point via the @theme directive. A minimal setup looks like this:
/* src/styles/global.css */
@import "tailwindcss";
@theme {
--color-brand: oklch(0.55 0.18 250);
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
--ease-smooth: cubic-bezier(0.4, 0, 0.2, 1);
}
/* The open state — toggled by JS adding the .is-open class */
#nav-overlay.is-open {
opacity: 1;
pointer-events: auto;
}
/* Animate the hamburger bars into an X when open */
#nav-toggle[aria-expanded="true"] .hamburger-bar:nth-child(1) {
transform: translateY(8px) rotate(45deg);
}
#nav-toggle[aria-expanded="true"] .hamburger-bar:nth-child(2) {
opacity: 0;
}
#nav-toggle[aria-expanded="true"] .hamburger-bar:nth-child(3) {
transform: translateY(-8px) rotate(-45deg);
}
/* Lock body scroll */
body.nav-open {
overflow: hidden;
}
The @theme block registers custom design tokens as CSS custom properties. Every value you put there becomes available as a Tailwind utility — for example, --color-brand is usable as bg-brand or text-brand anywhere in your HTML. No plugin, no extend, no JavaScript file required.
The vanilla JavaScript implementation
The JavaScript does four things: toggle the open state, lock body scroll, handle the Escape key, and manage focus for accessibility.
// nav.js
const toggle = document.getElementById('nav-toggle');
const overlay = document.getElementById('nav-overlay');
const firstLink = overlay.querySelector('a');
function openNav() {
overlay.classList.add('is-open');
overlay.setAttribute('aria-hidden', 'false');
toggle.setAttribute('aria-expanded', 'true');
document.body.classList.add('nav-open');
// Move focus into the overlay so keyboard users land inside it
firstLink?.focus();
}
function closeNav() {
overlay.classList.remove('is-open');
overlay.setAttribute('aria-hidden', 'true');
toggle.setAttribute('aria-expanded', 'false');
document.body.classList.remove('nav-open');
// Return focus to the button that opened the menu
toggle.focus();
}
toggle.addEventListener('click', () => {
const isOpen = overlay.classList.contains('is-open');
isOpen ? closeNav() : openNav();
});
// Close on Escape key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && overlay.classList.contains('is-open')) {
closeNav();
}
});
// Close when clicking a nav link (single-page or client-side navigation)
overlay.querySelectorAll('a').forEach((link) => {
link.addEventListener('click', closeNav);
});
The scroll lock deserves a comment. Some tutorials toggle document.body.style.overflow = 'hidden' inline, which works but leaves a residual style attribute in the DOM. Using a CSS class (nav-open) keeps the lock declarative and easy to inspect or override per breakpoint. If your page has a fixed header that shifts when the scrollbar disappears, you can compensate with padding-right: var(--scrollbar-width) — but for most local business sites the simpler approach is fine.
The Alpine.js version
If you are already using Alpine.js (common in Astro projects), the same pattern collapses to a handful of directives. Add x-cloak to suppress the flash-of-visible-overlay before Alpine boots, and define [x-cloak] { display: none; } in your CSS.
<!-- In your CSS -->
<!-- [x-cloak] { display: none !important; } -->
<div x-data="{ open: false }" @keydown.escape.window="open = false">
<header class="flex items-center justify-between px-6 py-4">
<a href="/" class="text-xl font-bold">Acme Co</a>
<button
@click="open = !open"
:aria-expanded="open.toString()"
aria-controls="nav-overlay"
aria-label="Open navigation"
class="relative z-50 flex h-10 w-10 flex-col items-center justify-center gap-1.5"
>
<span class="block h-0.5 w-6 bg-current transition-transform duration-300"
:class="open ? 'translate-y-2 rotate-45' : ''"></span>
<span class="block h-0.5 w-6 bg-current transition-opacity duration-300"
:class="open ? 'opacity-0' : ''"></span>
<span class="block h-0.5 w-6 bg-current transition-transform duration-300"
:class="open ? '-translate-y-2 -rotate-45' : ''"></span>
</button>
</header>
<div
id="nav-overlay"
x-show="open"
x-cloak
x-transition:enter="transition-opacity duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition-opacity duration-300"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
:aria-hidden="(!open).toString()"
:class="open ? 'overflow-hidden' : ''"
role="dialog"
aria-label="Navigation"
class="fixed inset-0 z-40 flex flex-col items-center justify-center bg-slate-900 text-white"
>
<nav>
<ul class="flex flex-col items-center gap-8 text-4xl font-semibold">
<li><a href="/" @click="open = false" class="hover:text-slate-300 transition-colors">Home</a></li>
<li><a href="/services" @click="open = false" class="hover:text-slate-300 transition-colors">Services</a></li>
<li><a href="/about" @click="open = false" class="hover:text-slate-300 transition-colors">About</a></li>
<li><a href="/contact" @click="open = false" class="hover:text-slate-300 transition-colors">Contact</a></li>
</ul>
</nav>
</div>
<!-- Lock body scroll reactively -->
<div x-effect="document.body.classList.toggle('nav-open', open)"></div>
</div>
Alpine's x-transition:enter-start / x-transition:enter-end give you explicit control over the before-and-after states of the animation — more granular than the shorthand x-transition alone. The x-effect at the bottom is a clean way to run a side effect (toggling a class on body) whenever reactive data changes, without reaching outside the Alpine component with imperative code.
Accessibility checklist
Before shipping, run through these:
- aria-expanded on the toggle button reflects the open state (
"true"/"false"as strings). - aria-hidden on the overlay is the inverse:
"true"when closed,"false"when open. - Focus management: on open, focus moves into the overlay; on close, focus returns to the toggle button.
- Escape key closes the menu — required behavior for dialog-like overlays.
- Touch target size: the hamburger button should be at least 44×44 px. The
h-10 w-10(40 px) in the examples above is close; bump toh-11 w-11(44 px) if you want to be precise. - x-cloak (Alpine) or initial
opacity-0 pointer-events-none(vanilla) prevents the overlay from flashing visible before JavaScript runs.
Hiding the overlay on larger screens
If your header shows a regular horizontal nav on desktop, you want to hide both the hamburger and the overlay above a breakpoint. In Tailwind v4:
<button ... class="lg:hidden ...">...</button>
<div id="nav-overlay" class="lg:hidden fixed inset-0 ...">...</div>
One caveat: lg:hidden applies display: none at the Tailwind breakpoint regardless of the overlay's open state. That is fine for a responsive approach — the overlay just does not exist above the breakpoint. Make sure your JavaScript also guards against trying to open the nav when the toggle button is hidden (if (!toggle) return; or check offsetParent).
Slide-in variant
If you prefer the overlay to slide in from the right rather than fade, swap the transition utilities. In the vanilla CSS:
#nav-overlay {
/* base: off-screen to the right */
transform: translateX(100%);
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
/* Remove opacity-0/pointer-events approach; use transform instead */
}
#nav-overlay.is-open {
transform: translateX(0);
}
Remove the opacity-0 pointer-events-none classes from the HTML in this case — the element is always technically visible to the DOM (which is good for accessibility), just translated off-screen. Add overflow: hidden to the <body> or a wrapping container so the translated panel does not create a horizontal scrollbar.
Putting it together in Astro
In an Astro project, the nav markup lives in a BaseLayout.astro or a Header.astro component. The vanilla JavaScript goes in a <script> tag at the bottom of the component (Astro automatically bundles and deduplicates component scripts). The CSS goes in your global stylesheet, imported via @import "tailwindcss" and your @theme block.
If you use Alpine, install it with npm install alpinejs, then initialize it in a layout-level <script>:
import Alpine from 'alpinejs';
window.Alpine = Alpine;
Alpine.start();
Or use the CDN script tag — either works. Alpine's reactive approach fits Astro's component model well, since all the state and behavior is co-located in the markup rather than in a separate .js file you have to keep in sync.
Common mistakes to avoid
- Transitioning from display: none. CSS transitions do not fire when an element moves from
display: nonetodisplay: block. Use opacity + pointer-events, or transform, as shown above. - Forgetting the scroll lock. When the overlay opens, users on mobile can still scroll the page underneath if you do not add
overflow: hiddentobody. It is a subtle bug that reviewers always notice. - Not returning focus on close. Keyboard users will lose their place in the page if focus just drops to wherever the browser decides. Always move focus back to the trigger button.
- Using aria-hidden on the trigger button. Only hide the overlay itself, not the button. The button needs to remain reachable at all times.
Full-screen navigation is one of those components that seems simple until you hit the edge cases. Getting the transitions, scroll lock, and ARIA right the first time saves you a round of bug fixes after launch. The patterns above cover all of it — pick the vanilla JS or Alpine version depending on your project, and adjust the colors and sizing to match your design.