Header and Navigation Components Intermediate 10 min read

Announcement Bar and Sticky Header Behavior

Build a dismissible announcement bar and intelligent sticky header that hides on scroll down and shows on scroll up. Master scroll-aware UI patterns.

A well-designed header responds to user scroll behavior—hiding when scrolling down to maximize content space, and reappearing when scrolling up for easy access. Combined with a dismissible announcement bar, these patterns create a polished user experience.

Theme Integration

The announcement bar typically renders above the header, either in Liquid or React:

sections/announcement-bar.liquid (Liquid section with React enhancement)
└── <div id="announcement-bar-root">
└── AnnouncementBar (React) ← You are here
sections/header.liquid
└── Header container uses useStickyHeader hook
{% comment %} sections/announcement-bar.liquid {% endcomment %}
{% if section.settings.show_announcement %}
<div id="announcement-bar-root"></div>
<script type="application/json" id="announcement-data">
{
"message": {{ section.settings.message | json }},
"link": {{ section.settings.link | json }},
"linkText": {{ section.settings.link_text | json }},
"dismissible": {{ section.settings.dismissible | json }}
}
</script>
{% endif %}

Data Source

Prop/StateSourceLiquid Field
messageJSON script elementsection.settings.message
linkJSON script elementsection.settings.link
linkTextJSON script elementsection.settings.link_text
backgroundColorJSON script elementsection.settings.background_color
textColorJSON script elementsection.settings.text_color
dismissibleJSON script elementsection.settings.dismissible
isVisibleLocal state + localStorage-
scrollDirectionuseScrollDirection hook-
isStickyuseStickyHeader hook-

Note: The AnnouncementBar props come from section settings configured in the Shopify theme editor. The sticky header state is computed entirely in React based on scroll position.

Announcement Bar Component

The announcement bar sits above the header and can be dismissed by users:

src/components/header/AnnouncementBar/AnnouncementBar.tsx
import { useState, useEffect } from 'react';
import styles from './AnnouncementBar.module.css';
interface AnnouncementBarProps {
message: string;
link?: string;
linkText?: string;
backgroundColor?: string;
textColor?: string;
dismissible?: boolean;
storageKey?: string;
}
export function AnnouncementBar({
message,
link,
linkText,
backgroundColor = 'var(--color-primary)',
textColor = 'var(--color-primary-contrast)',
dismissible = true,
storageKey = 'announcement-dismissed',
}: AnnouncementBarProps) {
const [isVisible, setIsVisible] = useState(false);
const [isAnimating, setIsAnimating] = useState(false);
useEffect(() => {
// Check if previously dismissed
const isDismissed = localStorage.getItem(storageKey);
if (!isDismissed) {
setIsVisible(true);
}
}, [storageKey]);
const handleDismiss = () => {
setIsAnimating(true);
// Wait for animation to complete
setTimeout(() => {
setIsVisible(false);
localStorage.setItem(storageKey, 'true');
// Dispatch event for header to adjust
window.dispatchEvent(new CustomEvent('announcement-dismissed'));
}, 300);
};
if (!isVisible) return null;
return (
<div
className={`${styles.bar} ${isAnimating ? styles.hiding : ''}`}
style={{
backgroundColor,
color: textColor,
}}
role="region"
aria-label="Announcement"
>
<div className={styles.content}>
<p className={styles.message}>
{message}
{link && linkText && (
<a href={link} className={styles.link}>
{linkText}
</a>
)}
</p>
{dismissible && (
<button
type="button"
className={styles.dismiss}
onClick={handleDismiss}
aria-label="Dismiss announcement"
>
<CloseIcon />
</button>
)}
</div>
</div>
);
}
function CloseIcon() {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M12.854 3.146a.5.5 0 0 1 0 .708L8.707 8l4.147 4.146a.5.5 0 0 1-.708.708L8 8.707l-4.146 4.147a.5.5 0 0 1-.708-.708L7.293 8 3.146 3.854a.5.5 0 1 1 .708-.708L8 7.293l4.146-4.147a.5.5 0 0 1 .708 0z" />
</svg>
);
}
src/components/header/AnnouncementBar/AnnouncementBar.module.css
.bar {
padding: 0.625rem 0;
transition: transform 0.3s ease, opacity 0.3s ease;
}
.bar.hiding {
transform: translateY(-100%);
opacity: 0;
}
.content {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
max-width: var(--page-width);
margin: 0 auto;
padding: 0 var(--page-gutter);
}
.message {
margin: 0;
font-size: 0.875rem;
font-weight: 500;
text-align: center;
}
.link {
color: inherit;
text-decoration: underline;
margin-left: 0.5rem;
}
.link:hover {
opacity: 0.8;
}
.dismiss {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: none;
background: transparent;
color: inherit;
cursor: pointer;
opacity: 0.7;
transition: opacity 0.15s ease;
}
.dismiss:hover {
opacity: 1;
}

