Conversion
Shopify announcement bar without an app (dismissible, with countdown)
Build a dismissible Shopify announcement bar as a theme section with a real {% schema %}: a merchant-editable message, link, and colors, dismissal that sticks via localStorage, and an optional sale countdown driven by a merchant-set date. No app required.
Bas Lefeber
Founder, learnshopify.dev · June 19, 2026 · 7 min read
An announcement bar is the slim strip across the very top of the store: free shipping over a threshold, a sale code, a shipping cutoff before the holidays. Every announcement-bar app in the store charges a monthly fee for what is, at the core, one Liquid section and about fifteen lines of JavaScript. If you own the theme, you do not need the app. You need a {% schema %} so the merchant can edit the message, a dismiss button that stays dismissed, and (optionally) a countdown wired to a date the merchant sets, never one you hardcode.
TL;DR
Build a sections/announcement-bar.liquid with a {% schema %} exposing the message (richtext), a url link, color settings, and a checkbox to enable it. Make it dismissible with a button plus localStorage so it does not reappear on every page load. For a sale, add an optional countdown driven by a text setting holding an ISO date the merchant edits. Shopify themes have no date input type, so a text field is the correct primitive here.
The section and its schema
An announcement bar is a full-width module the merchant turns on and off, so it belongs in sections/, not snippets/. The section that follows reads everything from section.settings, which means the merchant edits the copy, the link, and the colors from the theme editor without ever opening a code file. That is the whole point: the developer ships the structure once, the merchant owns the words.
{% if section.settings.enabled and section.settings.message != blank %} <aside class="announcement-bar" data-announcement data-key="{{ section.id }}" style="--bar-bg: {{ section.settings.bar_color }}; --bar-fg: {{ section.settings.text_color }};" > <div class="announcement-bar__inner page-width"> <p class="announcement-bar__message"> {{ section.settings.message }} {% if section.settings.link_url != blank %} <a class="announcement-bar__link" href="{{ section.settings.link_url }}"> {{ section.settings.link_label }} </a> {% endif %} </p> <button type="button" class="announcement-bar__close" data-announcement-close aria-label="{{ 'sections.announcement.dismiss' | t }}" > × </button> </div> </aside>{% endif %} {% schema %}{ "name": "Announcement bar", "settings": [ { "type": "checkbox", "id": "enabled", "label": "Show announcement bar", "default": true }, { "type": "richtext", "id": "message", "label": "Message", "default": "<p>Free shipping on orders over $75</p>" }, { "type": "url", "id": "link_url", "label": "Link" }, { "type": "text", "id": "link_label", "label": "Link label", "default": "Shop the sale" }, { "type": "color", "id": "bar_color", "label": "Background", "default": "#1c1a17" }, { "type": "color", "id": "text_color", "label": "Text", "default": "#ffffff" } ], "presets": [{ "name": "Announcement bar" }]}{% endschema %}enabledis acheckboxsetting. The merchant toggles the whole bar off for a slow week without deleting their copy, and the{% if %}guard means an empty message renders nothing at all.messageis arichtextsetting so the merchant can bold a word or add a link inline.richtextis sanitized by Shopify, which is what you want for merchant-entered HTML.- The
urlsetting type gives the merchant a proper resource picker (collection, product, page, or external URL), not a raw text box where they can fat-finger a broken path. - Two
colorsettings feed CSS custom properties on the element (--bar-bgand--bar-fg). A computed value at render time is the one legitimate use of an inlinestyleattribute. - The
presetsblock is what lets the merchant drag the section in from the editor. Without it the section exists but cannot be added.
Where it actually mounts
In Online Store 2.0 themes, the header area is a section group (sections/header-group.json). Add the section there, or drop it into the editor under the header, so it sits above the page on every template. Avoid hardcoding a {% section %} tag into theme.liquid unless your theme predates 2.0.
Making it dismissible (and keeping it dismissed)
A close button is trivial. The detail that separates a real bar from a tutorial one is persistence: once a shopper dismisses the bar, it must stay gone as they browse, otherwise it is just nagging. There is no server-side per-visitor state for this in a theme, and you should not build a shadow cart or cookie soup for a banner. The right tool is localStorage: a single key, set on dismiss, checked on load.
document.querySelectorAll('[data-announcement]').forEach((bar) => { const storageKey = 'announcement-dismissed:' + bar.dataset.key; // Hide immediately if this shopper already dismissed this bar. if (localStorage.getItem(storageKey) === '1') { bar.hidden = true; return; } const closeButton = bar.querySelector('[data-announcement-close]'); closeButton?.addEventListener('click', () => { bar.hidden = true; localStorage.setItem(storageKey, '1'); });});- The key is namespaced with
section.id(bar.dataset.key), so if the merchant changes the message and you want the bar to show again, you change the section, the key changes, and dismissals reset. Two different bars never collide. - Setting
bar.hidden = trueis the accessible way to remove it: it drops out of the layout and the accessibility tree, unlikevisibility: hiddenor an off-screen hack. - The check runs on load before paint if the script is in the
<head>with a small inline guard, but for most stores running it at the end of the section is fine and avoids a flash if you start the bar collapsed in CSS.
The optional countdown: a date the merchant owns
Sale bars convert harder with a clock. Here is the rule that AI-generated versions almost always break: never hardcode the deadline in code. A countdown that ends on a date baked into a .js file is a support ticket waiting to happen, because the merchant cannot change it and will not remember to ask you. The deadline is content, so it belongs in a setting the merchant edits.
Shopify themes do not have a date input setting type (that type exists for checkout and customer-account extensions, not theme schemas). The correct primitive is a text setting holding an ISO 8601 date, which the browser parses reliably. Add these two settings to the schema above:
{ "type": "checkbox", "id": "show_countdown", "label": "Show countdown", "default": false},{ "type": "text", "id": "deadline", "label": "Sale ends (e.g. 2026-07-01T23:59)", "info": "ISO date and time. The countdown hides itself once this passes.", "default": "2026-07-01T23:59"}Render the deadline into a <time> element with a machine-readable datetime attribute. That is both the semantic HTML choice and the value the JavaScript reads, so the date lives in exactly one place: the setting.
{% if section.settings.show_countdown and section.settings.deadline != blank %} <time class="announcement-bar__countdown" data-countdown datetime="{{ section.settings.deadline }}" >{{ section.settings.deadline }}</time>{% endif %}document.querySelectorAll('[data-countdown]').forEach((el) => { const end = Date.parse(el.getAttribute('datetime')); if (Number.isNaN(end)) return; const tick = () => { const remaining = end - Date.now(); if (remaining <= 0) { el.textContent = 'Ended'; return; } const totalSeconds = Math.floor(remaining / 1000); const days = Math.floor(totalSeconds / 86400); const hours = Math.floor((totalSeconds % 86400) / 3600); const minutes = Math.floor((totalSeconds % 3600) / 60); const seconds = totalSeconds % 60; el.textContent = days + 'd ' + hours + 'h ' + minutes + 'm ' + seconds + 's'; }; tick(); setInterval(tick, 1000);});Note
The countdown reads Date.parse(datetime) from the element the merchant's setting populated, so there is no date anywhere in JavaScript. Change the setting in the theme editor and the clock updates on the next load. That is the merchant-editable-date principle: the structure is yours, the deadline is theirs.
What AI tools get wrong here
Ask a code assistant for an announcement bar and you usually get markup that works in a demo and falls apart in a real store. The failures cluster:
- Hardcoding the message and the deadline straight into the Liquid or the JavaScript. The merchant cannot change a word without a developer, so the bar goes stale and the sale clock counts down to a date that already passed.
- No schema at all, just a
<div>pasted intotheme.liquid. It is invisible in the theme editor, so the merchant has no way to toggle it, restyle it, or take it down for a slow week. - A dismiss button with no persistence. Clicking close hides it for that pageview, then it reappears on the next click-through. Without
localStoragethe close button is decorative. - Reaching for a date setting type that does not exist in theme schemas. A
dateinput is a checkout and customer-account extension type, not a theme one, so the upload fails. The real answer is atextsetting with an ISO value, which is conservative and parses everywhere.
Learn this properly · free lesson
The constraint puzzle: a countdown, no app
Build the no-app countdown end to end in our interactive browser editor: a merchant-set deadline, a live ticking clock, and the reasoning for why the date never gets hardcoded. Free, runs against a live store, with a reviewer checking your work.
Try this lesson — freeWrapping up
One section, one {% schema %}, a few lines of localStorage, and an optional clock that reads a date the merchant owns. That replaces a paid app and behaves like part of the theme, because it is. The same instincts (real settings, no hardcoded content, accessible dismissal) carry straight into the other bars worth building: a free shipping progress bar and a sticky add to cart bar that keeps the buy action in reach.
Frequently asked questions
How do I add an announcement bar in Shopify without an app?
Create a sections/announcement-bar.liquid file with a {% schema %} that exposes the message, a link, colors, and an enable checkbox, then add the section to your header section group in the theme editor. The merchant edits everything from the editor, so you ship it once and no app is needed.
How do I make a Shopify announcement bar dismissible so it stays closed?
Add a close button and a small script. On click, set bar.hidden = true and write a flag to localStorage keyed by the section id. On page load, check that key first and hide the bar if it is set. localStorage keeps the dismissal across page loads without any server-side state.
How do I add a countdown to a Shopify announcement bar?
Add a text setting holding an ISO date (Shopify themes have no date input type), render it into a <time datetime> element, and have JavaScript read Date.parse on that attribute and tick every second. Because the deadline lives in a setting, the merchant changes the sale end date from the theme editor, never in code.
Why not just hardcode the announcement message and sale date?
Because content belongs to the merchant. A hardcoded message means they need a developer to change a word, and a hardcoded deadline counts down to a date that eventually passes with nobody able to fix it. Putting both in schema settings is the difference between a one-off snippet and a section the store can actually run.
On the launch list
Get updates on the platform.
Same waitlist as the homepage. New posts plus a heads-up when v1 launches. No drip, no spam.
About the author
Bas Lefeber, Founder, learnshopify.dev
Bas builds learnshopify.dev, where developers learn production-grade Shopify theme development against a live storefront. He writes about Liquid, theme architecture, and the parts of the job that still matter now that AI writes the code.
Keep going in the curriculum
