Summit Themes
Blog

How to build a responsive sidebar with Tailwind CSS and Alpine.js

A sidebar sounds simple until you actually build one. You need it to collapse on mobile, stay open on desktop, animate smoothly, close when you tap outside, and not break keyboard navigation. Do it wrong and you end up with a mess of media-query hacks and manually managed CSS classes. Do it right with Tailwind CSS v4 and Alpine.js, and the whole thing snaps together in under 100 lines of markup.

This guide walks through building a responsive sidebar from scratch — one that collapses to a hamburger menu on small screens, stays expanded by default on large screens, and uses a backdrop overlay on mobile. All state is handled by Alpine.js; all styling is handled by Tailwind CSS v4. No build-step gymnastics required.

What you need

This guide assumes you have Tailwind CSS v4 and Alpine.js available in your project. For a quick prototype you can load both from a CDN. For an Astro project, install them through npm as you normally would.

Tailwind CSS v4 CDN (for prototyping):

<script src="https://cdn.tailwindcss.com?v=4"></script>

Alpine.js CDN (pin the version in production; 3.13.x is the current stable line):

<script defer src="https://cdn.jsdelivr.net/npm/[email protected]/dist/cdn.min.js"></script>

If you are using the Tailwind CLI or a bundler, your CSS entry point for v4 looks like this:

@import "tailwindcss";

@theme {
  --color-sidebar: oklch(0.18 0.02 250);
  --color-sidebar-text: oklch(0.92 0.01 250);
  --color-sidebar-active: oklch(0.30 0.08 250);
}

The @theme block is how Tailwind v4 handles custom design tokens. Whatever you define there becomes a CSS custom property and auto-generates utility classes like bg-sidebar and text-sidebar-text. No tailwind.config.js required.

The layout skeleton

The outer shell is a flex container that splits into a sidebar and a main content area. The sidebar overlays the content on mobile (position fixed, z-index on top) and sits beside it on large screens (position static, always visible).

<!-- Outer wrapper: full-height flex row -->
<div class="flex h-screen overflow-hidden bg-white"
     x-data="sidebar()">

  <!-- Mobile backdrop -->
  <div x-show="open"
       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"
       @click="open = false"
       class="fixed inset-0 z-20 bg-black/50 lg:hidden"
       aria-hidden="true">
  </div>

  <!-- Sidebar panel -->
  <aside :class="open ? 'translate-x-0' : '-translate-x-full'"
         class="fixed inset-y-0 left-0 z-30 w-64 transform
                bg-sidebar text-sidebar-text
                transition-transform duration-300 ease-in-out
                lg:static lg:translate-x-0 lg:z-auto"
         aria-label="Sidebar navigation">

    <!-- Sidebar header -->
    <div class="flex items-center justify-between px-6 py-5 border-b border-white/10">
      <span class="font-semibold text-lg tracking-tight">My App</span>
      <!-- Close button — only visible on mobile -->
      <button @click="open = false"
              class="lg:hidden rounded-md p-1 hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white"
              aria-label="Close sidebar">
        <svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
          <path fill-rule="evenodd"
                d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293
                   a1 1 0 111.414 1.414L11.414 10l4.293 4.293
                   a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293
                   a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707
                   a1 1 0 010-1.414z"
                clip-rule="evenodd" />
        </svg>
      </button>
    </div>

    <!-- Nav links -->
    <nav class="px-3 py-4 space-y-1">
      <a href="#"
         class="flex items-center gap-3 rounded-lg px-3 py-2
                text-sm font-medium
                bg-sidebar-active text-white"
         aria-current="page">
        Dashboard
      </a>
      <a href="#"
         class="flex items-center gap-3 rounded-lg px-3 py-2
                text-sm font-medium
                text-sidebar-text hover:bg-white/10 transition-colors">
        Projects
      </a>
      <a href="#"
         class="flex items-center gap-3 rounded-lg px-3 py-2
                text-sm font-medium
                text-sidebar-text hover:bg-white/10 transition-colors">
        Settings
      </a>
    </nav>
  </aside>

  <!-- Main content area -->
  <div class="flex flex-1 flex-col overflow-auto">
    <!-- Top bar -->
    <header class="flex items-center gap-4 border-b px-6 py-4 bg-white">
      <button @click="open = true"
              class="lg:hidden rounded-md p-2 text-gray-500
                     hover:bg-gray-100 focus:outline-none
                     focus-visible:ring-2 focus-visible:ring-gray-500"
              aria-label="Open sidebar">
        <svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
          <path fill-rule="evenodd"
                d="M2 4.75A.75.75 0 012.75 4h14.5a.75.75 0 010 1.5H2.75A.75.75 0 012 4.75zm0 10.5
                   a.75.75 0 01.75-.75h14.5a.75.75 0 010 1.5H2.75a.75.75 0 01-.75-.75zM2 10
                   a.75.75 0 01.75-.75h14.5a.75.75 0 010 1.5H2.75A.75.75 0 012 10z"
                clip-rule="evenodd" />
        </svg>
      </button>
      <h1 class="text-lg font-semibold text-gray-900">Dashboard</h1>
    </header>

    <main class="flex-1 px-6 py-8">
      <p class="text-gray-500">Your content goes here.</p>
    </main>
  </div>

