12 Pure CSS8 Light JSPublished 10 new designs · Updated on
Most sidebar bugs are not visual — the drawer traps focus with no way out, the collapsed state resets on reload, scrolling the menu scrolls the page behind it, or a screen reader is told nothing changed. These 20 hand-coded patterns cover the ARIA reference build, focus trapping and restore, inert background content, roving tabindex, state persistence, scroll containment, nested disclosure menus, and a keyboard-operable resize handle. Scoped under .sdb-NN; drops into React, Vue, Svelte, or Astro.
Building the whole admin dashboard, not just this layout?
You just browsed 20 sidebar navigation patterns. Mosaic by Cruip is a complete Tailwind CSS admin dashboard template, coded in HTML, React, Next.js, Vue and Laravel, with a Figma version too. It costs $69.
The reference build the other nineteen demos borrow from. A <nav> with an accessible name, a real <ul> of links, aria-current="page" on the active item, and a disclosure control that carries its expanded state. The two mistakes seen most in review are the ones this markup is built to avoid: aria-selected borrowed from tabs, and aria-expanded parked on the sidebar rather than the control.
When a sidebar opens as a modal drawer, focus has to move into it, stay inside while it is open, and land back on the trigger when it closes. This wires the trap by hand — first and last focusable, <kbd>Tab</kbd> and <kbd>Shift</kbd>+<kbd>Tab</kbd> wrapping, <kbd>Esc</kbd> closing — and shows the live tab order as a ring, including the step almost everyone drops: storing the trigger before opening so focus has somewhere to return to.
A focus trap stops <kbd>Tab</kbd> from leaving, but the page behind an open drawer is still clickable and still reachable by a screen reader's virtual cursor. The inert attribute removes a whole subtree from the accessibility tree and from hit-testing in one declaration — the same thing <dialog>'s showModal() does internally. It goes on with the drawer and comes off when the drawer closes, with a detected fallback for engines that do not have it.
Applying role="tablist" to site navigation because it looks like a vertical strip is a frequent and serious mistake. Tabs promise that activating an item swaps a panel in place; navigation promises a page change. Get it wrong and a screen-reader user is told the wrong thing about what will happen, and inherits arrow-key conventions navigation should not have. The rail here is the navigation build: a named landmark, a list of links, and aria-current="page" on the active one.
Collapsing a sidebar to an icon rail is a visual change that assistive technology has to perceive too. aria-expanded on the toggle covers the state, but once the labels are gone the icons need accessible names of their own — visually hidden text or aria-label, never a title attribute, which is announced inconsistently and not at all on touch. The label text stays in the DOM and is clipped rather than removed, so the names survive the collapse.
Whether arrow keys should move between sidebar items at all, and when. For a plain navigation list the answer is usually no — links belong in the natural tab order and hijacking the arrows breaks what users expect from a page. For a long rail that genuinely behaves as one composite widget, roving tabindex turns fifty tab stops into one. The rail here takes the roving route: one tab stop, arrows and <kbd>Home</kbd>/<kbd>End</kbd> moving focus, activation only on <kbd>Enter</kbd>.
Newsletter
Enjoying these demos? Get new ones by email.
A short email when a new collection ships. Unsubscribe any time.
A sidebar that forgets it was collapsed is a small daily annoyance that compounds. Persist the boolean, then apply it before first paint — an inline script in <head> that sets a class on the root, the same trick a theme toggle uses. Reading the value in a framework effect instead means the wide sidebar paints first and visibly snaps shut. Every read and write is wrapped, because storage throws in sandboxed and private contexts.
Scroll to the bottom of a long sidebar and keep going, and the scroll chains to the page behind it — on an open drawer that means the background moves while the drawer sits still. overscroll-behavior: contain stops the chain in one declaration. The rail here contains its own scroll, and the thread beside it does the same. Includes the related iOS problem, where the body still scrolls behind a fixed overlay, and what actually fixes it.
Expandable groups done properly. The group header is a control, not a link that goes nowhere; a collapsed submenu is hidden rather than merely invisible, so its links leave the tab order; and aria-current on a nested item also has to tell the user that the collapsed parent group contains the current page — otherwise the only signal that you are somewhere inside it disappears the moment it closes.
Drag-to-resize is standard in tools and almost always mouse-only. The accessible version gives the handle role="separator" with aria-valuenow, aria-valuemin and aria-valuemax, plus arrow-key support so it can be operated without a pointer — <kbd>Home</kbd> and <kbd>End</kbd> snap to the bounds. The width is clamped to sensible limits and remembered, with the current value shown live.
The no-script collapse: a visually hidden checkbox, :has(:checked) on the root, and one width custom property doing all the work. One honest limitation: a checkbox cannot carry aria-expanded, so the pattern needs a clearly labelled control, and the scripted version in demo 05 is the accessible ceiling. css-side-menu covers the look; this is the state mechanism under it.
The point where a persistent sidebar has to become a modal drawer, and what changes at that line. Below the breakpoint the focus trap switches on, inert starts applying, <kbd>Esc</kbd> starts closing, and the toggle's aria-expanded means something different — it now describes a modal, not a layout. Most implementations change the CSS and forget the behaviour. Resize the frame across 44rem and the contract changes with it.
An icon-only rail where every item keeps an accessible name, and the tooltip is deliberately not that name. The two jobs are separate: the name is permanent clipped text that assistive technology always reads; the tooltip is a visual affordance for anyone who does not recognise the glyph. title is wrong for either — announced inconsistently, absent on touch, and its tooltip cannot be hovered or dismissed.
aria-current="page" as the single source of truth for the active item, with every visual affordance derived from the attribute instead of a parallel class. One state, not two that can disagree: the row fill, the inset marker, the icon colour, the breadcrumb and the group's containment dot all read the same attribute.
Newsletter
Enjoying these demos? Get new ones by email.
A short email when a new collection ships. Unsubscribe any time.
Two-level nesting using the disclosure pattern from demo 09, with the depth limit stated out loud: past two levels a sidebar stops being navigable and the information architecture is the real problem, not the component. The rail here stops at two levels, and the third is promoted into the page as an on-this-page navigation.
Native <details> and <summary> groups, including the name attribute that makes them behave as an exclusive accordion with no script at all. What you get free: a real disclosure control, keyboard operation, and expanded state exposed by the browser. Where it stops: animation, exclusive behaviour on older engines, and the fact that closing a group as another opens can move the item a user was aiming at.
Why height: 100% on a grid child silently fails, and why position: sticky needs a containing block taller than itself. These are the two mechanics behind every full-height sidebar, and both fail without an error — the rail simply is not full height, or simply does not stick. The build here is the version that works: no height declaration on the rail at all, and align-self: start giving sticky the room it needs.
Why a floating sidebar with z-index: 9999 can still vanish behind a card. A transform, filter, opacity below 1 or will-change on an ancestor creates a stacking context, and z-index only orders siblings inside their own context — so the number is being compared against the wrong things. The panel and its backdrop are siblings in one deliberately isolated context, which is the arrangement that survives.
The full modal-drawer contract from demos 02, 03 and 08 assembled in one place: focus moves in, focus is trapped, the background goes inert, <kbd>Esc</kbd> closes, focus returns to the trigger, and scroll never chains to the page behind. css-hamburger-menus owns the trigger design; this is the behaviour it must open onto.
A footer pinned to the bottom of the rail while the nav list between header and footer scrolls on its own. The mechanic is a flex column with min-block-size: 0 on the scrolling child — without it the child's automatic minimum size lets it grow to its content, the whole rail gets taller, and the overflow never engages. The failure produces no error at all, which is what makes it expensive; this build is the corrected one.
FAQ
Frequently asked questions
How do you build an accessible sidebar navigation?
Four things carry almost all of it. Wrap the links in a <nav> with an aria-label, because a page with several navigation landmarks gives a screen-reader user no way to tell them apart otherwise. Use a real <ul> of <a> elements, so the list is announced with its item count and each entry is understood as a link rather than a generic clickable thing. Mark the active item with aria-current="page" and derive the visual highlight from that attribute, so there is one source of truth instead of a class and an attribute that can disagree. And put aria-expanded on the button that toggles the sidebar, not on the sidebar itself, with aria-controls pointing at the sidebar's id. The two mistakes that show up most often are using aria-selected — which belongs to tabs and promises something different — and styling an active state with a class while the attribute is never set at all.
Why does my sidebar drawer trap keyboard users?
Because a focus trap without an exit is a WCAG Level A failure under SC 2.1.2, and it is easy to write one by accident. Cycling Tab and Shift+Tab between the first and last focusable elements inside the drawer is the right behaviour while it is open, but it has to be paired with an escape route: the Escape key, plus a visible close control that is itself reachable inside the trap. The other half people forget is where focus goes afterwards. Store the element that opened the drawer before you move focus in, and return focus to it on close — otherwise the user is dropped at the top of the document with no idea where they were, which is disorienting even though nothing is technically broken. Demo 02 wires the whole cycle and demo 19 assembles it with the rest of the drawer contract.
What does the inert attribute do for a sidebar drawer?
A focus trap stops the Tab key from leaving the drawer, but it does nothing about the rest of the page. Screen-reader users can still move the virtual cursor into content behind the overlay, and depending on your backdrop the background may still be clickable — so the page underneath is only visually unavailable. The inert attribute removes an entire subtree from the accessibility tree and from hit-testing at once, which is exactly what a native modal <dialog> does internally. Apply it to the page wrapper when the drawer opens and remove it on close. Where inert is unavailable the fallback is aria-hidden on the background plus disabling every focusable element inside it, which is strictly worse: it has to be undone precisely, and one element missed on close leaves a permanently unreachable control. Demo 03 shows both.
Should sidebar navigation use role=tablist?
No, and this is one of the more damaging mistakes because the markup looks reasonable. A vertical rail resembles a tab strip visually, so role="tablist", role="tab" and aria-selected get applied to what is really site navigation. Those roles make a specific promise: activating a tab swaps a panel in place, without leaving the page. Navigation promises the opposite — a page change. A screen-reader user hearing "tab, 2 of 5, selected" expects content to swap beside them, and instead the page navigates. The roles also bring tab keyboard conventions, where arrow keys move between tabs and activate them, which is wrong for links. If your rail genuinely swaps panels without navigating, it is a tab list and css-vertical-tabs covers that properly. If it links to pages, it is a <nav>. Demo 04 shows both marked up side by side with what each announces.
How do I save the sidebar collapsed state so it survives a reload?
Persist the boolean and, more importantly, apply it before the first paint. Writing to localStorage on toggle is the easy half; the part that looks broken is reading it back in a component effect, because that runs after hydration and the user watches the sidebar render expanded and then snap closed. The fix is the same pattern a theme toggle uses: a tiny inline script in the document head that reads the stored value and sets a class or data attribute on the root element before any content paints. Wrap every read and write in try/catch — localStorage throws in private browsing on some engines and in sandboxed iframes, and an unguarded read takes the whole script down with it. The sidebar should render correctly with no stored value at all, treating absence as the default rather than an error. Demo 07 ships the guarded pattern.
How do I stop the page scrolling behind an open sidebar?
The behaviour you are seeing is scroll chaining: when a scrollable element reaches its end, the browser passes the remaining scroll to its parent, so reaching the bottom of a long sidebar starts scrolling the page behind it. overscroll-behavior: contain on the sidebar stops the chain in one declaration, and unlike the old approach it does not require locking the body or tracking scroll position in script. It is the right first thing to reach for. iOS Safari has an additional wrinkle where a fixed overlay can still allow the body underneath to move on touch, which usually needs position: fixed on the body plus restoring the scroll offset on close — worth applying only when you actually see the problem, since it introduces its own layout jump. Demo 08 shows the chaining behaviour with and without containment.
Should arrow keys move between sidebar links?
Usually not. Links belong in the natural tab order, and users expect Tab to move between them; hijacking the arrow keys breaks a convention that costs more than the tab stops it saves. Roving tabindex — one item at tabindex="0", the rest at -1, arrows moving focus between them — is the right answer for a composite widget, and a sidebar becomes one when it is long enough that tabbing through fifty links to reach the main content is genuinely hostile. Even then, provide a skip link rather than reaching for roving tabindex first. The rule of thumb: if activating an item navigates, keep the natural tab order; if the rail behaves as a single control with a selected item, roving tabindex is appropriate. Demo 06 implements both with the decision stated.
How is this different from the side menu and hamburger menu collections?
Four collections sit adjacent here and each owns sidebar appearance from a different angle. css-side-menu (17 demos) owns the visual patterns — slide-in, off-canvas push, icon-only expandable, accordion, full-height flexbox. css-hamburger-menus (28 demos) owns the trigger and the drawer it opens. css-dashboard-layouts (17 demos) owns the admin shell the sidebar sits inside. css-vertical-tabs (18 demos) owns rails that genuinely behave as tab lists. This collection owns the behaviour underneath all of them: focus management, keyboard interaction, ARIA semantics, state persistence and scroll containment. If you want to know what your sidebar should look like, start with one of those. If your sidebar looks right but traps keyboard users, forgets it was collapsed, or tells a screen reader nothing, start here.