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.

A Shopify storefront with a dismissible announcement bar pinned across the top showing a free shipping message, a Shop the sale link, a live countdown, and a close button on the right
One theme section: editable message, optional countdown, and a close button that remembers it was dismissed.

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.

sections/announcement-bar.liquid
{% 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 }}"      >        &times;      </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 %}
  • enabled is a checkbox setting. 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.
  • message is a richtext setting so the merchant can bold a word or add a link inline. richtext is sanitized by Shopify, which is what you want for merchant-entered HTML.
  • The url setting 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 color settings feed CSS custom properties on the element (--bar-bg and --bar-fg). A computed value at render time is the one legitimate use of an inline style attribute.
  • The presets block 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.

assets/announcement-bar.js (or a {% javascript %} block)
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 = true is the accessible way to remove it: it drops out of the layout and the accessibility tree, unlike visibility: hidden or 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:

sections/announcement-bar.liquid (schema additions)
{  "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.

sections/announcement-bar.liquid (inside the message paragraph)
{% 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 %}
assets/announcement-bar.js (countdown)
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 into theme.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 localStorage the close button is decorative.
  • Reaching for a date setting type that does not exist in theme schemas. A date input is a checkout and customer-account extension type, not a theme one, so the upload fails. The real answer is a text setting 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 — free

Wrapping 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.

One email when a new module ships, one when v1 launches. No drip sequence, no spam. Unsubscribe anytime.

LiquidConversionTheme section

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