Site index
Arrow keys to navigateEnter to open / Esc to close
Writing index
Field note / Frontend engineering

How to create a responsive navbar in Next.js with TypeScript and Zustand

Build an accessible, responsive navigation bar in the Next.js App Router using TypeScript and Zustand for predictable global state.

2 min readnext.jstypescriptzustandui
On this page
How to create a responsive navbar in Next.js with TypeScript and Zustand

A navbar looks trivial until you support a keyboard, a screen reader, a mobile drawer, and a sticky scroll state at the same time. This is the structure I keep coming back to.

Model the state first

Navigation state is small and global: which drawer is open, whether the page is scrolled. Zustand keeps that out of the component tree.

import { create } from "zustand";

type NavState = {
  isMobileOpen: boolean;
  isScrolled: boolean;
  openMobile: () => void;
  closeMobile: () => void;
  setScrolled: (value: boolean) => void;
};

export const useNavStore = create<NavState>((set) => ({
  isMobileOpen: false,
  isScrolled: false,
  openMobile: () => set({ isMobileOpen: true }),
  closeMobile: () => set({ isMobileOpen: false }),
  setScrolled: (value) => set({ isScrolled: value }),
}));

Selecting a single value avoids re-rendering the whole header on every scroll tick.

const isScrolled = useNavStore((state) => state.isScrolled);

Scroll state without thrashing

Listening to scroll is fine as long as you do almost nothing in the handler.

useEffect(() => {
  const onScroll = () => setScrolled(window.scrollY > 24);
  window.addEventListener("scroll", onScroll, { passive: true });
  return () => window.removeEventListener("scroll", onScroll);
}, [setScrolled]);

Two details matter here: passive: true tells the browser the handler will not call preventDefault, and the boolean is written only when it changes.

Make the drawer accessible

The drawer is a dialog. Treat it like one.

  1. Focus the first item when it opens
  2. Return focus to the trigger on close
  3. Close on Escape
  4. Prevent the page behind it from scrolling
useEffect(() => {
  if (!isMobileOpen) return;
  const onKey = (event: KeyboardEvent) => {
    if (event.key === "Escape") closeMobile();
  };
  document.addEventListener("keydown", onKey);
  return () => document.removeEventListener("keydown", onKey);
}, [isMobileOpen, closeMobile]);

Layout that does not jump

The header is fixed, so it overlaps content. Reserve the space once:

[id] {
  scroll-margin-top: 6rem;
}

This single rule fixes anchor links that would otherwise hide their target under a sticky bar.

Summary

Zustand gives you predictable state, TypeScript gives you confidence at the edges, and a handful of accessibility rules give you a navbar that works for everyone.

Continue reading

Related articles

Modal interface

Accent system

Choose a palette. The selection is saved on this device.

Accent color themes

28 palettes available

Modal interface

A note worth keeping

A randomly selected thought from the inspiration archive.