Sticky Header and Announcements
Implement sticky headers with scroll behavior, announcement bars with rotation, and dismissible notices for enhanced user experience.
A sticky header keeps navigation accessible as users scroll. Combined with an announcement bar, you can highlight promotions while maintaining easy navigation. Let’s build both with proper scroll handling.
Basic Sticky Header
The simplest sticky header uses CSS:
.header { position: sticky; top: 0; z-index: 100; background: var(--color-background);}That’s it for basic functionality. The header sticks to the top when scrolling.
Sticky with Offset
If you have an announcement bar above the header:
<div class="announcement-bar"> Free shipping on orders over $50</div>
<header class="header"> <!-- header content --></header>.announcement-bar { background: var(--color-primary); color: var(--color-primary-text); text-align: center; padding: var(--spacing-sm);}
.header { position: sticky; top: 0; z-index: 100;}The header will stick after the announcement scrolls away.
Hide on Scroll Down
A popular pattern hides the header when scrolling down and shows it when scrolling up:
class StickyHeader extends HTMLElement { constructor() { super(); this.header = this; this.lastScrollY = 0; this.scrollThreshold = 100; }
connectedCallback() { window.addEventListener('scroll', this.onScroll.bind(this), { passive: true }); }
onScroll() { const currentScrollY = window.scrollY;
// Don't hide if near the top if (currentScrollY < this.scrollThreshold) { this.show(); return; }
// Scrolling down if (currentScrollY > this.lastScrollY) { this.hide(); } // Scrolling up else { this.show(); }
this.lastScrollY = currentScrollY; }
hide() { this.classList.add('is-hidden'); }
show() { this.classList.remove('is-hidden'); }}
customElements.define('sticky-header', StickyHeader);.header { position: sticky; top: 0; z-index: 100; transition: transform 0.3s ease;}
.header.is-hidden { transform: translateY(-100%);}Compact Header on Scroll
Reduce header height when scrolling:
class StickyHeader extends HTMLElement { connectedCallback() { window.addEventListener( 'scroll', () => { if (window.scrollY > 50) { this.classList.add('is-scrolled'); } else { this.classList.remove('is-scrolled'); } }, { passive: true } ); }}
customElements.define('sticky-header', StickyHeader);.header { position: sticky; top: 0; z-index: 100; padding: var(--spacing-md) 0; transition: padding 0.3s, box-shadow 0.3s;}
.header.is-scrolled { padding: var(--spacing-sm) 0; box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);}
/* Shrink logo */.header__logo img { transition: height 0.3s;}
.header.is-scrolled .header__logo img { height: 40px;}Announcement Bar Section
Basic Announcement
{# sections/announcement-bar.liquid #}
{%- if section.settings.text != blank -%} <div class="announcement-bar" style="background: {{ section.settings.background }}; color: {{ section.settings.text_color }};"> <div class="announcement-bar__container container"> {%- if section.settings.link != blank -%} <a href="{{ section.settings.link }}" class="announcement-bar__link"> {{ section.settings.text }} </a> {%- else -%} <p class="announcement-bar__text">{{ section.settings.text }}</p> {%- endif -%} </div> </div>{%- endif -%}
{% schema %}{ "name": "Announcement Bar", "settings": [ { "type": "text", "id": "text", "label": "Text", "default": "Free shipping on orders over $50" }, { "type": "url", "id": "link", "label": "Link" }, { "type": "color", "id": "background", "label": "Background color", "default": "#000000" }, { "type": "color", "id": "text_color", "label": "Text color", "default": "#ffffff" } ]}{% endschema %}Multiple Announcements with Rotation
Use blocks for multiple messages:
{# sections/announcement-bar.liquid #}
{%- if section.blocks.size > 0 -%} <announcement-bar class="announcement-bar" data-autoplay="{{ section.settings.autoplay }}" data-delay="{{ section.settings.delay }}" style="background: {{ section.settings.background }}; color: {{ section.settings.text_color }};" > <div class="announcement-bar__slider"> {%- for block in section.blocks -%} <div class="announcement-bar__slide {% if forloop.first %}is-active{% endif %}" {{ block.shopify_attributes }} > {%- if block.settings.link != blank -%} <a href="{{ block.settings.link }}" class="announcement-bar__link"> {{ block.settings.text }} </a> {%- else -%} <span class="announcement-bar__text">{{ block.settings.text }}</span> {%- endif -%} </div> {%- endfor -%} </div>
{%- if section.blocks.size > 1 -%} <div class="announcement-bar__nav"> <button class="announcement-bar__prev" aria-label="Previous announcement"> {% render 'icon-chevron-left' %} </button> <button class="announcement-bar__next" aria-label="Next announcement"> {% render 'icon-chevron-right' %} </button> </div> {%- endif -%} </announcement-bar>{%- endif -%}
{% schema %}{ "name": "Announcement Bar", "settings": [ { "type": "checkbox", "id": "autoplay", "label": "Auto-rotate announcements", "default": true }, { "type": "range", "id": "delay", "label": "Rotation delay", "min": 3, "max": 10, "step": 1, "default": 5, "unit": "s" }, { "type": "color", "id": "background", "label": "Background color", "default": "#000000" }, { "type": "color", "id": "text_color", "label": "Text color", "default": "#ffffff" } ], "blocks": [ { "type": "announcement", "name": "Announcement", "settings": [ { "type": "text", "id": "text", "label": "Text", "default": "Announcement text" }, { "type": "url", "id": "link", "label": "Link" } ] } ], "presets": [ { "name": "Announcement Bar", "blocks": [ { "type": "announcement" } ] } ]}{% endschema %}Announcement Slider JavaScript
class AnnouncementBar extends HTMLElement { constructor() { super(); this.slides = this.querySelectorAll('.announcement-bar__slide'); this.currentIndex = 0; this.autoplay = this.dataset.autoplay === 'true'; this.delay = parseInt(this.dataset.delay) * 1000 || 5000; this.interval = null; }
connectedCallback() { if (this.slides.length <= 1) return;
this.querySelector('.announcement-bar__prev')?.addEventListener('click', () => this.prev()); this.querySelector('.announcement-bar__next')?.addEventListener('click', () => this.next());
if (this.autoplay) { this.startAutoplay();
// Pause on hover this.addEventListener('mouseenter', () => this.stopAutoplay()); this.addEventListener('mouseleave', () => this.startAutoplay()); } }
startAutoplay() { this.interval = setInterval(() => this.next(), this.delay); }
stopAutoplay() { clearInterval(this.interval); }
next() { this.goTo((this.currentIndex + 1) % this.slides.length); }
prev() { this.goTo((this.currentIndex - 1 + this.slides.length) % this.slides.length); }
goTo(index) { this.slides[this.currentIndex].classList.remove('is-active'); this.currentIndex = index; this.slides[this.currentIndex].classList.add('is-active'); }}
customElements.define('announcement-bar', AnnouncementBar);.announcement-bar { position: relative; padding: var(--spacing-sm) var(--spacing-lg); text-align: center; font-size: 0.875rem;}
.announcement-bar__slider { position: relative; overflow: hidden;}
.announcement-bar__slide { display: none;}
.announcement-bar__slide.is-active { display: block; animation: fadeIn 0.3s ease;}
@keyframes fadeIn { from { opacity: 0; transform: translateY(-5px); } to { opacity: 1; transform: translateY(0); }}
.announcement-bar__link { color: inherit; text-decoration: underline; text-underline-offset: 2px;}
.announcement-bar__nav { position: absolute; top: 50%; left: var(--spacing-sm); right: var(--spacing-sm); transform: translateY(-50%); display: flex; justify-content: space-between; pointer-events: none;}
.announcement-bar__prev,.announcement-bar__next { pointer-events: auto; background: none; border: none; color: inherit; cursor: pointer; padding: var(--spacing-xs); opacity: 0.7; transition: opacity 0.2s;}
.announcement-bar__prev:hover,.announcement-bar__next:hover { opacity: 1;}Dismissible Announcement
Let users close the announcement:
<dismissible-bar class="announcement-bar" id="announcement-{{ section.id }}" data-storage-key="announcement-{{ section.id }}-dismissed"> <div class="announcement-bar__content"> {{ section.settings.text }} </div> <button class="announcement-bar__close" aria-label="Dismiss announcement"> {% render 'icon-close' %} </button></dismissible-bar>class DismissibleBar extends HTMLElement { constructor() { super(); this.storageKey = this.dataset.storageKey; }
connectedCallback() { // Check if already dismissed if (localStorage.getItem(this.storageKey)) { this.remove(); return; }
this.querySelector('.announcement-bar__close')?.addEventListener('click', () => { this.dismiss(); }); }
dismiss() { // Store dismissal localStorage.setItem(this.storageKey, 'true');
// Animate out this.style.transition = 'all 0.3s'; this.style.maxHeight = this.offsetHeight + 'px';
requestAnimationFrame(() => { this.style.maxHeight = '0'; this.style.padding = '0'; this.style.overflow = 'hidden'; });
setTimeout(() => this.remove(), 300); }}
customElements.define('dismissible-bar', DismissibleBar);Handling Content Offset
When the header is sticky, content can jump under it. Handle this with CSS:
/* Using scroll-padding for anchor links */html { scroll-padding-top: var(--header-height, 80px);}
/* Or with margin on main content */main { margin-top: var(--header-height, 80px);}
/* Calculate header height dynamically */.header { --header-height: 80px;}
.header.is-scrolled { --header-height: 60px;}Dynamic Height with JavaScript
class StickyHeader extends HTMLElement { connectedCallback() { this.updateHeight(); window.addEventListener('resize', () => this.updateHeight()); }
updateHeight() { const height = this.offsetHeight; document.documentElement.style.setProperty('--header-height', `${height}px`); }}Complete Header Group
Combine announcement and header in a section group:
{# sections/header-group.json #}{ "type": "header", "name": "Header Group", "sections": { "announcement": { "type": "announcement-bar" }, "header": { "type": "header" } }, "order": ["announcement", "header"]}{# layout/theme.liquid #}<body> {% sections 'header-group' %}
<main> {{ content_for_layout }} </main>
{% sections 'footer-group' %}</body>Performance Considerations
Throttle Scroll Events
function throttle(func, wait) { let waiting = false; return function (...args) { if (waiting) return; func.apply(this, args); waiting = true; setTimeout(() => { waiting = false; }, wait); };}
window.addEventListener( 'scroll', throttle(() => { // Scroll handler }, 100), { passive: true });Use Passive Listeners
window.addEventListener('scroll', handler, { passive: true });Avoid Layout Thrashing
// BAD: Causes layout thrashingwindow.addEventListener('scroll', () => { const height = header.offsetHeight; // Forces layout header.style.top = `${height}px`; // Triggers layout});
// GOOD: Batch reads and writeslet height;window.addEventListener('scroll', () => { // Read height = header.offsetHeight;
// Write in next frame requestAnimationFrame(() => { header.style.top = `${height}px`; });});Practice Exercise
Build a header system that:
- Shows announcement bar on first visit
- Allows dismissal with localStorage persistence
- Becomes sticky after announcement
- Shrinks on scroll
- Hides when scrolling down, shows when scrolling up
Test:
- Does the announcement stay dismissed after refresh?
- Is scroll performance smooth?
- Do anchor links account for header height?
Key Takeaways
- CSS
position: stickyhandles basic sticky behavior - Track scroll direction to show/hide on scroll
- Add visual feedback (shadow, compact mode) when scrolled
- Rotate announcements with blocks and JavaScript
- Use localStorage for dismissible announcements
- Set
scroll-padding-topfor anchor link offsets - Throttle scroll handlers for performance
- Use passive listeners for scroll events
Module Complete!
Congratulations on completing Module 7! You now understand Shopify’s menu system, navigation Liquid objects, and how to build complete header and mobile navigation systems with sticky behavior and announcements.
Next up: Module 8: Global UI: Footer where you’ll build the complementary footer section.
Finished this lesson?
Mark it complete to track your progress.
Discussion
Loading comments...