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 exportThe 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:
/* * 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 readyif (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', mountHeaderComponents);} else { mountHeaderComponents();}Why Multiple Mount Points?
- Isolation: Each component tree is independent
- Performance: Smaller React trees, faster hydration
- Flexibility: Mix React and Liquid freely
- Progressive enhancement: Parts can fail independently
Header Actions Component
The actions component handles search and cart buttons:
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> );}.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;}Menu Toggle Component
The hamburger button for mobile navigation:
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> );}.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:
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;}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:
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
- Multiple mount points: Mount React to specific interactive elements
- Liquid for static content: Keep logo and basic structure in Liquid
- Shared state via stores: Cart count, menu state, search modal
- Progressive enhancement: Header works without JavaScript, React enhances it
- Component isolation: Each header part is an independent component tree
- 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...