Navbar
Responsive app navigation with a sliding panel.
Live demo
01Implementation
02<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
03What 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 panelThe Core Concept: Two-Level Navigation
Hamburger β Panel β Categories β Items
β° slide expand click
in collapse navigateCategory 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.
SvelteSetlookups β O(1)has()for every category render, noArray.includesscans.- 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
FilterChipson 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 whenisAuthConfiguredistrue; 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 pageAPI
04| Prop | Type | Default | Description |
|---|---|---|---|
menuCategories | MenuCategory[] | [] | Categorised navigation items (preferred). |
menuItems | MenuItem[] | [] | Legacy flat list (kept for backwards compatibility). |
currentPageTitle | string | 'Home' | Title announced for the current route. |
logoIcon | string | 'β‘' | Emoji or single character shown beside the logo text. |
logoSrc | string | undefined | Optional image source β when set, replaces the emoji with an <img>. |
logoAlt | string | logoText | Alt text for logoSrc. |
logoText | string | 'Svelte Templates' | Wordmark beside the icon. |
logoHref | string | '/' | Where the logo link points. |
isAuthConfigured | boolean | false | Show Better Auth controls or the offline badge. |
authUser | AuthUser | null | null | Currently signed-in user, when supplied by the root layout. |
githubUrl | string | undefined | When provided, renders a GitHub icon button on the right side. |