Blog · · 13 min

The announcement bar, and when it costs you money

A sticky announcement bar takes a permanent slice of the mobile first screen, so it has to carry something the buyer can act on: a code that works, a real cut-off time, a shipping fact. It must be dismissible, remember the dismissal across pages, reserve its height so nothing shifts, and give the space back when closed. Message rotation fails because nobody reads the second one.

By

An announcement bar is a rental agreement. It takes a strip across the top of every screen on the site, forever, and on a phone that strip comes out of the same first screen the product name, the price and the primary image are fighting over. So the bar has to pay rent, and only three kinds of message can: a discount code that actually works, a deadline that is genuinely a deadline, and a shipping or delivery fact the buyer would otherwise have to hunt for.

Anything else is a header that got smaller.

What does the bar have to carry to be worth the space?

The test is whether a buyer can do something with it in the next thirty seconds without leaving the page.

MessageCan they act on it here?Verdict
WELCOME10 for 10% off your first orderYes, if the code works and can be copiedKeep, and make it tap-to-copy
Order before 14:00 for dispatch todayYes, and it changes when they buyKeep, only while it is true today
Free delivery over $50Yes, it changes what they addKeep on listing pages, drop it in the cart
We ship to 34 countriesOnly if their country is one, which it does not sayRewrite with their country, or drop
Winter sale now onNo. It names no product, no code, no amountDrop
Follow us on InstagramYes, and it leaves your siteDrop
Offer ends in 04:59 on a timer that resetsNo, and it is a claim you cannot supportDrop

Two rows are worth more than the others.

Free delivery over $50 in the bar is a weaker version of a component that already exists in the cart, where the remainder can be stated in currency against the actual basket. The bar can only ever state the rule; the free shipping threshold bar states the gap. Once the buyer has a cart, the bar version is redundant and it is taking pixels from the one that works.

Order before 14:00 for dispatch today is the strongest thing a bar can hold, because it is factual, it decays, and it changes behaviour on the same visit. It also has an obligation attached: it must stop saying that at 14:00. A cut-off that is still displayed at four in the afternoon is a false statement about your own operation, and it is the first thing a returning buyer checks the second time.

The arithmetic of the first screen

This is arithmetic on stated inputs, not a measurement of anyone's store. It exists because the cost of the bar is usually described as a bit of space, and a bit of space is not a number you can weigh a message against.

A common phone viewport is 390 by 844 CSS pixels. Mobile Safari and Chrome take roughly 110 to 140 pixels for the address bar and toolbar on first load, so call the visible page 710 pixels.

Element pinned to the topHeightShare of 710 px
Announcement bar, one line40 px5.6%
Announcement bar, two lines wrapped72 px10.1%
Site header with logo and cart56 px7.9%
Bar plus header96 px13.5%
Bar wrapped plus header128 px18.0%
Plus a consent banner, first visit190 px26.8%

The wrapped row is the one that gets missed in review, because the copy is written on a desktop where it fits on one line. Free delivery on orders over $50, plus 10% off your first order with WELCOME10 is one line at 1440 pixels and three lines at 390. Set a hard character budget on the message and enforce it in the type, not in the copy deck:

type Announcement = {
  id: string // campaign id, e.g. 'ship-today-2026-09'
  text: string // 48 characters, hard limit, checked at build
  href?: string
  code?: string
}

Forty-eight characters is about what fits on one line at 390 pixels at 14 pixels with normal letter spacing. Measure it in your own type rather than trusting that number, then write it down and fail the build when it is exceeded. A message that wraps has doubled its rent without anyone approving the increase.

A torn strip of dark kraft paper stretched taut across the top of a black slate slab under hard directional light, letterpressed in two words, one corner ripped away and curling.
A strip across the top, and a corner already torn off. The interesting question is what the slate underneath was going to show.

Why does message rotation fail?

Because the second message is written for a reader who is not there.

A rotating bar assumes the buyer holds still for the interval and reads whatever arrives. What actually happens is that they land, take in the top of the page in a glance, and start scrolling. The message on screen during that glance is message one. Messages two and three are shown to a strip of pixels that has already left the top of the viewport.

