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/State | Source | Liquid Field |
|---|---|---|
message | JSON script element | section.settings.message |
link | JSON script element | section.settings.link |
linkText | JSON script element | section.settings.link_text |
backgroundColor | JSON script element | section.settings.background_color |
textColor | JSON script element | section.settings.text_color |
dismissible | JSON script element | section.settings.dismissible |
isVisible | Local state + localStorage | - |
scrollDirection | useScrollDirection hook | - |
isSticky | useStickyHeader hook | - |
Note: The
AnnouncementBarprops 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:
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> );}.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:
/* * 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:
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:
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> );}.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:
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:
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
- Dismissible announcements: Store dismissed state in localStorage
- Scroll direction detection: Use requestAnimationFrame for performance
- Hide on scroll down: Maximize content space when browsing
- Show on scroll up: Provide quick access to navigation
- Threshold-based behavior: Don’t hide header immediately at top
- Announcement coordination: Dispatch events for header to respond
- Reduced motion: Respect user accessibility preferences
- 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...