For the complete documentation index, see llms.txt. This page is also available as Markdown.
Add a Banner Above the Header
The announcement bar scrolls up and out of view while the main header follows it up, then stops and pins below the top of the screen. No gap, no jump, no double-height header.
This works whether the header resolves to position: sticky or position: fixed, so toggling data-overlay-header will not break it.
Before you start
Your header needs this structure, with the banner inside the Header Wrapper:
<headerclass="header-wrapper"><!-- behavior shell, has the attached script --><divclass="header-banner"><!-- announcement bar --><divclass="header"><!-- logo + nav -->
Step 1: Understand why it doesn't work by default
.header-wrapper is the positioned box, so everything inside rides along with it, banner included.
The fix is to offset the wrapper upward by exactly the banner's height. The banner ends up above the viewport while .header lands at the top.
How much work that takes depends on which position the wrapper resolves to:
Attributes
Computed position
Who handles the timing
Sticky only
sticky
The browser. A static negative offset is enough.
Sticky + overlay
fixed
You. Fixed never waits for scroll.
position: sticky waits for scroll natively. position: fixed does not, so a negative offset applied to it takes effect at first paint and the banner would be gone before the visitor scrolls at all.
This guide supports both by publishing two CSS variables and letting each rule use only what it needs.
Step 2: Add the JavaScript
Paste this into the Header Wrapper's JavaScript panel at the very bottom. It is fully self-contained, so it can go anywhere in the file and nothing existing needs editing.
Four details worth understanding:
if (!bannerEl) return; means pages without a banner exit immediately and keep stock behavior.
The requestAnimationFrame throttle collapses scroll events down to one write per frame, since scroll fires far more often than the screen repaints.
The clamp plus the lastY guard means writes stop completely once you are past the banner. You pay for roughly the first 76px of scroll and nothing after.
{ box: 'border-box' } matters because the ResizeObserver default is content-box, which ignores padding and border changes.
Step 3: Replace the Overlay & Sticky Header CSS
Open the Header Wrapper's CSS panel and find the /* ## Overlay & Sticky Header */ section. Use the comment navigation menu at the top left of the CSS editor to jump straight to it.
Replace everything from that comment down to (but not including) /* ### hide on scroll */ with the block below.
// BANNER HEIGHT + SCROLL OFFSET
// Publishes two custom properties:
// --banner-height the banner's measured height
// --header-scroll-y scroll position, clamped to that height
// The sticky rule uses only the first. The fixed rule uses both.
(() => {
const bannerEl = document.querySelector('.header-banner');
if (!bannerEl) return;
const root = document.documentElement;
let bannerHeight = 0;
let lastY = -1;
let ticking = false;
const updateScroll = () => {
ticking = false;
// Clamped, so once you're past the banner we stop writing entirely
const y = Math.min(Math.max(0, window.scrollY), bannerHeight);
if (y === lastY) return;
lastY = y;
root.style.setProperty('--header-scroll-y', `${y}px`);
};
const measureBanner = () => {
bannerHeight = bannerEl.offsetHeight;
root.style.setProperty('--banner-height', `${bannerHeight}px`);
lastY = -1; // force a re-write against the new height
updateScroll();
};
const onScroll = () => {
if (!ticking) {
ticking = true;
requestAnimationFrame(updateScroll);
}
};
if (window.ResizeObserver) {
// border-box so padding/border changes at breakpoints re-measure too
new ResizeObserver(measureBanner).observe(bannerEl, { box: 'border-box' });
} else {
window.addEventListener('resize', measureBanner);
window.addEventListener('load', measureBanner);
}
window.addEventListener('scroll', onScroll, { passive: true });
measureBanner();
})();
/* ## Overlay & Sticky Header */
&[data-overlay-header='true'],
&[data-sticky-header='true'] {
inset-block-start: var(--wp-admin--admin-bar--height, 0px);
}
&[data-overlay-header='true'] {
position: absolute;
}
&[data-sticky-header='true'] {
position: sticky;
}
/* Sticky without overlay.
Position comes from the rule above; this only shifts the offset up by
the banner's height. Sticky handles the timing natively, so
--header-scroll-y is deliberately not used here. */
&[data-sticky-header='true']:not([data-overlay-header='true']) {
inset-block-start: calc(
var(--wp-admin--admin-bar--height, 0px) - var(--banner-height, 0px)
);
/* Optional: reclaim the flow space so content starts at the top of the
page, the way overlay does. Delete this line for normal flow. */
margin-block-end: calc(-1 * var(--header-height, 0px));
}
/* Sticky + overlay resolves to fixed. A fixed box cannot wait for scroll
the way sticky does, so min() supplies that timing from
--header-scroll-y and caps the movement at the banner's height. */
&[data-sticky-header='true'][data-overlay-header='true'] {
position: fixed;
inset-block-start: calc(
var(--wp-admin--admin-bar--height, 0px) -
min(var(--header-scroll-y, 0px), var(--banner-height, 0px))
);
}
&[data-overlay-header='true'][data-overlay-header-offset='true'] + main > :first-child > :first-child {
margin-block-start: var(--header-height);
}