Header and Navigation Components Intermediate 12 min read

Header Component Architecture

Design a scalable React header architecture for your Shopify theme. Learn component composition, mount strategies, and how to integrate with Liquid-rendered markup.

The header is often the first React component you’ll build in a Shopify theme. It combines multiple interactive elements—search, cart, and navigation—that need to share state and respond to user actions. In this lesson, we’ll design a scalable architecture for your header components.

Header Architecture Overview

A well-architected header separates concerns across multiple components:

┌─────────────────────────────────────────────────────────────────────┐
│ HEADER │
│ ┌──────────┐ ┌──────────────────────┐ ┌────────┐ ┌──────────┐ │
│ │ Menu │ │ Logo │ │ Search │ │ Cart │ │
│ │ Toggle │ │ (Liquid) │ │ Button │ │ Icon │ │
│ └──────────┘ └──────────────────────┘ └────────┘ └──────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Desktop Navigation (React enhanced) │ │
│ └─────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘

Component Hierarchy

Structure your header with clear component boundaries:

src/components/header/
├── Header/
│ ├── Header.tsx # Main container, orchestrates layout
│ ├── Header.module.css
│ └── index.ts
├── HeaderActions/
│ ├── HeaderActions.tsx # Search + Cart buttons
│ ├── HeaderActions.module.css
│ └── index.ts
├── MenuToggle/
│ ├── MenuToggle.tsx # Mobile hamburger button
│ ├── MenuToggle.module.css
│ └── index.ts
├── CartIcon/
│ ├── CartIcon.tsx # Cart with badge
│ ├── CartIcon.module.css
│ └── index.ts
├── SearchButton/
│ ├── SearchButton.tsx # Opens search modal
│ └── index.ts
└── index.ts # Barrel export

The Liquid Foundation

Start with a Liquid header section that provides the structure:

{% comment %} sections/header.liquid {% endcomment %}
<header class="header" data-section-id="{{ section.id }}">
<div class="header__container">
{%- comment -%} Mobile menu toggle - React enhances this {%- endcomment -%}
<div id="header-menu-toggle" class="header__menu-toggle"></div>
{%- comment -%} Logo - pure Liquid, no React needed {%- endcomment -%}
<div class="header__logo">
<a href="{{ routes.root_url }}">
{% if section.settings.logo %}
<img
src="{{ section.settings.logo | image_url: width: 200 }}"
alt="{{ shop.name }}"
width="200"
height="auto"
loading="eager"
>
{% else %}
{{ shop.name }}
{% endif %}
</a>
</div>
{%- comment -%} Desktop navigation mount point {%- endcomment -%}
<nav id="header-nav" class="header__nav" aria-label="Main navigation"></nav>
{%- comment -%} Header actions - React enhanced {%- endcomment -%}
<div id="header-actions" class="header__actions"></div>
</div>
</header>
{%- comment -%} Provide header configuration to React {%- endcomment -%}
<script type="application/json" id="header-config">
{
"cartCount": {{ cart.item_count }},
"shopName": {{ shop.name | json }},
"searchEnabled": {{ section.settings.enable_search | json }},
"stickyHeader": {{ section.settings.sticky_header | json }}
}
</script>

Mount Strategy: Multiple Entry Points

For the header, we mount React to multiple specific elements rather than one large tree:

