Key takeaways
- You do not need an app for a countdown banner. A timer is one section file: some Liquid, a little CSS, and a small inline script. Apps charge monthly for it and add their own script weight to every page.
- Dawn’s announcement bar has no countdown. It supports up to 12 rotating text announcements, social icons and locale selectors, but nothing time-based, so a custom section is the app-free route.1
- The timezone gotcha is real. A date string without a UTC offset is parsed in each visitor’s local timezone, so your sale “ends” at a different moment in every country. Pin the deadline with an explicit offset.
- A banner is prime CLS territory. Reserve its height in CSS and give the digits fixed-width boxes, or the timer will shove your whole page down after load.
A Shopify countdown banner with no app is a single section file you paste into your theme: a message, four ticking numbers, and a deadline set from the theme editor. This guide gives you the complete section, then walks through the three details most copy-paste snippets get wrong: timezones, layout shift, and screen readers.
It is written for BFCM, because that is when most stores reach for a timer, but the section works for any sale with a real end date.
Why you can trust us
We have been in the Shopify space for over four years and have worked with hundreds of Shopify brands on their storefronts. Jacques has over 15 years of development experience. We build Fudge, an AI storefront editor with a 4.9 rating on the Shopify App Store and Built for Shopify status, so writing theme sections like this one is literally what our product does all day.
Why a countdown banner works for BFCM
A deadline answers the one question every BFCM shopper is silently asking: can I think about this later? When the honest answer is no, a visible timer says so better than any adjective. That is loss aversion doing the selling for you.
The keyword is honest. Count down to a real end time, and end the sale when it hits zero. Evergreen timers that quietly reset on refresh train repeat visitors to ignore urgency, and they put you on the wrong side of dark-pattern rules in a growing list of jurisdictions.
A banner is also the cheapest place to run urgency. It sits above the header on every page, needs no redesign of anything, and disappears the moment the sale ends.
If you are lining up the rest of your BFCM prep, do the measurement side too: our sibling guide on server-side Meta tracking with the Conversions API covers making sure the traffic your banner converts actually gets attributed.
Do you really need a countdown timer app?
No, and for a banner this small the app trade is bad on both sides of the ledger.
Apps cost money for something a theme can do natively. Countdown banner apps run subscriptions for what amounts to one section file. That is fine for complex functionality; a timer is not complex functionality.
Apps cost speed on every page, all year. App embeds inject their own JavaScript and CSS into your storefront, and most stay loaded after the sale ends, or even after you uninstall if the app edited your theme. During BFCM, when every visitor matters, third-party script weight is the last thing your largest traffic week needs.
A native section, by contrast, is a few kilobytes served from Shopify’s CDN with your theme, edited in the same theme editor as everything else, and deleted in one click when December arrives.
What can the theme editor do without any code?
Worth checking before you build, because the answer changed in 2025-2026.
Dawn and most free themes: no countdown. Dawn’s announcement bar section supports up to 12 rotating announcement blocks with links, auto-rotate, social icons, a separator line and country and language selectors. There is no time-based setting anywhere in its schema.1 A static “Sale ends Monday” message is as far as it goes.
Horizon and AI-generated blocks: yes, sort of. Shopify’s theme editor can now generate a custom block from a text prompt via Shopify Magic. You click Add block, choose Generate, and describe the timer you want. It works across Theme Store themes on most plans, with one caveat worth reading twice: Shopify Support does not help customize or troubleshoot the generated code, and quality is explicitly your responsibility.2
That caveat is why a hand-checked section is still the better route for BFCM. You get code you have read, with the exact settings you want, and no surprises at 11pm on Thanksgiving. If you have never added a section before, skim how to add homepage sections in Shopify first; the mechanics are identical.
Build it: the complete countdown banner section
In your admin, go to Online Store → Themes → ⋯ → Edit code, duplicate the theme first, then create a new file under sections/ called countdown-banner.liquid and paste everything below into it.
<div
id="CountdownBanner-{{ section.id }}"
class="countdown-banner"
data-end="{{ section.settings.end_date | escape }}T{{ section.settings.end_time | escape }}:00{{ section.settings.utc_offset | escape }}"
data-expiry="{{ section.settings.expiry_behavior | escape }}"
style="background: {{ section.settings.bg_color }}; color: {{ section.settings.text_color }};"
>
<div class="countdown-banner__inner">
<p class="countdown-banner__message">
{%- if section.settings.link != blank -%}
<a href="{{ section.settings.link }}">{{ section.settings.message | escape }}</a>
{%- else -%}
{{ section.settings.message | escape }}
{%- endif -%}
</p>
<div class="countdown-banner__timer" role="timer" aria-live="off">
<span class="countdown-banner__sr-only">
Offer ends {{ section.settings.end_date }} at {{ section.settings.end_time }}
</span>
<span class="countdown-banner__unit" aria-hidden="true">
<span class="countdown-banner__digits" data-unit="days">–</span>
<small>days</small>
</span>
<span class="countdown-banner__unit" aria-hidden="true">
<span class="countdown-banner__digits" data-unit="hours">–</span>
<small>hrs</small>
</span>
<span class="countdown-banner__unit" aria-hidden="true">
<span class="countdown-banner__digits" data-unit="minutes">–</span>
<small>min</small>
</span>
<span class="countdown-banner__unit" aria-hidden="true">
<span class="countdown-banner__digits" data-unit="seconds">–</span>
<small>sec</small>
</span>
</div>
<p class="countdown-banner__expired" hidden>
{{ section.settings.expired_message | escape }}
</p>
</div>
</div>
{% style %}
#CountdownBanner-{{ section.id }} {
min-height: 3.25rem; /* reserve the space: no layout shift when JS fills in */
display: flex;
align-items: center;
}
#CountdownBanner-{{ section.id }} .countdown-banner__inner {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1.25rem;
align-items: center;
justify-content: center;
width: 100%;
padding: 0.5rem 1.5rem;
text-align: center;
}
#CountdownBanner-{{ section.id }} .countdown-banner__message {
margin: 0;
font-size: 0.9rem;
letter-spacing: 0.03em;
}
#CountdownBanner-{{ section.id }} .countdown-banner__message a {
color: inherit;
}
#CountdownBanner-{{ section.id }} .countdown-banner__timer {
display: flex;
gap: 0.75rem;
}
#CountdownBanner-{{ section.id }} .countdown-banner__unit {
display: inline-flex;
flex-direction: column;
line-height: 1.1;
}
#CountdownBanner-{{ section.id }} .countdown-banner__digits {
font-variant-numeric: tabular-nums; /* every digit same width: no jitter */
min-width: 2ch;
font-weight: 700;
font-size: 1rem;
}
#CountdownBanner-{{ section.id }} .countdown-banner__unit small {
font-size: 0.6rem;
text-transform: uppercase;
letter-spacing: 0.08em;
opacity: 0.75;
}
#CountdownBanner-{{ section.id }} .countdown-banner__expired {
margin: 0;
width: 100%;
}
.countdown-banner__sr-only {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
@media (prefers-reduced-motion: no-preference) {
#CountdownBanner-{{ section.id }} .countdown-banner__digits {
transition: opacity 150ms ease;
}
}
{% endstyle %}
<script>
(function () {
function initCountdown() {
var banner = document.getElementById('CountdownBanner-{{ section.id }}');
if (!banner || banner.dataset.initialized) return;
banner.dataset.initialized = 'true';
var timer = banner.querySelector('.countdown-banner__timer');
var expired = banner.querySelector('.countdown-banner__expired');
var end = new Date(banner.dataset.end).getTime();
if (isNaN(end)) {
// Bad or missing date: never show NaN, fall back to the static message
timer.hidden = true;
expired.hidden = false;
return;
}
var units = {};
banner.querySelectorAll('[data-unit]').forEach(function (el) {
units[el.dataset.unit] = el;
});
function pad(n) {
return n < 10 ? '0' + n : String(n);
}
var tick;
function render() {
if (!banner.isConnected) {
clearInterval(tick);
return;
}
var diff = end - Date.now();
if (diff <= 0) {
clearInterval(tick);
if (banner.dataset.expiry === 'hide') {
banner.hidden = true;
} else {
timer.hidden = true;
expired.hidden = false;
}
return;
}
var s = Math.floor(diff / 1000);
units.days.textContent = String(Math.floor(s / 86400));
units.hours.textContent = pad(Math.floor((s % 86400) / 3600));
units.minutes.textContent = pad(Math.floor((s % 3600) / 60));
units.seconds.textContent = pad(s % 60);
}
render();
tick = setInterval(render, 1000);
}
initCountdown();
// Theme editor: re-init when the section is reloaded
document.addEventListener('shopify:section:load', function (event) {
if (event.detail && event.detail.sectionId === '{{ section.id }}') {
initCountdown();
}
});
})();
</script>
{% schema %}
{
"name": "Countdown banner",
"settings": [
{
"type": "text",
"id": "message",
"label": "Message",
"default": "Black Friday: up to 40% off sitewide"
},
{
"type": "url",
"id": "link",
"label": "Link (optional)"
},
{
"type": "text",
"id": "end_date",
"label": "End date (YYYY-MM-DD)",
"default": "2026-11-30"
},
{
"type": "text",
"id": "end_time",
"label": "End time, 24h (HH:MM)",
"default": "23:59"
},
{
"type": "text",
"id": "utc_offset",
"label": "Store UTC offset",
"default": "-05:00",
"info": "Pins the deadline to one global moment. Use your store timezone's offset, e.g. -05:00 for New York in November, +01:00 for Berlin."
},
{
"type": "select",
"id": "expiry_behavior",
"label": "When the countdown ends",
"options": [
{ "value": "hide", "label": "Hide the banner" },
{ "value": "message", "label": "Show the expired message" }
],
"default": "message"
},
{
"type": "text",
"id": "expired_message",
"label": "Expired message",
"default": "The sale has ended. See you next year!"
},
{
"type": "color",
"id": "bg_color",
"label": "Background",
"default": "#111111"
},
{
"type": "color",
"id": "text_color",
"label": "Text",
"default": "#ffffff"
}
],
"presets": [{ "name": "Countdown banner" }]
}
{% endschema %}
Save, open the theme editor, and add Countdown banner to the Header section group so it renders above the header on every page. Set the date, time and offset, pick your expiry behavior, and you are done. The same section-group placement works for a free shipping bar, which pairs well with a countdown during BFCM.
The timezone gotcha
This is the bug in most copy-paste timers. new Date('2026-11-30T23:59:00'), with no offset, is parsed in the visitor’s local timezone. A shopper in Sydney would see your sale end 16 hours before a shopper in Los Angeles, and someone always screenshots it.
The section avoids this by appending the utc_offset setting to the date string, so 2026-11-30T23:59:00-05:00 is the same instant everywhere on Earth. Two things to check when you configure it:
- Use the offset your store timezone will have on the end date, not today. New York is -05:00 in late November (EST), not the -04:00 it shows in summer.
- If you run the sale into a DST changeover, verify the offset for that specific week. For BFCM 2026 (Black Friday is November 27), US and EU clocks have already changed, so the winter offset is the right one.
Keeping it CLS-safe
A banner injected at the top of the page is the classic cause of a bad Cumulative Layout Shift score: everything below it jumps down when it appears. The section defends against that in two ways.
The height is reserved before JavaScript runs. The min-height: 3.25rem on the wrapper means the space exists at first paint, and the digits filling in changes nothing about layout. If you enlarge the fonts, re-measure and update the reserved height to match.
The digits cannot wobble. font-variant-numeric: tabular-nums gives every digit the same width, and min-width: 2ch on each number box stops the timer breathing as 10 becomes 09. Without this, the banner re-flows every second on some fonts.
One deliberate trade-off: the hide on expiry option removes the banner and shifts the page, once. If your sale ends mid-traffic, prefer the expired-message option, which keeps the height. Layout shift has more failure modes than banners; our guide to fixing CLS on Shopify covers the rest.
Accessibility: a timer that stays quiet
A per-second ticker is the worst possible thing to announce to a screen reader, so the section is built to say it once and then shut up:
- The timer has
role="timer"witharia-live="off", so updates are never announced automatically. This is the explicit opposite of thearia-live="polite"many snippets cargo-cult in, which would interrupt a screen reader user every single second. - The ticking digits are
aria-hidden="true". What assistive tech reads instead is one static, visually hidden sentence: “Offer ends 2026-11-30 at 23:59.” - The only animation, a subtle digit transition, is wrapped in
prefers-reduced-motion: no-preference, so users who have asked for reduced motion get none. If you add a flashier effect later, keep it inside that media query.
Before you publish
Check the banner on desktop and on a phone, since the message and timer wrap differently at narrow widths. Set a past end date to confirm the expired state does what you configured, then set the real date back. Open the section in the theme editor and change a setting to confirm the timer keeps running after the section reloads.
When a page builder makes sense instead
A single banner is not a reason to install anything. But the calculus changes when the countdown is one piece of a larger campaign build: a BFCM landing page with timers, tiered offers, moving parts across templates, and a marketing team that wants to iterate hourly without touching code. At that point a page builder or a dedicated campaign tool earns its subscription, at least for November.
The middle path is what we built Fudge for. You describe the banner, or the whole campaign section, in plain language, and Fudge writes it as native Liquid, CSS and JavaScript in your theme, settings and all, with a diff you can read before publishing. You get the app-free result without hand-coding it; the Shopify store editor page shows how that works. Nothing to uninstall in December, because it was never an app layer to begin with.
FAQ
Yes. A countdown banner is a single custom section: Liquid markup, a schema with an end date setting, some CSS and a small inline script. You paste it into sections/ in the code editor, add it via the theme editor, and configure the deadline like any other section setting. No subscription, no third-party script.
No. Dawn's announcement bar supports up to 12 rotating text announcements with links, auto-rotate, social icons and country and language selectors, but nothing time-based. To get a countdown in Dawn you add a custom section or use the theme editor's AI block generation, whose output Shopify Support does not troubleshoot.
Because the date string has no UTC offset. JavaScript parses a bare date-time like 2026-11-30T23:59:00 in each visitor's local timezone, so the deadline drifts by up to a day around the world. Append an explicit offset such as -05:00 to pin the sale end to one global instant.
Only if it is injected without reserved space. Give the banner a fixed min-height in CSS so the page is laid out correctly at first paint, and use tabular-nums with a min-width on the digits so the timer does not re-flow every second. Built that way, the banner is designed to minimise layout shift.
Pick one of two behaviors and make it a setting: hide the banner entirely, or swap the timer for an expired message. The message option is better if the sale ends while traffic is on the site, because it preserves the banner height and avoids a late layout shift. Whatever you choose, actually end the sale.
No. Timers that reset on every visit train returning shoppers to ignore urgency, and fake deadlines are treated as deceptive design under consumer protection rules in several jurisdictions. Count down to a real end time and honor it; honest urgency converts and keeps converting.
Add the section to the header section group in the theme editor rather than to a single template. Sections in the header group render above the header across all pages of an Online Store 2.0 theme, so one placement covers the whole storefront and one click removes it after the sale.
Footnotes
-
Shopify, Dawn theme source,
sections/announcement-bar.liquid- the section schema supports announcement blocks (up to 12) with text and link, auto-rotate with a 3-10 second speed, a color scheme, separator line, social icons, and country and language selectors; no countdown or time-based setting exists. https://github.com/Shopify/dawn/blob/main/sections/announcement-bar.liquid ↩ ↩2 -
Shopify Help Center, “Automatically generating theme blocks in the theme editor” - Shopify Magic generates a self-contained theme block from a text prompt via Add block → Generate, works with Theme Store themes on Free trial, Basic, Growth, Advanced and Plus plans, and “Assistance with customizing or troubleshooting the code for blocks generated by Shopify Magic isn’t supported by Shopify Support.” https://help.shopify.com/en/manual/online-store/themes/customizing-themes/theme-editor/shopify-magic/generate-blocks ↩