Passing Announcement Data from Liquid

{% comment %} sections/announcement-bar.liquid {% endcomment %}
{%- if section.settings.show_announcement -%}
<div id="announcement-bar-root"></div>
<script type="application/json" id="announcement-data">
{
"message": {{ section.settings.message | json }},
"link": {{ section.settings.link | json }},
"linkText": {{ section.settings.link_text | json }},
"backgroundColor": {{ section.settings.background_color | json }},
"textColor": {{ section.settings.text_color | json }},
"dismissible": {{ section.settings.dismissible | json }}
}
</script>
{%- endif -%}
{% schema %}
{
"name": "Announcement Bar",
"settings": [
{
"type": "checkbox",
"id": "show_announcement",
"label": "Show announcement",
"default": true
},
{
"type": "text",
"id": "message",
"label": "Message",
"default": "Free shipping on orders over $50"
},
{
"type": "url",
"id": "link",
"label": "Link"
},
{
"type": "text",
"id": "link_text",
"label": "Link text",
"default": "Shop now"
},
{
"type": "color",
"id": "background_color",
"label": "Background color",
"default": "#000000"
},
{
"type": "color",
"id": "text_color",
"label": "Text color",
"default": "#ffffff"
},
{
"type": "checkbox",
"id": "dismissible",
"label": "Allow dismiss",
"default": true
}
]
}
{% endschema %}

Scroll Direction Hook

Detect scroll direction for sticky header behavior:

src/hooks/useScrollDirection.ts
/*
* Scroll Direction Detection Hook
*
* Detects whether user is scrolling up or down. Used for:
* - Sticky headers that hide on scroll down, show on scroll up
* - Infinite scroll triggers
* - Scroll-aware animations
*
* Uses requestAnimationFrame for smooth, performant updates.
*/
import { useState, useEffect, useRef } from 'react';
type ScrollDirection = 'up' | 'down' | null;
interface UseScrollDirectionOptions {
threshold?: number; // Minimum scroll amount to trigger direction change
initialDirection?: ScrollDirection;
}
export function useScrollDirection(
options: UseScrollDirectionOptions = {}
): ScrollDirection {
const { threshold = 10, initialDirection = null } = options;
const [scrollDirection, setScrollDirection] = useState<ScrollDirection>(initialDirection);
const lastScrollY = useRef(0); // Track last scroll position
const ticking = useRef(false); // Prevent multiple RAF calls
useEffect(() => {
lastScrollY.current = window.scrollY;
const updateScrollDirection = () => {
const scrollY = window.scrollY;
const difference = scrollY - lastScrollY.current;
// Ignore tiny scroll movements (avoids jitter)
if (Math.abs(difference) < threshold) {
ticking.current = false;
return;
}
// Positive difference = scrolling down, negative = scrolling up
const newDirection = difference > 0 ? 'down' : 'up';
// Only update state if direction actually changed
if (newDirection !== scrollDirection) {
setScrollDirection(newDirection);
}
lastScrollY.current = scrollY > 0 ? scrollY : 0;
ticking.current = false;
};
// Throttle scroll handler with requestAnimationFrame
const onScroll = () => {
if (!ticking.current) {
window.requestAnimationFrame(updateScrollDirection);
ticking.current = true;
}
};
// passive: true improves scroll performance
window.addEventListener('scroll', onScroll, { passive: true });
return () => window.removeEventListener('scroll', onScroll);
}, [threshold, scrollDirection]);
return scrollDirection;
}

Sticky Header Hook

Combine scroll detection with header visibility:

src/hooks/useStickyHeader.ts
import { useState, useEffect, useCallback } from 'react';
import { useScrollDirection } from './useScrollDirection';
interface UseStickyHeaderOptions {
hideThreshold?: number;
showOnTop?: boolean;
}
interface StickyHeaderState {
isVisible: boolean;
isSticky: boolean;
isPinned: boolean;
}
export function useStickyHeader(
options: UseStickyHeaderOptions = {}
): StickyHeaderState {
const { hideThreshold = 100, showOnTop = true } = options;
const scrollDirection = useScrollDirection({ threshold: 10 });
const [scrollY, setScrollY] = useState(0);
useEffect(() => {
const handleScroll = () => {
setScrollY(window.scrollY);
};
window.addEventListener('scroll', handleScroll, { passive: true });
return () => window.removeEventListener('scroll', handleScroll);
}, []);
// Header is sticky when past threshold
const isSticky = scrollY > 0;
// Header is visible when:
// - At top of page (if showOnTop is true)
// - Scrolling up
// - Not past hide threshold
const isVisible =
(showOnTop && scrollY < hideThreshold) ||
scrollDirection === 'up' ||
scrollY < hideThreshold;
// Header is pinned (fixed) when scrolling up after passing threshold
const isPinned = scrollY > hideThreshold && scrollDirection === 'up';
return { isVisible, isSticky, isPinned };
}

