Navbar

Responsive app navigation with a sliding panel.

Live demo

01

Implementation

02
Navbar.svelte
<script lang="ts">
  import Navbar from '$lib/components/Navbar.svelte';
  import type { MenuCategory } from '$lib/types';
​
  const menuCategories: MenuCategory[] = [
    {
      name: 'Cards',
      icon: 'πŸƒ',
      items: [
        { label: 'CardStack', href: '/cardstack', icon: 'πŸƒ', active: false },
        { label: 'MagicCard', href: '/magiccard', icon: '✨', active: false }
      ]
    },
    {
      name: 'Navigation',
      icon: '☰',
      items: [{ label: 'Navbar', href: '/navbar', icon: '☰', active: true }]
    }
  ];
</script>
​
<Navbar {menuCategories} logoText="Svelte Templates" logoHref="/" />

Navbar renders a sticky logo + hamburger trigger that opens a left-sliding panel. Categories collapse/expand independently; the category containing the active route auto-expands on mount. Body scroll locks while the panel is open, focus is trapped inside it, and Escape or backdrop click closes it. Better Auth integration is opt-in via isAuthConfigured.

Logic explainer

03

What Does It Do? (Plain English)

Navbar provides a responsive navigation bar with a hamburger menu that opens a sliding side panel. The panel organises 28+ components into collapsible categories β€” click a category header to expand or collapse its items. The category containing the page you're currently looking at auto-expands when the panel opens, so you always see your context.

Think of it like: A filing cabinet on the side of the screen. Each drawer (category) opens to reveal the files (links) inside. Press Escape or click outside to close the whole cabinet.

How It Works (Pseudo-Code)

WHEN component loads:
  1. RECEIVE menu categories from parent
  2. FIND category containing the active page
  3. AUTO-EXPAND that category (so users see where they are)
  4. RENDER hamburger button + logo in sticky header

WHEN user clicks hamburger button:
  1. TOGGLE panel open/closed state
  2. ADD or REMOVE "open" class for CSS animations
  3. LOCK page scroll when open (prevent background scrolling)
  4. FOCUS the panel element for keyboard navigation

WHEN user clicks category header:
  1. CHECK if category is already expanded
  2. IF expanded: COLLAPSE it (remove from expanded set)
  3. IF collapsed: EXPAND it (add to expanded set)
  4. ANIMATE chevron rotation (180Β° flip)

WHEN user clicks navigation link:
  1. CLOSE the panel
  2. NAVIGATE to the new page

WHEN user presses Escape:
  1. CLOSE the panel
  2. RETURN focus to hamburger button

WHEN user presses Tab inside the panel:
  1. IF at last focusable element: WRAP to first
  2. IF Shift+Tab at first element: WRAP to last
  3. KEEP focus trapped inside panel

The Core Concept: Two-Level Navigation

Hamburger β†’ Panel β†’ Categories β†’ Items
    ☰       slide    expand      click
            in       collapse    navigate

Category expansion pattern:

Before Click (Collapsed):
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ β–Ό Data Viz    (5)   β”‚  ← Click to expand
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

After Click (Expanded):
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ β–² Data Viz    (5)   β”‚  ← Click to collapse
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚   β€’ CalendarHeatmap β”‚
β”‚   β€’ BubblePacking   β”‚
β”‚   β€’ RadialCluster   β”‚
β”‚   β€’ Sunburst        β”‚
β”‚   β€’ Sankey          β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

State Management

Expansion state lives in a reactive SvelteSet<string> keyed by category name:

expandedCategories = new SvelteSet(['Data Viz', 'Cards']);

// Check
expandedCategories.has('Data Viz');  // true
expandedCategories.has('Forms');     // false

// Mutate
expandedCategories.add('Forms');
expandedCategories.delete('Cards');

Why a Set? O(1) has() lookup for every render of every category, O(1) add/delete for toggles, natural uniqueness (no duplicates), and a clean toggle API. SvelteSet (from svelte/reactivity) gives you the reactivity that a plain Set lacks β€” mutations trigger re-renders without the rebuild-the-set workaround.