src/entries/header.tsx
/*
* Multiple Mount Points Pattern (Islands Architecture)
*
* Instead of one large React tree for the entire header, we mount
* React to specific interactive elements. This gives us:
* - Smaller React trees = faster hydration
* - Mix React and Liquid freely
* - Each component fails independently
* - Better SEO (Liquid renders static content)
*/
import { createRoot } from 'react-dom/client';
import { HeaderActions } from '@/components/header/HeaderActions';
import { MenuToggle } from '@/components/header/MenuToggle';
import { DesktopNav } from '@/components/header/DesktopNav';
function mountHeaderComponents() {
// Mount header actions (search + cart buttons)
// Liquid: <div id="header-actions"></div>
const actionsContainer = document.getElementById('header-actions');
if (actionsContainer) {
const root = createRoot(actionsContainer);
root.render(<HeaderActions />);
}
// Mount hamburger menu toggle
// Liquid: <div id="header-menu-toggle"></div>
const menuToggleContainer = document.getElementById('header-menu-toggle');
if (menuToggleContainer) {
const root = createRoot(menuToggleContainer);
root.render(<MenuToggle />);
}
// Mount desktop navigation with dropdowns
// Liquid: <nav id="header-nav"></nav>
const navContainer = document.getElementById('header-nav');
if (navContainer) {
const root = createRoot(navContainer);
root.render(<DesktopNav />);
}
}
// Handle both cases: script loaded before or after DOM ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', mountHeaderComponents);
} else {
mountHeaderComponents();
}

Why Multiple Mount Points?

  1. Isolation: Each component tree is independent
  2. Performance: Smaller React trees, faster hydration
  3. Flexibility: Mix React and Liquid freely
  4. Progressive enhancement: Parts can fail independently

Header Actions Component

The actions component handles search and cart buttons:

src/components/header/HeaderActions/HeaderActions.tsx
import { useCart } from '@/stores/cart';
import { useSearchModal } from '@/stores/ui';
import { VisuallyHidden } from '@/components/ui';
import styles from './HeaderActions.module.css';
export function HeaderActions() {
const itemCount = useCart((state) => state.cart?.itemCount ?? 0);
const toggleCart = useCart((state) => state.toggleCart);
const { open: openSearch } = useSearchModal();
return (
<div className={styles.actions}>
<button
type="button"
className={styles.action}
onClick={openSearch}
aria-label="Open search"
>
<SearchIcon />
<VisuallyHidden>Search</VisuallyHidden>
</button>
<button
type="button"
className={`${styles.action} ${styles.cart}`}
onClick={toggleCart}
aria-label={`Cart with ${itemCount} items`}
>
<CartIcon />
{itemCount > 0 && (
<span className={styles.count} aria-hidden="true">
{itemCount > 99 ? '99+' : itemCount}
</span>
)}
</button>
</div>
);
}
function SearchIcon() {
return (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
);
}
function CartIcon() {
return (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="9" cy="21" r="1" />
<circle cx="20" cy="21" r="1" />
<path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6" />
</svg>
);
}
src/components/header/HeaderActions/HeaderActions.module.css
.actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.action {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
padding: 0;
border: none;
background: transparent;
cursor: pointer;
color: inherit;
transition: opacity 0.15s ease;
}
.action:hover {
opacity: 0.7;
}
.cart {
position: relative;
}
.count {
position: absolute;
top: 4px;
right: 4px;
min-width: 18px;
height: 18px;
padding: 0 4px;
font-size: 11px;
font-weight: 600;
line-height: 18px;
text-align: center;
color: var(--color-background);
background: var(--color-primary);
border-radius: 9px;
}

The hamburger button for mobile navigation:

src/components/header/MenuToggle/MenuToggle.tsx
import { useMobileMenu } from '@/stores/ui';
import { VisuallyHidden } from '@/components/ui';
import styles from './MenuToggle.module.css';
export function MenuToggle() {
const { isOpen, toggle } = useMobileMenu();
return (
<button
type="button"
className={`${styles.toggle} ${isOpen ? styles.open : ''}`}
onClick={toggle}
aria-expanded={isOpen}
aria-controls="mobile-menu"
aria-label={isOpen ? 'Close menu' : 'Open menu'}
>
<span className={styles.bar} />
<span className={styles.bar} />
<span className={styles.bar} />
<VisuallyHidden>{isOpen ? 'Close menu' : 'Open menu'}</VisuallyHidden>
</button>
);
}
src/components/header/MenuToggle/MenuToggle.module.css
.toggle {
display: flex;
flex-direction: column;
justify-content: center;
gap: 5px;
width: 44px;
height: 44px;
padding: 10px;
border: none;
background: transparent;
cursor: pointer;
}
@media (min-width: 1024px) {
.toggle {
display: none;
}
}
.bar {
display: block;
width: 24px;
height: 2px;
background: currentColor;
transition: transform 0.2s ease, opacity 0.2s ease;
transform-origin: center;
}
/* Animate to X when open */
.open .bar:nth-child(1) {
transform: translateY(7px) rotate(45deg);
}
.open .bar:nth-child(2) {
opacity: 0;
}
.open .bar:nth-child(3) {
transform: translateY(-7px) rotate(-45deg);
}