Sticky Header Wrapper Component

Apply the sticky behavior to your header:

src/components/header/StickyHeader/StickyHeader.tsx
import { useEffect, useRef } from 'react';
import { useStickyHeader } from '@/hooks/useStickyHeader';
import styles from './StickyHeader.module.css';
interface StickyHeaderProps {
children: React.ReactNode;
}
export function StickyHeader({ children }: StickyHeaderProps) {
const headerRef = useRef<HTMLDivElement>(null);
const { isVisible, isSticky, isPinned } = useStickyHeader();
// Update CSS classes based on state
useEffect(() => {
const header = headerRef.current;
if (!header) return;
header.classList.toggle(styles.sticky, isSticky);
header.classList.toggle(styles.hidden, !isVisible);
header.classList.toggle(styles.pinned, isPinned);
}, [isVisible, isSticky, isPinned]);
// Listen for announcement dismissal
useEffect(() => {
const handleAnnouncementDismissed = () => {
// Recalculate header position after announcement is dismissed
if (headerRef.current) {
headerRef.current.style.top = '0';
}
};
window.addEventListener('announcement-dismissed', handleAnnouncementDismissed);
return () => window.removeEventListener('announcement-dismissed', handleAnnouncementDismissed);
}, []);
return (
<div ref={headerRef} className={styles.wrapper}>
{children}
</div>
);
}
src/components/header/StickyHeader/StickyHeader.module.css
.wrapper {
position: sticky;
top: 0;
z-index: 100;
background: var(--color-background);
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.sticky {
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
.hidden {
transform: translateY(-100%);
}
.pinned {
transform: translateY(0);
}

CSS-Only Alternative

For simpler requirements, use pure CSS:

/* Pure CSS sticky header */
.header-wrapper {
position: sticky;
top: 0;
z-index: 100;
transition: transform 0.3s ease;
}
/* Add class via JavaScript when scrolling down */
.header-wrapper.is-hidden {
transform: translateY(-100%);
}
/* Shadow when scrolled */
.header-wrapper.is-scrolled {
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}

Header with Announcement Integration

Combine announcement bar and sticky header:

src/components/header/HeaderGroup/HeaderGroup.tsx
import { AnnouncementBar } from '../AnnouncementBar';
import { StickyHeader } from '../StickyHeader';
import { Header } from '../Header';
import { readJsonScript } from '@/utils/data-bridge';
interface AnnouncementData {
message: string;
link?: string;
linkText?: string;
backgroundColor?: string;
textColor?: string;
dismissible?: boolean;
}
export function HeaderGroup() {
const announcementData = readJsonScript('announcement-data') as AnnouncementData | null;
return (
<>
{announcementData && (
<AnnouncementBar
message={announcementData.message}
link={announcementData.link}
linkText={announcementData.linkText}
backgroundColor={announcementData.backgroundColor}
textColor={announcementData.textColor}
dismissible={announcementData.dismissible}
/>
)}
<StickyHeader>
<Header />
</StickyHeader>
</>
);
}

Reduced Motion Support

Respect user preferences for reduced motion:

src/hooks/useReducedMotion.ts
import { useState, useEffect } from 'react';
export function useReducedMotion(): boolean {
const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);
useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
setPrefersReducedMotion(mediaQuery.matches);
const handleChange = (event: MediaQueryListEvent) => {
setPrefersReducedMotion(event.matches);
};
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, []);
return prefersReducedMotion;
}

Apply to sticky header:

export function StickyHeader({ children }: StickyHeaderProps) {
const prefersReducedMotion = useReducedMotion();
const { isVisible, isSticky, isPinned } = useStickyHeader();
// Skip animation if user prefers reduced motion
const style = prefersReducedMotion
? { transition: 'none' }
: undefined;
return (
<div
className={`${styles.wrapper} ${!isVisible ? styles.hidden : ''}`}
style={style}
>
{children}
</div>
);
}

Key Takeaways

  1. Dismissible announcements: Store dismissed state in localStorage
  2. Scroll direction detection: Use requestAnimationFrame for performance
  3. Hide on scroll down: Maximize content space when browsing
  4. Show on scroll up: Provide quick access to navigation
  5. Threshold-based behavior: Don’t hide header immediately at top
  6. Announcement coordination: Dispatch events for header to respond
  7. Reduced motion: Respect user accessibility preferences
  8. CSS transitions: Smooth animations for state changes

Your header and navigation components are complete! In the next module, we’ll build Collection Page components.

Finished this lesson?

Mark it complete to track your progress.

Discussion

Loading comments...