Part 2 of a series on building with the modern web platform — and letting AI handle the learning curve.
- Part 0: I Stopped Building With What I Know
- Part 1: The Web Doesn't Need Your JavaScript
- Part 2: One HTML Attribute Replaced 70 Lines of JavaScript (this article)
- Part 3: AI Didn't Write My Code. It Let Me Design It. (coming soon)
In the last article I talked about how the web platform has caught up to most of what we still solve with JavaScript. The mobile menu on my blog was the last piece of custom JS in the codebase. Here's what actually changed — the before, the after, and the bumps along the way.
What the old code looked like
The menu was a standard implementation. A hamburger button toggled a nav panel and a backdrop overlay. The script handled everything: open, close, aria-expanded, click outside, Escape key, link clicks closing the menu, and a resize listener to clean up when switching between mobile and desktop.
Here's the essence of what it did:
function initHeader() {
const toggle = document.querySelector(".header__toggle");
const nav = document.querySelector("#nav-menu");
const overlay = document.querySelector("#nav-overlay");
function close() {
toggle?.setAttribute("aria-expanded", "false");
nav?.setAttribute("data-open", "false");
overlay?.setAttribute("data-open", "false");
}
function open() {
toggle?.setAttribute("aria-expanded", "true");
nav?.setAttribute("data-open", "true");
overlay?.setAttribute("data-open", "true");
}
toggle.addEventListener("click", () => {
nav?.getAttribute("data-open") === "true" ? close() : open();
});
overlay?.addEventListener("click", close);
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") close();
});
// ... plus link clicks, resize handler, aria sync
}
About 70 lines total. It worked. But every behavior — toggle, dismiss, focus, state — was ours to maintain.
What replaced it
Two HTML attributes and some CSS.
The markup went from a button with a click handler and a nav with data-open state to this:
<button
popovertarget="nav-menu"
popovertargetaction="toggle"
aria-controls="nav-menu"
aria-label="Menu"
>
<span class="header__toggle-icon"></span>
</button>
<nav id="nav-menu" popover="auto">
<!-- nav links -->
</nav>
popover="auto" tells the browser: this is a panel. Show it when triggered. Close it when the user clicks outside, presses Escape, or opens another popover. Put it in the top layer. No z-index management needed.
popovertarget="nav-menu" on the button tells the browser: this button controls that popover. Toggle it on click.
That's the entire open/close/dismiss logic. No script.
The overlay div was also removed. The popover's ::backdrop pseudo-element replaced it — styled with CSS only, including a gradient so the header stays undimmed:
.header__nav::backdrop {
background: linear-gradient(
to bottom,
transparent var(--header-height),
rgba(0, 0, 0, 0.35) var(--header-height)
);
}
And the hamburger-to-X icon animation? Pure CSS, using :has() to detect when the popover is open:
.header:has(.header__nav:popover-open) .header__toggle-icon {
background: transparent;
}
No state variable. No aria-expanded toggle. The browser knows when the popover is open; the CSS responds to it.
It didn't work on the first try
The first attempt built and passed lint. But when I opened it in the browser, the mobile menu was always visible, wouldn't close, wasn't full width, and the desktop nav had a black border it never had before.
This is the part worth sharing — because if you try this yourself, you'll likely hit the same walls. Here's what went wrong and the fixes:
- Desktop border: The browser applies a default outline/border to popover elements. Fixed with
outline: noneandborder: noneon the desktop media query. - Mobile always expanded: My base CSS had
display: flexon the nav. That overrode the browser'sdisplay: nonefor closed popovers. Fixed with.header__nav:not(:popover-open) { display: none !important }. - Not closing: A consequence of the above — the menu was never truly "hidden," so toggle and light-dismiss had nothing to do.
- Not full width: The popover sits in the top layer, outside normal flow. Fixed with
inset: var(--header-height) 0 0 0andwidth: 100%. - Backdrop over the header: A flat
rgbabackground covers the entire viewport. Fixed with the gradient shown above — transparent for the header height, then dimmed.
Each fix was small. But the lesson was clear: the Popover API has its own defaults, and your existing CSS will fight them if you're not aware. The biggest one: any display rule you already have on the element will override the browser's hidden state for closed popovers.
A smooth open, for free
Once the basics worked, I wanted a subtle transition when the menu opens. Not an animation library — just a gentle fade and slide.
CSS can now handle this with @starting-style and transition-behavior: allow-discrete:
.header__nav:popover-open {
opacity: 1;
transform: translateY(0);
transition:
opacity 0.2s ease,
transform 0.2s ease,
display 0.2s allow-discrete;
}
@starting-style {
.header__nav:popover-open {
opacity: 0;
transform: translateY(-8px);
}
}
The menu fades in and slides down slightly. Closing is instant. The backdrop fades in synced with the menu. And prefers-reduced-motion disables it all for users who need that. No JS involved.
What I gained and what I gave up
Gained: No menu script at all. Open, close, Escape, click-outside, focus return — all handled by the platform. The ::backdrop replaces a manual overlay div. The :has() selector replaces JS state for the icon animation. Less code, fewer bugs, fewer things to test.
Gave up: Browser support is modern-only (Popover is Baseline 2024 (opens in a new tab)). The smooth transition (@starting-style) is even newer — browsers that don't support it just show the menu instantly, which is fine. I also dropped the resize handler: the menu won't auto-close if you drag the browser from mobile to desktop width. A rare edge case I chose not to pay for.
For me, the real gain isn't fewer lines of code — it's fewer things to think about. Seventy lines of state management, event listeners, and edge cases replaced by behavior that lives in the platform. That's complexity removed, not moved. And that's the goal.
Next in this series: the bigger lesson — how AI changed my role from writing code to making decisions, and why removing complexity is the whole point.