Sticky Header Behavior

Add scroll-aware sticky behavior:

src/hooks/useScrollDirection.ts
import { useState, useEffect } from 'react';
type ScrollDirection = 'up' | 'down' | null;
export function useScrollDirection(threshold = 10): ScrollDirection {
const [scrollDirection, setScrollDirection] = useState<ScrollDirection>(null);
useEffect(() => {
let lastScrollY = window.scrollY;
let ticking = false;
const updateScrollDirection = () => {
const scrollY = window.scrollY;
const difference = scrollY - lastScrollY;
if (Math.abs(difference) < threshold) {
ticking = false;
return;
}
setScrollDirection(difference > 0 ? 'down' : 'up');
lastScrollY = scrollY > 0 ? scrollY : 0;
ticking = false;
};
const onScroll = () => {
if (!ticking) {
window.requestAnimationFrame(updateScrollDirection);
ticking = true;
}
};
window.addEventListener('scroll', onScroll, { passive: true });
return () => window.removeEventListener('scroll', onScroll);
}, [threshold]);
return scrollDirection;
}
src/hooks/useStickyHeader.ts
import { useEffect } from 'react';
import { useScrollDirection } from './useScrollDirection';
export function useStickyHeader(headerSelector = '.header') {
const scrollDirection = useScrollDirection();
useEffect(() => {
const header = document.querySelector(headerSelector);
if (!header) return;
if (scrollDirection === 'down' && window.scrollY > 100) {
header.classList.add('header--hidden');
} else {
header.classList.remove('header--hidden');
}
}, [scrollDirection, headerSelector]);
}

Add the CSS for sticky behavior:

/* In your global header styles */
.header {
position: sticky;
top: 0;
z-index: 100;
background: var(--color-background);
transition: transform 0.3s ease;
}
.header--hidden {
transform: translateY(-100%);
}

Reading Header Configuration

Use the JSON config from Liquid:

src/components/header/useHeaderConfig.ts
import { useMemo } from 'react';
import { readJsonScript } from '@/utils/data-bridge';
interface HeaderConfig {
cartCount: number;
shopName: string;
searchEnabled: boolean;
stickyHeader: boolean;
}
export function useHeaderConfig(): HeaderConfig {
return useMemo(() => {
const config = readJsonScript('header-config') as HeaderConfig | null;
return {
cartCount: config?.cartCount ?? 0,
shopName: config?.shopName ?? '',
searchEnabled: config?.searchEnabled ?? true,
stickyHeader: config?.stickyHeader ?? true,
};
}, []);
}

Key Takeaways

  1. Multiple mount points: Mount React to specific interactive elements
  2. Liquid for static content: Keep logo and basic structure in Liquid
  3. Shared state via stores: Cart count, menu state, search modal
  4. Progressive enhancement: Header works without JavaScript, React enhances it
  5. Component isolation: Each header part is an independent component tree
  6. Sticky behavior: Use scroll direction to hide/show on scroll

In the next lesson, we’ll build desktop navigation with dropdowns and mega menus.

Finished this lesson?

Mark it complete to track your progress.

Discussion

Loading comments...