Build a sidebar with HTML and CSS

A sidebar is an aside next to main, laid out with two grid columns. Sticky scrolling, a phone drawer and a collapsible icon rail each add a few lines on top.

A sidebar in HTML is an <aside> placed next to <main>. HTML only names the parts. The side-by-side layout comes from CSS: make the wrapper a grid with two columns, a fixed width for the sidebar and the rest for the content.

<div class="layout">
  <aside class="sidebar">...</aside>
  <main>...</main>
</div>

Try it below. On a phone, or in a narrow window, the sidebar turns into a row of links on top.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sidebar layout</title>
<style>
  body { margin: 0; font-family: system-ui, sans-serif; color: #1d2330; background: #f4f5f7; }

  /* Two columns: a fixed-width sidebar and a main column that takes the rest */
  .layout {
    display: grid;
    grid-template-columns: 200px minmax(0, 1fr);
    min-height: 100vh;
  }

  .sidebar { background: #1f2937; color: #e5e7eb; padding: 16px; }
  .sidebar h2 { margin: 0 0 12px; font-size: 15px; }
  .sidebar nav { display: flex; flex-direction: column; gap: 4px; }
  .sidebar a { color: inherit; text-decoration: none; padding: 8px 10px; border-radius: 6px; font-size: 14px; }
  .sidebar a:hover, .sidebar a[aria-current] { background: #374151; }

  main { padding: 18px 22px; }
  main h1 { margin: 0 0 8px; font-size: 20px; }
  main p { margin: 0 0 10px; line-height: 1.5; font-size: 14px; }

  /* Phone: one column, the sidebar becomes a row of links on top */
  @media (max-width: 560px) {
    .layout { grid-template-columns: minmax(0, 1fr); min-height: 0; }
    .sidebar { padding: 10px 12px; }
    .sidebar h2 { display: none; }
    .sidebar nav { flex-direction: row; flex-wrap: wrap; }
  }
</style>
</head>
<body>
<div class="layout">
  <aside class="sidebar">
    <h2>My project</h2>
    <nav aria-label="Sections">
      <a href="#" aria-current="page">Overview</a>
      <a href="#">Tasks</a>
      <a href="#">Files</a>
      <a href="#">Settings</a>
    </nav>
  </aside>
  <main>
    <h1>Overview</h1>
    <p>The sidebar is 200px wide. The main column takes whatever width is left.</p>
    <p>Make the window narrower than 560px and the sidebar turns into a row of links above the content.</p>
  </main>
</div>
</body>
</html>
An aside and a main in a two-column grid. Below 560px it becomes one column.

Two columns with CSS grid

The whole layout is one rule on the wrapper. The first column is the sidebar, the second is the content.

.layout {
  display: grid;
  grid-template-columns: 200px minmax(0, 1fr);
  min-height: 100vh;
}
The first track stays at 200px. The second takes the rest of the width.
The first track stays at 200px. The second takes the rest of the width.

Why minmax(0, 1fr) and not plain 1fr? A 1fr track will not shrink below the minimum width of its content. A wide table or a long line of code inside main then pushes the page sideways.

The 0 minimum lets the column shrink, and the table can scroll inside its own box.

To put the sidebar on the right, swap the order: minmax(0, 1fr) 200px, with main first in the HTML. For three or more regions, named areas read better. The grid template areas guide covers that.

aside, nav or div?

The element does not change the look, but it tells screen readers what the column is.

Sidebar holds Element Why
Site or app navigation <nav>, inside the aside or on its own A navigation landmark users can jump to
Filters, related links, an author box <aside> Related but separate from the main content
Only a wrapper for styling <div> No meaning to add

The demos here wrap a <nav aria-label="..."> in an aside. The aside tag guide explains the landmark rules in detail, and the nav tag guide covers labeling more than one menu.

A sticky sidebar

On a long page, a sidebar that stays in view as you scroll is useful for a table of contents or filters. Add position: sticky and a top offset:

.sidebar {
  position: sticky;
  top: 16px;
  align-self: start; /* the line that is easy to miss */
}

The third line matters. Grid and flex items stretch to the height of their row by default.

A sidebar as tall as the whole row has no room left to slide down, so it scrolls away like a normal block. Untick the box in this example to see it.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sticky sidebar</title>
<style>
  body { margin: 0; font-family: system-ui, sans-serif; color: #1d2330; background: #f4f5f7; }
  .bar {
    position: sticky; top: 0; z-index: 1;
    padding: 10px 14px; background: #fff; border-bottom: 1px solid #e1e4ea; font-size: 14px;
  }
  .layout { display: grid; grid-template-columns: 130px minmax(0, 1fr); gap: 14px; padding: 14px; }

  .sidebar {
    position: sticky;
    top: 56px;            /* stop below the bar */
    background: #fff; border: 1px solid #e1e4ea; border-radius: 10px; padding: 10px;
  }
  /* The fix: without it, grid stretches the sidebar to the full row height */
  .layout.fixed .sidebar { align-self: start; }

  .sidebar a { display: block; padding: 6px 4px; color: #1d4ed8; font-size: 14px; text-decoration: none; }
  main section { background: #fff; border-radius: 10px; padding: 12px 14px; margin-bottom: 12px; }
  main h2 { margin: 0 0 6px; font-size: 16px; }
  main p { margin: 0; font-size: 14px; line-height: 1.5; color: #4b5563; }
</style>
</head>
<body>
<div class="bar">
  <label><input type="checkbox" id="fix" checked> <code>align-self: start</code> on the sidebar</label>
</div>
<div class="layout fixed" id="layout">
  <aside class="sidebar">
    <nav aria-label="On this page">
      <a href="#">Intro</a>
      <a href="#">Setup</a>
      <a href="#">Usage</a>
      <a href="#">FAQ</a>
    </nav>
  </aside>
  <main>
    <section><h2>Intro</h2><p>Scroll this example. With the box ticked, the sidebar stays in view.</p></section>
    <section><h2>Setup</h2><p>Untick the box and scroll again. The sidebar now scrolls away with the page.</p></section>
    <section><h2>Usage</h2><p>The sidebar still has position: sticky. It simply has no room to move.</p></section>
    <section><h2>Examples</h2><p>A stretched item is as tall as the whole row, so it has no space to slide in.</p></section>
    <section><h2>Notes</h2><p>align-self: start makes it only as tall as its links.</p></section>
    <section><h2>FAQ</h2><p>Keep scrolling to the end to see where a sticky sidebar stops.</p></section>
  </main>
</div>
<script>
  const layout = document.getElementById('layout');
  document.getElementById('fix').addEventListener('change', (e) => {
    layout.classList.toggle('fixed', e.target.checked);
  });
</script>
</body>
</html>
Scroll the example, then untick align-self: start and scroll again.
A stretched sidebar fills the row and cannot move. align-self: start gives it room.
A stretched sidebar fills the row and cannot move. align-self: start gives it room.

If there is a fixed or sticky header, set top to the header height so the sidebar stops below it. The sticky header guide lists the other things that stop sticky from working.

Full height with its own scroll

Dashboards and admin screens often use a different pattern. The page itself never scrolls. The sidebar and the content each scroll on their own.

body {
  margin: 0;
  height: 100vh;
  height: 100dvh; /* follows the visible height on phones */
  display: grid;
  grid-template-columns: auto minmax(0, 1fr);
}
.side, main { overflow-y: auto; }

On phones, 100vh can be taller than the visible area while the browser's address bar is showing. The dvh unit follows the visible height. The first line stays as a fallback for browsers that do not know dvh.

A collapsible sidebar on phones

A 200px column takes too much of a phone screen. The usual answer is a drawer: the sidebar leaves the grid, waits off screen, and slides over the page when a menu button is pressed.

Closed, open and the three ways to close it.
Closed, open and the three ways to close it.
@media (max-width: 640px) {
  body { grid-template-columns: minmax(0, 1fr); }
  .side {
    position: fixed; inset: 0 auto 0 0; width: 240px;
    transform: translateX(-100%);
    visibility: hidden;
    transition: transform .2s, visibility 0s .2s;
  }
  body.open .side { transform: none; visibility: visible; transition: transform .2s; }
}
  1. Take it out of the grid. position: fixed puts it over the page, and the grid drops to one column.
  2. Park it off screen. translateX(-100%) moves it exactly its own width to the left.
  3. Hide it for real. visibility: hidden stops Tab from reaching links nobody can see. The delayed transition waits until the slide has finished before hiding.
  4. Open it with a button. A <button> toggles a class and updates aria-expanded.
  5. Give three ways out. Close on Escape, on a tap on the backdrop, and after a link is picked. Return focus to the menu button.

The hamburger menu guide goes deeper into the button and its icon. The breakpoint itself is an ordinary media query.

A finished dashboard sidebar

This one puts it all together. On a wide screen, the sidebar can collapse to a 60px icon rail. On a phone, it becomes the drawer above. The content column scrolls on its own, and the table scrolls sideways inside its box.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Dashboard sidebar</title>
<style>
  * { box-sizing: border-box; }
  body {
    margin: 0; height: 100vh; height: 100dvh;
    display: grid; grid-template-columns: auto minmax(0, 1fr);
    font-family: system-ui, sans-serif; color: #1d2330; background: #f4f5f7;
  }

  /* Sidebar: its own scroll, full height */
  .side {
    width: 210px; overflow-y: auto; overflow-x: hidden;
    display: flex; flex-direction: column; gap: 4px; padding: 12px 10px;
    background: #111827; color: #d1d5db; transition: width .2s;
  }
  .brand { display: flex; align-items: center; gap: 12px; font-weight: 700; color: #fff; padding: 4px 8px 12px; white-space: nowrap; }
  .logo { flex: none; width: 24px; height: 24px; border-radius: 6px; background: #2563eb; display: grid; place-items: center; font-size: 13px; }
  .side a, .side button {
    display: flex; align-items: center; gap: 12px; padding: 9px 10px; border-radius: 8px;
    color: inherit; text-decoration: none; font: inherit; font-size: 14px; white-space: nowrap;
    background: none; border: 0; cursor: pointer; text-align: left;
  }
  .side a:hover, .side button:hover { background: #1f2937; }
  .side a[aria-current] { background: #2563eb; color: #fff; }
  .side svg { flex: none; width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-width: 2; }
  #collapse { margin-top: auto; }

  /* Desktop collapsed: icons only. Labels stay readable to screen readers. */
  body.rail .side { width: 60px; }
  body.rail .label {
    position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%);
  }
  body.rail #collapse svg { transform: scaleX(-1); }

  /* Main column: scrolls on its own */
  main { overflow-y: auto; padding: 14px 18px; }
  .top { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
  .top h1 { margin: 0; font-size: 19px; }
  #menu { display: none; }
  .cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 10px; }
  .card { background: #fff; border-radius: 10px; padding: 12px; }
  .card b { display: block; font-size: 20px; }
  .card small { color: #6b7280; }
  .wide { overflow-x: auto; background: #fff; border-radius: 10px; margin-top: 12px; }
  table { border-collapse: collapse; width: 100%; min-width: 460px; font-size: 13px; }
  th, td { text-align: left; padding: 8px 12px; border-bottom: 1px solid #eef0f3; }
  .backdrop { display: none; }

  /* Phone: the sidebar becomes a drawer over the page */
  @media (max-width: 640px) {
    body { grid-template-columns: minmax(0, 1fr); }
    .side, body.rail .side {
      position: fixed; inset: 0 auto 0 0; z-index: 2; width: 240px;
      transform: translateX(-100%); visibility: hidden;
      transition: transform .2s, visibility 0s .2s;   /* hide after it slides out */
    }
    body.rail .label { position: static; width: auto; height: auto; clip-path: none; }
    body.open .side { transform: none; visibility: visible; transition: transform .2s; }
    body.open .backdrop { display: block; position: fixed; inset: 0; z-index: 1; background: rgba(0, 0, 0, .4); }
    #menu { display: flex; padding: 6px; border: 1px solid #d5d9e0; border-radius: 8px; background: #fff; cursor: pointer; }
    #menu svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-width: 2; }
    #collapse { display: none; }
  }
</style>
</head>
<body>
<aside class="side" id="side">
  <div class="brand"><span class="logo">A</span><span class="label">Acme Admin</span></div>
  <nav aria-label="Main" id="nav">
    <a href="#" aria-current="page"><svg viewBox="0 0 24 24"><path d="M3 11l9-7 9 7v9H3z"/></svg><span class="label">Overview</span></a>
    <a href="#"><svg viewBox="0 0 24 24"><path d="M4 20V10M10 20V4M16 20v-7M22 20H2"/></svg><span class="label">Reports</span></a>
    <a href="#"><svg viewBox="0 0 24 24"><path d="M3 7h18v13H3zM3 7l3-4h12l3 4"/></svg><span class="label">Orders</span></a>
    <a href="#"><svg viewBox="0 0 24 24"><circle cx="9" cy="8" r="4"/><path d="M2 21c0-4 3-6 7-6s7 2 7 6M17 4a4 4 0 010 8M22 21c0-3-2-5-4-6"/></svg><span class="label">Customers</span></a>
    <a href="#"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3M5 5l2 2M17 17l2 2M5 19l2-2M17 7l2-2"/></svg><span class="label">Settings</span></a>
  </nav>
  <button id="collapse" aria-expanded="true" aria-controls="side">
    <svg viewBox="0 0 24 24"><path d="M15 5l-7 7 7 7"/></svg><span class="label">Collapse</span>
  </button>
</aside>
<div class="backdrop" id="backdrop"></div>

<main>
  <div class="top">
    <button id="menu" aria-expanded="false" aria-controls="side" aria-label="Open menu">
      <svg viewBox="0 0 24 24"><path d="M3 6h18M3 12h18M3 18h18"/></svg>
    </button>
    <h1 id="title">Overview</h1>
  </div>
  <div class="cards">
    <div class="card"><small>Revenue</small><b>$12,480</b></div>
    <div class="card"><small>Orders</small><b>326</b></div>
    <div class="card"><small>Refunds</small><b>4</b></div>
  </div>
  <div class="wide">
    <table>
      <tr><th>Order</th><th>Customer</th><th>Status</th><th>Total</th></tr>
      <tr><td>#1042</td><td>Dana Whitfield</td><td>Paid</td><td>$84.00</td></tr>
      <tr><td>#1041</td><td>Omar Haddad</td><td>Shipped</td><td>$129.50</td></tr>
      <tr><td>#1040</td><td>Lena Park</td><td>Paid</td><td>$42.00</td></tr>
      <tr><td>#1039</td><td>Theo Brandt</td><td>Refunded</td><td>$18.00</td></tr>
      <tr><td>#1038</td><td>Ines Moreau</td><td>Shipped</td><td>$230.00</td></tr>
      <tr><td>#1037</td><td>Sam Okafor</td><td>Paid</td><td>$61.25</td></tr>
      <tr><td>#1036</td><td>Mia Novak</td><td>Paid</td><td>$97.40</td></tr>
      <tr><td>#1035</td><td>Ravi Menon</td><td>Shipped</td><td>$150.00</td></tr>
      <tr><td>#1034</td><td>Clara Diaz</td><td>Paid</td><td>$33.10</td></tr>
      <tr><td>#1033</td><td>Jon Eriksen</td><td>Paid</td><td>$76.80</td></tr>
      <tr><td>#1032</td><td>Aiko Sato</td><td>Shipped</td><td>$58.00</td></tr>
    </table>
  </div>
</main>

<script>
  const body = document.body;
  const menu = document.getElementById('menu');
  const collapse = document.getElementById('collapse');
  const phone = matchMedia('(max-width: 640px)');

  // Desktop: collapse to an icon rail and back
  collapse.addEventListener('click', () => {
    const rail = body.classList.toggle('rail');
    collapse.setAttribute('aria-expanded', !rail);
  });

  // Phone: open and close the drawer
  function setOpen(open) {
    body.classList.toggle('open', open);
    menu.setAttribute('aria-expanded', open);
    if (open) document.querySelector('#nav a').focus();
    else menu.focus();
  }
  menu.addEventListener('click', () => setOpen(true));
  document.getElementById('backdrop').addEventListener('click', () => setOpen(false));
  document.addEventListener('keydown', (e) => {
    if (e.key === 'Escape' && body.classList.contains('open')) setOpen(false);
  });

  // Picking a page marks it current, and closes the drawer on a phone
  document.getElementById('nav').addEventListener('click', (e) => {
    const link = e.target.closest('a');
    if (!link) return;
    e.preventDefault();  // one-page demo: no real pages to load
    document.querySelector('#nav [aria-current]').removeAttribute('aria-current');
    link.setAttribute('aria-current', 'page');
    document.getElementById('title').textContent = link.textContent;
    if (phone.matches) setOpen(false);
  });

  // Leaving phone width while the drawer is open: reset it
  phone.addEventListener('change', () => { body.classList.remove('open'); menu.setAttribute('aria-expanded', false); });
</script>
</body>
</html>
Collapse the sidebar to icons on a wide screen. On a phone, open it with the menu button.
  • Icon rail: a rail class on body sets the sidebar width to 60px. With grid-template-columns: auto ..., the grid follows the new width.
  • Hidden labels, kept names: in the rail, labels are visually hidden with clip-path rather than display: none, so screen readers still hear "Reports".
  • Current page: aria-current="page" marks the active link and styles it too.
  • Reset on resize: a matchMedia listener closes the drawer if the window grows past the phone width.

When it does not work

What you see Cause Fix
The sidebar sits above the content, not beside it The wrapper is not a grid or flex container display: grid and two column widths
A wide table pushes the page sideways The content track is 1fr, which will not shrink below its content Use minmax(0, 1fr)
The sticky sidebar scrolls away It is stretched to the row height align-self: start
Sticky still fails No top value, or an ancestor has overflow: hidden, auto or scroll Add top, remove the overflow
On a phone the page is cut off at the bottom 100vh is taller than the visible area Add height: 100dvh after it
Tab lands on links in the closed drawer It is only moved off screen Add visibility: hidden when closed
The whole layout is tiny on a phone No viewport meta tag Add the viewport meta tag

A sidebar is one of those layouts you need to click through to judge. A screenshot shows one width and one state, and an .html attachment may open as plain code on a phone.

To send the working version, paste the page into a NOS document and choose Create share link. HTML to link walks through it.

The page renders as written and its scripts run, so the people you send it to can collapse the sidebar and open the drawer on their own phones. If you change the code later, the same link shows the new version.

Questions people ask

Which HTML element should a sidebar use?

Use aside for the column itself when it holds content related to the page, such as filters or extra links. If the sidebar is the site's main navigation, put the links in a nav element, inside the aside or instead of it. Either way, main holds the page content.

Grid or flexbox for a sidebar layout?

Both work. Grid sets both column widths in one line on the parent, which is easier to change in a media query. With flexbox, give the sidebar flex: 0 0 200px and the content flex: 1 plus min-width: 0.

Why does my sticky sidebar not stick?

In a grid or flex row, the sidebar is stretched to the height of the row, so it has no room to move. Add align-self: start. Also check that it has a top value and that no ancestor has overflow set to hidden, auto or scroll.

How do I hide the sidebar on mobile?

Inside a media query, either drop it to one column so it sits above the content, or take it out of the layout with position: fixed and transform: translateX(-100%), then slide it back in when a menu button is pressed.

Can I make a collapsible sidebar without JavaScript?

A details element or a checkbox hack can toggle it, but a button with a few lines of JavaScript is simpler to make accessible. It can update aria-expanded, close on Escape and move focus into the menu and back.

Keep reading