Rotation also breaks the two things that make a bar useful. A code cannot be copied if it is going to be replaced in four seconds. A deadline cannot be read twice to check the time.

There is an accessibility cost on top. WCAG success criterion 2.2.2 requires that automatically updating information which starts automatically, lasts more than five seconds and is presented alongside other content can be paused, stopped or hidden by the user. A three-message rotator that runs for the life of the page needs a pause control. Almost none of them have one, and the control would take as much room as the message.

If you have three things to say, you have a ranking problem, not a display problem. Pick the one that changes what the buyer does next. Put the second in the header navigation. Put the third in the footer, where the people who go looking will find it.

What about a countdown?

Only if the deadline is real, enforced, and the same for everyone.

A timer that restarts on reload, or that is seeded from the moment of the visit, is a statement that the offer ends at a time it does not end. Annex I, point 7 of the Unfair Commercial Practices Directive lists as an in-all-circumstances unfair practice falsely stating that a product will only be available for a very limited time, or that it will only be available on particular terms for a very limited time, in order to elicit an immediate decision. That is a description of a session-seeded countdown.

The honest version takes an absolute instant, not a duration:

// The deadline is a fact about the world, so it is stored as one.
const ENDS_AT = Date.UTC(2026, 8, 8, 21, 59, 59) // 8 Sep 2026, 21:59:59 UTC

export function remaining(now = Date.now()): number {
  return Math.max(0, ENDS_AT - now)
}

Server-rendered against the same constant, identical on every device, and at zero the bar renders the offer as over rather than looping. The same rule about invented deadlines applies to the order bump and to tier cards, and it is the same failure each time: a mechanism that would have been fine has been attached to a claim that is not true.

If the deadline is real, prefer the sentence to the digits. Ends Sunday night is readable at a glance, does not need a timer, does not re-render every second, and does not turn into 00:00:00 on a page nobody refreshed.

How do you build one that does not shift the layout?

Two requirements pull against each other. The bar must be dismissible and the dismissal must survive a page change, which means reading storage. And nothing may move after the first paint, which means the decision has to be made before React runs.

The answer is a blocking script in the document head and a CSS variable. The script is four lines and it runs before the first paint:

<script>
  try {
    var dismissed = localStorage.getItem('announcement.dismissed')
    if (dismissed === 'ship-today-2026-09') {
      document.documentElement.dataset.announcement = 'closed'
    }
  } catch (e) {}
</script>

The value stored is the campaign id, not true. That is what makes the next campaign appear for someone who dismissed the last one, without you having to clear anything. Change the id, everyone sees the new bar once, and their old dismissal is now a value that matches nothing.

Then the height is a custom property, so closing it animates and returns the space:

:root {
  --announcement-h: 40px;
}
:root[data-announcement='closed'] {
  --announcement-h: 0px;
}

.topbar {
  position: sticky;
  top: 0;
  z-index: 40; /* below the cart drawer overlay */
}

.announcement {
  block-size: var(--announcement-h);
  overflow: hidden;
  transition: block-size 160ms ease;
}

@media (prefers-reduced-motion: reduce) {
  .announcement {
    transition: none;
  }
}

The bar and the header live inside one sticky .topbar, rather than each being sticky with the header offset by the bar's height. That is the difference between one element that behaves and two that disagree about where the top is during the closing transition.

The React side then only manages the runtime state, and it reads the same attribute the script wrote:

const CAMPAIGN = 'ship-today-2026-09'
const KEY = 'announcement.dismissed'

