Markup first: the skeleton
Open app/index.html after selecting Remix to Edit, then drop this into <body>:
<aside></aside>
<main></main>
<aside> carries the navigation menu as a companion to <main>, which carries the page content.
Now fill those two elements in. Inside <aside>, add a <nav> with navigation links and a closing link:
<aside>
<nav>
<h4>My</h4>
<a href="#">Dashboard</a>
<a href="#">Profile</a>
<a href="#">Preferences</a>
<a href="#">Archive</a>
<h4>Settings</h4>
<a href="#">Accessibility</a>
<a href="#">Theme</a>
<a href="#">Admin</a>
</nav>
<a href="#"></a>
</aside>
Inside <main>, add a header and an article:
<main>
<header>
<a href="#sidenav-open" class="hamburger">
<svg viewBox="0 0 50 40">
<line x1="0" x2="100%" y1="10%" y2="10%" />
<line x1="0" x2="100%" y1="50%" y2="50%" />
<line x1="0" x2="100%" y1="90%" y2="90%" />
</svg>
</a>
<h1>Site Title</h1>
</header>
<article>
{put some placeholder content here}
</article>
</main>
The header holds the menu open link, the aside holds the close button; which one is shown depends on viewport size. The placeholder sentence in the <article> can be replaced with your own copy or the supplied lorem:
<h2>Totam Header</h2>
<p>Lorem ipsum dolor, sit amet consectetur adipisicing elit. Cum consectetur, necessitatibus velit officia ut impedit veritatis temporibus soluta? Totam odit cupiditate facilis nisi sunt hic necessitatibus voluptatem nihil doloribus! Enim.</p>
<p>Lorem ipsum dolor sit, amet consectetur adipisicing elit. Fugit rerum, amet odio explicabo voluptas eos cum libero, ex esse quasi optio incidunt soluta eligendi labore error corrupti! Dolore, cupiditate porro.</p>
<h3>Subhead Totam Odit</h3>
<p>Lorem ipsum dolor sit, amet consectetur adipisicing elit. Fugit rerum, amet odio explicabo voluptas eos cum libero, ex esse quasi optio incidunt soluta eligendi labore error corrupti! Dolore, cupiditate porro.</p>
<p>Lorem ipsum dolor sit, amet consectetur adipisicing elit. Fugit rerum, amet odio explicabo voluptas eos cum libero, ex esse quasi optio incidunt soluta eligendi labore error corrupti! Dolore, cupiditate porro.</p>
<h3>Subhead</h3>
<p>Lorem ipsum dolor sit, amet consectetur adipisicing elit. Fugit rerum, amet odio explicabo voluptas eos cum libero, ex esse quasi optio incidunt soluta eligendi labore error corrupti! Dolore, cupiditate porro.</p>
<p>Lorem ipsum dolor sit, amet consectetur adipisicing elit. Fugit rerum, amet odio explicabo voluptas eos cum libero, ex esse quasi optio incidunt soluta eligendi labore error corrupti! Dolore, cupiditate porro.</p>
Article length is what makes the page scroll once it passes the viewport height.
Making the actions unambiguous
The open link needs clearer labelling. Add title and aria-label:
<a href="#sidenav-open" class="hamburger">
<a href="#sidenav-open" title="Open Menu" aria-label="Open Menu" class="hamburger">
Label the open SVG icon inside that link too:
<svg viewBox="0 0 50 40">
<svg viewBox="0 0 50 40" role="presentation" focusable="false" aria-label="trigram for heaven symbol">
Do the same for the close link in the sidenav:
<a href="#"></a>
<a href="#" title="Close Menu" aria-label="Close Menu"></a>
The body layout and the URL as state
Both the main content and the sidenav are direct children of <body>, so start there. Add this to css/sidenav.css:
body {
display: grid;
grid: [stack] 1fr / min-content [stack] 1fr;
@media (max-width: 540px) {
& > :matches(aside, main) {
grid-area: stack;
}
}
}
>Read as a whole: a named row stack holding two columns, the second of which is also named stack. Column one is sized by its minimum content; column two takes the remainder. At 540px or below, both children land in the same row and column, stacking into a 1x1 grid.
With responsive stacking in place, the URL bar can drive the sidenav's visibility and transitions. First give the <aside> in app/index.html its ID:
<aside>
<aside id="sidenav-open">
>That pairing of element ID and URL hash is what makes :target work. Add IDs to the sidenav open and close links as well, so JavaScript has clean hooks:
<a href="#sidenav-open" class="hamburger" title="Open Menu" aria-label="Open Menu">
<a href="#sidenav-open" id="sidenav-button" class="hamburger" title="Open Menu" aria-label="Open Menu">
><a href="#" title="Close Menu" aria-label="Close Menu"></a>
<a href="#" id="sidenav-close" title="Close Menu" aria-label="Close Menu"></a>
>Styling the aside
The <aside> holds two children: the <nav> that slides out, and a closing <a> that points the URL hash at #. That link sits invisibly to the right of the panel so a click beside the component dismisses it.
#sidenav-open {
display: grid;
grid-template-columns: [nav] 2fr [escape] 1fr;
}
>Controlling the ratio and naming the grid areas gives a designer a lot of latitude here.
Main content still has to be overlaid conditionally and scroll position preserved — work for position: sticky plus overscroll-behavior:
#sidenav-open {
display: grid;
grid-template-columns: [nav] 2fr [escape] 1fr;
@media (max-width: 540px) {
position: sticky;
top: 0;
max-height: 100vh;
overflow: hidden auto;
overscroll-behavior: contain;
visibility: hidden; /* not keyboard accessible when closed */
}
}
>Those rules give the sidenav viewport height, vertical scrolling and scroll containment, and hide the element. It stays hidden at 540px and below — unless the #sidenav-open element carries :target:
#sidenav-open {
@media (max-width: 540px) {
&:target {
visibility: visible;
}
}
}
>When hash and ID match, visibility becomes visible. Try opening the menu after scrolling, or scrolling while it is open.
Next, the buttons and the transitions:
#sidenav-button,
#sidenav-close {
-webkit-tap-highlight-color: transparent;
-webkit-touch-callout: none;
user-select: none;
touch-action: manipulation;
@media (min-width: 540px) {
display: none;
}
}
>#sidenav-open {
--easeOutExpo: cubic-bezier(0.16, 1, 0.3, 1);
--duration: .6s;
...
@media (max-width: 540px) {
...
transform: translateX(-110vw);
will-change: transform;
transition:
transform var(--duration) var(--easeOutExpo),
visibility 0s linear var(--duration);
&:target {
visibility: visible;
transform: translateX(0);
transition: transform var(--duration) var(--easeOutExpo);
}
}
@media (prefers-reduced-motion: reduce) {
--duration: 1ms;
}
}
>The first block covers tap and touch styling for the open and close buttons and hides them at 540px and up; the second adds transforms with accessibility respected.
Keyboard and focus behaviour
Escape should dismiss the menu, so add this to js/index.js:
const sidenav = document.querySelector('#sidenav-open');
sidenav.addEventListener('keyup', e => {
if (e.code === 'Escape') {
document.location.hash = '';
}
});
>The listener watches key events on the sidenav element and, on Escape, empties the URL hash so the sidenav transitions away.
Focus management comes next: after a sidenav transition settles, compare it against the URL hash to tell whether it opened or closed, then move focus to the button complementary to the one just pressed.
const closenav = document.querySelector('#sidenav-close');
const opennav = document.querySelector('#sidenav-button');
sidenav.addEventListener('transitionend', e => {
if (e.propertyName !== 'transform') {
return;
}
const isOpen = document.location.hash === '#sidenav-open';
isOpen
? closenav.focus()
: opennav.focus();
});
>Checking the result
- Press View App to preview, then Fullscreen
.
Where to take it
The component covers the needs it was built for, but it's a starting point — drive it from JavaScript state rather than the URL, or reshape it entirely.
css/brandnav.css holds the non-layout styling kept separate from this feature set on purpose, so the layout styles stay copy-pasteable. Slide-out sidenavs vary a lot between projects — including setups with more than one, such as a panel on each side.



