Learn Responsive Navigation in Angular for Header and Admin Sidebar

Oct 19, 2026AngularEngineeringDesign
Summarize with AI: Google AI Claude ChatGPT Perplexity Grok

AI links open with a title + excerpt (these tools can't fetch the page themselves) — use "Copy full article" to paste the complete text for a fuller summary.

Share:

Introduction

This article demonstrates how we built two different responsive navigations in the same Angular app — a public site header and an admin sidebar — and why they are not the same component.

They look similar on a phone. Both collapse behind a button. But they behave differently, and trying to share one component between them costs more than writing two.

Two Navigations, Two Jobs

The public header is a short list of links. On desktop it is a row. On mobile it collapses into a panel that pushes the page down.

The admin sidebar is a persistent column beside the content. On desktop it is always visible. On mobile it becomes an overlay that floats above the content, because the content underneath is a data table that cannot shrink any further.

The difference is that word — the header's panel takes space in the layout, the sidebar's overlay does not. That is a genuinely different behaviour, and it is why we wrote two.

Features

  • No JavaScript media queries; Tailwind breakpoints do the switching.
  • Menu state is a signal, so no manual change detection.
  • The menu closes on navigation.
  • Correct ARIA attributes on both.

With the following steps, you can build the same.

  1. Switch layout with CSS, not JavaScript.
  2. Hold the open state in a signal.
  3. Close on navigation.
  4. Add the accessibility attributes.

Switch Layout With CSS, Not JavaScript

The first rule. Do not listen to window.resize and store a isMobile boolean.

<!-- desktop -->
<div class="hidden md:flex items-center gap-1">…</div>

<!-- mobile -->
<div class="flex items-center gap-1 md:hidden">…</div>

Tailwind's md: prefix handles it. The browser does the work, at exactly the right moment, with no event listener, no change detection and nothing to clean up in ngOnDestroy.

There is a second reason this matters for us specifically. Our app is prerendered at build time, where there is no window and no viewport at all. A JavaScript-driven layout would render wrong in the prerendered HTML and then jump once the app started. A CSS breakpoint is correct in the static HTML before any JavaScript runs.

My suggestion: reach for a JavaScript viewport check only when you need to do something CSS genuinely cannot — like not instantiating a heavy component at all on small screens. Layout is not that.

Hold the Open State in a Signal

mobileMenuOpen = signal(false);

And in the template:

<button
  (click)="mobileMenuOpen.set(!mobileMenuOpen())"
  [attr.aria-expanded]="mobileMenuOpen()"
  aria-controls="mobile-menu-panel">
  …
</button>

@if (mobileMenuOpen()) {
  <div id="mobile-menu-panel" class="md:hidden bg-paper dark:bg-ink border-t border-line shadow-tk-md">
    …
  </div>
}

Two details worth copying.

@if, not [hidden] or a CSS class. The panel does not exist in the DOM when closed. Nothing inside it is focusable by keyboard, and a screen reader cannot reach it. Hiding a menu with CSS alone leaves its links in the tab order, so a keyboard user tabs into an invisible menu — a bug nobody notices because nobody tests it with the keyboard.

signal, not a plain property. The template reads mobileMenuOpen() and re-renders when it changes, with no ChangeDetectorRef and no zone concerns. For a boolean this is small, but it is the pattern the rest of the app uses, and consistency is worth more than saving one function call.

The admin sidebar uses exactly the same shape with sidebarOpen() and aria-controls="admin-sidebar-panel".

Close on Navigation

This is the bug every hand-rolled mobile menu ships with. You tap a link, the page changes, and the menu is still covering it.

It happens because Angular's router does not reload the page. Nothing resets your component state, so the menu stays exactly as it was.

this.router.events
  .pipe(filter((e) => e instanceof NavigationEnd))
  .subscribe(() => {
    this.mobileMenuOpen.set(false);
  });

Subscribe once, close on every completed navigation.

Use NavigationEnd, not NavigationStart. Closing on start means the menu disappears while the new route is still resolving, so on a slow route the user gets a moment of nothing happening and taps again.

We also close it directly in the click handler for the same-route case, since navigating to the page you are already on fires no NavigationEnd at all. Belt and braces, and both are one line.

Add the Accessibility Attributes

A menu button is not a decorative icon. Three attributes make it work for everyone.

aria-expanded must reflect the real state, bound not hardcoded:

[attr.aria-expanded]="mobileMenuOpen()"

A screen reader announces "collapsed" or "expanded". Hardcode it and you announce a lie.

aria-controls points at the panel's id, so assistive technology knows which element the button governs. This is the pair to aria-expanded — one says the state, the other says what it is the state of.

aria-label gives the button a name. A hamburger icon is an SVG, and an SVG has no text. Without a label it is announced as "button" and nothing else.

Use [attr.aria-expanded], not [aria-expanded]. These are HTML attributes, not DOM properties, so the attr. prefix is required — a common mistake that silently produces nothing in the rendered HTML.

The mobile menu open, with the admin sidebar overlay alongside

The Admin Sidebar Difference

The sidebar follows all of the above with one extra piece — a header bar that only exists on mobile.

<div class="md:hidden flex items-center justify-between px-4 h-16 bg-surface border-b border-line shrink-0">

On desktop the sidebar is always there, so there is nothing to toggle and no bar to put a button in. On mobile the sidebar is gone, so the page needs a bar to hold the open button and the page title.

shrink-0 is doing quiet work. In a flex column, a header will happily compress when the content below is tall. Without it, your 64px bar becomes 40px on long pages and the icons look wrong for no obvious reason.

We also reduced the main content padding on mobile in the same change. Worth saying plainly: an overlay sidebar is only useful if the content underneath is actually readable at that width. The navigation work and the content padding were one problem, not two.

Conclusion

In this article we learned to switch navigation layouts with Tailwind breakpoints rather than JavaScript, hold menu state in a signal, remove the panel from the DOM with @if instead of hiding it with CSS, close the menu on NavigationEnd, and bind aria-expanded and aria-controls so the button is honest about what it does.

Two navigations, one set of rules. The only real difference is whether the open menu takes space in the layout or floats above it.

Reference

#angular#responsive#navigation#accessibility#tailwind#signals

Comments

Be the first to comment.

Leave a comment

Never shown publicly.

Comments are reviewed before appearing publicly.