export function AnnouncementBar({ children }: { children: React.ReactNode }) {
  const [open, setOpen] = useState(true)

  // The head script already hid it before paint; sync the React state to it.
  useEffect(() => {
    setOpen(document.documentElement.dataset.announcement !== 'closed')
  }, [])

  function dismiss() {
    setOpen(false)
    document.documentElement.dataset.announcement = 'closed'
    try {
      localStorage.setItem(KEY, CAMPAIGN)
    } catch {
      // private mode: it will come back on the next page, and that is acceptable
    }
  }

  return (
    <div className="announcement" aria-hidden={!open} inert={!open}>
      <div className="announcement__inner">
        <p className="announcement__text">{children}</p>
        <button
          type="button"
          className="announcement__close"
          onClick={dismiss}
          aria-label="Dismiss this announcement"
        >
          <span aria-hidden="true">&times;</span>
        </button>
      </div>
    </div>
  )
}

Three details in that component earn their lines.

The close button has an aria-label, because its visible content is a multiplication sign. A button whose accessible name is × is announced as times, button.

inert when closed stops the collapsed strip from holding a tab stop. Without it a keyboard user tabs from the address bar into a button they cannot see, inside a container with zero height. On React 18, spread it as {...(open ? {} : { inert: '' })} because the attribute is not in the type definitions there. The same attribute does the same job in the cart drawer.

The catch is empty on purpose. Storage throws in private mode and when a browser blocks site data, and the correct behaviour is that the bar reappears on the next page rather than that the page fails.

Should it be sticky at all?

Usually not.

Sticky is the expensive version. It says the message is worth a permanent share of every screen for the whole session, and almost nothing is. A bar in normal document flow, at the top of the page, is read on arrival by the same people who would have read the sticky one, and then it scrolls away and gives the space back for free.

Reserve sticky for a message that is true throughout the visit and that the buyer will want at a moment you cannot predict. A dispatch cut-off qualifies. A campaign slogan does not.

On desktop the calculation changes, because 96 pixels of a 900 pixel viewport is a different rent from 96 pixels of 710. A bar that is sticky above 1024 pixels and in normal flow below it is a defensible split, and it is one media query:

@media (min-width: 1024px) {
  .topbar { position: sticky; top: 0; }
}

The one thing the bar must never do on mobile is stack with a second fixed element at the other end of the screen. If you also run a sticky add-to-cart bar, the two of them together take a header, a footer and whatever the consent banner is doing, and the product is being viewed through a letterbox. Pick one.

We build uxgen, an MCP server that gives Claude 168 selling components and writes them into your store as HTML you own, from $19 a month. The bar above is not one of the hard ones, and the code on this page is enough to build it without us.

FAQ

Should an announcement bar be sticky?

Usually not on mobile. A sticky bar takes a permanent share of a viewport that is around 710 usable CSS pixels on a common phone, and together with a 56 pixel header that is roughly 13% of the first screen for the whole session. Reserve sticky for a message that stays true and useful throughout the visit, such as a same-day dispatch cut-off, and put everything else in normal document flow where it scrolls away.

Why should an announcement bar not rotate messages?

Because the buyer reads the top of the page in one glance on arrival and then scrolls, so only the first message is seen. Rotation also makes a discount code impossible to copy and a deadline impossible to re-read, and WCAG success criterion 2.2.2 requires a pause control for information that updates automatically for more than five seconds. Three messages is a ranking problem, not a display problem.

How do I make an announcement bar remember that it was dismissed?

Store the campaign id rather than a boolean, and read it in a small blocking script in the document head that sets a data attribute on the root element before the first paint. That avoids the flash of a bar that then disappears, and it means the next campaign appears for everyone because its id no longer matches the stored value. Wrap the storage access in a try/catch, since it throws in private mode.

Is a countdown timer in an announcement bar legal?

Only if the deadline is real, the same for every visitor, and enforced when it passes. A timer seeded from the moment of the visit or restarted on reload states that an offer ends at a time it does not end, and Annex I point 7 of the EU Unfair Commercial Practices Directive lists falsely stating that a product is available only for a very limited time as unfair in all circumstances. Store an absolute instant, render the same value everywhere, and show the offer as over at zero.

uxgen is a service of UXGen AI, LLC — 131 Continental Dr, Suite 305, Newark, DE 19713, United States.

© 2026 UXGen AI, LLC. All rights reserved.