Focus Trapping

When the panel is open, Tab navigation cycles only inside it:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  Panel (tabindex="-1")            β”‚
β”‚                                   β”‚
β”‚  [First Link]  ← Tab wraps here   β”‚
β”‚  [Second Link]                    β”‚
β”‚  [Third Link]                     β”‚
β”‚  [Last Link]   β†’ Tab wraps back   β”‚
β”‚                                   β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Tab at [Last Link]     β†’ Focus [First Link]
Shift+Tab at [First]   β†’ Focus [Last Link]

The implementation queries the panel for focusable descendants and intercepts Tab to wrap:

const focusableElements = panel.querySelectorAll(
  'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])'
);

const first = focusableElements[0];
const last = focusableElements[focusableElements.length - 1];

if (e.key === 'Tab') {
  if (e.shiftKey && activeElement === first) {
    e.preventDefault();
    last.focus();
  } else if (!e.shiftKey && activeElement === last) {
    e.preventDefault();
    first.focus();
  }
}

The panel itself takes tabindex="-1" so it can receive programmatic focus on open without joining the Tab order. On close, focus returns to the hamburger button β€” the originating control.

Scroll Lock Coordination

Multiple components can want to lock scrolling at the same time (Navbar panel open, then Editor opens on top, etc.). The shared lockScroll utility from $lib/scrollLock reference-counts lock requests so unlocking one doesn't accidentally unlock the page while another is still active:

import { lockScroll } from '$lib/scrollLock';

// On open
unlockScroll = lockScroll();   // returns a cleanup function

// On close
unlockScroll();                // releases this component's lock
unlockScroll = null;

If Editor opens while Navbar is open and the user closes Navbar first, the page stays locked until Editor also releases β€” preventing the "background suddenly scrolls" bug that plagues stacked overlays.

Hamburger Animation

Three lines transform into an X using pure CSS transforms:

Closed:              Open:
─────                  β•²
─────         β†’         β•³
─────                  β•±

Line 1: translateY(7px) rotate(45deg)
Line 2: scaleX(0) opacity(0)
Line 3: translateY(-7px) rotate(-45deg)

Compositor-only properties (transform + opacity), so the animation is GPU-accelerated and can't trigger layout. Honours prefers-reduced-motion: reduce by replacing the transition with an instant state swap.

State Flow Diagram

                    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                    β”‚   closed     β”‚  ← initial state
                    β”‚  panel hiddenβ”‚
                    β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜
                           β”‚ click hamburger
                           β–Ό
                    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                    β”‚    open      β”‚  ── scroll locked
                    β”‚  panel visibleβ”‚  ── focus on panel
                    β”‚  active cat   β”‚  ── auto-expanded category
                    β”‚  expanded     β”‚
                    β””β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”˜
                       β”‚         β”‚
       click category  β”‚         β”‚  Esc / outside click / link click
       header          β”‚         β”‚
                       β–Ό         β–Ό
                β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                β”‚ category    β”‚  β”‚   closed     β”‚
                β”‚ toggle      β”‚  β”‚  scroll free β”‚
                β”‚ (Set add /  β”‚  β”‚  focus β†’ ☰   β”‚
                β”‚  delete)    β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                β””β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜
                      β”‚ remains in "open"
                      β–Ό
                  back to "open"

Data Format

const menuCategories: MenuCategory[] = [
  {
    name: 'Data Visualisation',
    icon: 'πŸ“Š',
    items: [
      { label: 'CalendarHeatmap', href: '/calendarheatmap', active: false },
      { label: 'Sunburst', href: '/sunburst', active: true }
    ]
  },
  {
    name: 'Cards',
    icon: 'πŸƒ',
    items: [
      { label: 'CardStack', href: '/cardstack', active: false }
    ]
  }
];