</div>

The Alpine.js component

The x-data="sidebar()" attribute on the outer wrapper means Alpine will look for a global function called sidebar that returns the component's state and methods. Define it in a <script> tag before Alpine loads (or in any JS file that executes before Alpine initialises):

document.addEventListener('alpine:init', () => {
  Alpine.data('sidebar', () => ({
    open: false,

    init() {
      // Start open on large screens
      this.open = window.innerWidth >= 1024;

      // Keep state in sync when the window is resized
      window.addEventListener('resize', () => {
        if (window.innerWidth >= 1024) {
          this.open = true;
        }
      });
    },

    toggle() {
      this.open = !this.open;
    },
  }));
});

The init() hook runs automatically when Alpine processes the component. It sets the sidebar open by default on large screens and re-opens it if the user resizes from mobile to desktop mid-session — a subtle but useful touch.

How the responsive behaviour works

The key insight is that Tailwind's lg: prefix handles layout changes, while Alpine handles interactive state. They operate on different axes and do not fight each other.

  • Below lg (1024px): The sidebar is position: fixed, starts off-screen (-translate-x-full), and slides in when open is true. A semi-transparent backdrop overlays the content area. Clicking the backdrop sets open = false, which slides the sidebar back out.
  • At lg and above: lg:static pulls the sidebar out of fixed positioning into the normal document flow, lg:translate-x-0 keeps it visible regardless of open, and lg:z-auto removes the stacking context. The backdrop has lg:hidden so it never appears on wide screens. The hamburger button also has lg:hidden.

The transition-transform duration-300 on the <aside> provides the slide animation. Alpine's x-transition modifiers on the backdrop layer handle its fade in and out separately, which lets you tune each independently.

Keyboard and focus management

The sidebar as written handles the most common keyboard needs: the close button is a real <button>, focus-visible:ring-* makes focus states visible without polluting mouse interactions, and aria-label attributes give screen readers clear names for icon-only controls. aria-current="page" on the active link is recognised by screen readers and announced to users navigating by keyboard.

For a more complete implementation, you can add @keydown.escape.window="open = false" on the sidebar element to close it when the user presses Escape:

<aside ...
       @keydown.escape.window="open = false">

Alpine's event modifier syntax (@keydown.escape.window) listens on the window object rather than the element itself, so it fires even when focus is inside the main content area.

Persisting sidebar state

If you want the sidebar's open/closed state to survive page navigation, Alpine's $persist magic property writes state to localStorage automatically. Swap the plain boolean for a persisted one:

Alpine.data('sidebar', () => ({
  open: Alpine.$persist(false).as('sidebar-open'),

  init() {
    if (window.innerWidth >= 1024 && !localStorage.getItem('sidebar-open')) {
      this.open = true;
    }
  },
}));

The .as('sidebar-open') sets the localStorage key explicitly so it does not collide with anything else on the page. The $persist plugin ships with Alpine but needs to be registered — on CDN it is available at https://cdn.jsdelivr.net/npm/@alpinejs/[email protected]/dist/cdn.min.js, loaded before the main Alpine script.

Putting it all together

The sidebar pattern built here covers the full range of everyday requirements: a CSS-driven slide transition, a mobile overlay, responsive layout switching, keyboard support, and optional state persistence. Tailwind v4's @theme block keeps the colour palette in one place and generates all the utility classes from it. Alpine's declarative directives mean you can read the HTML and immediately understand what each button does — no hunting through JavaScript files for event listeners.

Once you have this base working, extending it is straightforward: add a collapsible sub-menu with a nested x-data, or swap the fixed-width sidebar for a collapsible icon-only mode by toggling a narrower width class on the <aside>. The reactive model stays the same regardless of how complex the sidebar grows.