Categories with only one item collapse the chevron and render as a direct link:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ 🏠 Home             β”‚  ← direct link, no chevron
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ β–Ό Data Viz    (5)   β”‚  ← expandable, chevron present
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Performance Notes

  • CSS transitions only β€” no JS-driven animation; the compositor handles open/close/chevron rotation.
  • SvelteSet lookups β€” O(1) has() for every category render, no Array.includes scans.
  • Lazy item rendering β€” category items only mount when expanded; collapsed categories are header-only.
  • backdrop-filter: blur() on the sticky header β€” handled natively by modern browsers, gracefully ignored elsewhere.

Props Reference

Prop Type Default Description
menuCategories MenuCategory[] [] Grouped navigation items. Each category has { name, icon, items: MenuItem[] }.
menuItems MenuItem[] [] Legacy flat list β€” use menuCategories for new code; this is kept for backwards compatibility.
currentPageTitle string 'Home' Display label for the active page (rendered in some compact layouts).
logoIcon string '⚑' Emoji or character used as the logo when logoSrc is empty.
logoSrc string '' Optional image URL β€” when provided, replaces logoIcon with <img src={logoSrc}>.
logoAlt string '' Alt text for logoSrc. Falls back to logoText when empty.
logoText string 'Svelte Templates' Text shown next to the logo.
logoHref string '/' Destination when the logo is clicked.
isAuthConfigured boolean false When true, auth UI renders; when false, an "Auth Offline" badge appears in the panel.
authUser AuthUser | null null Current signed-in user from the root layout's load function, or null for signed-out / demo mode.
githubUrl string '' When non-empty, a GitHub icon button appears in the panel header.

Edge Cases

Situation Behaviour
No active page (no active: true item) Panel opens with all categories collapsed; user expands manually.
Empty category Header renders, no items below; chevron doesn't appear.
Single-item category Renders as a direct link, no expand affordance.
Auth not configured Shows "Auth Offline" badge in the panel; sign-in/out controls suppressed.
Panel open + page navigation Panel closes automatically on goto(); scroll lock released.
Rapid hamburger toggle Debounced by the CSS transition timing; no flicker.
User has prefers-reduced-motion: reduce Transitions disabled; open/close becomes an instant state swap.
Window resized while panel open Panel layout reflows responsively; scroll lock and focus trap remain intact.
Multiple modal overlays open simultaneously lockScroll's ref-count keeps the page locked until the last consumer releases.

What This Component Does NOT Do

  • No nested categories β€” only one level of grouping.
  • No persistence of expansion state across page loads.
  • No search/filter for menu items (use the catalog's FilterChips on the home page instead).
  • No right-to-left (RTL) layout support yet.
  • No drag-to-reorder for categories.

Dependencies

  • Svelte 5.x β€” $state, $effect, $derived, svelte/reactivity (SvelteSet), and Svelte actions for the focus trap.
  • $lib/scrollLock β€” the reference-counted scroll-lock utility (in-repo, not external).
  • better-auth (optional) β€” only used when isAuthConfigured is true; the navbar otherwise renders without it.

File Structure

src/lib/components/Navbar.svelte         # implementation
src/lib/components/Navbar.md             # this file (rendered inside ComponentPageShell)
src/lib/components/Navbar.test.ts        # unit tests
src/lib/scrollLock.ts                    # shared scroll-lock utility
src/lib/types.ts                         # NavbarProps, MenuCategory, MenuItem, AuthUser
src/routes/navbar/+page.svelte           # demo page

API

04
PropTypeDefaultDescription
menuCategoriesMenuCategory[][]Categorised navigation items (preferred).
menuItemsMenuItem[][]Legacy flat list (kept for backwards compatibility).
currentPageTitlestring'Home'Title announced for the current route.
logoIconstring'⚑'Emoji or single character shown beside the logo text.
logoSrcstringundefinedOptional image source β€” when set, replaces the emoji with an <img>.
logoAltstringlogoTextAlt text for logoSrc.
logoTextstring'Svelte Templates'Wordmark beside the icon.
logoHrefstring'/'Where the logo link points.
isAuthConfiguredbooleanfalseShow Better Auth controls or the offline badge.
authUserAuthUser | nullnullCurrently signed-in user, when supplied by the root layout.
githubUrlstringundefinedWhen provided, renders a GitHub icon button on the right side.