D
P
0
← All articles Baca dalam Bahasa Indonesia

Shopify, Liquid & the Theme CLI

Transparent Overlay Header Covering Headings on Non-Homepage Templates? One Header Behavior Cannot Serve Both Page Types

· · 8 min read
Transparent Overlay Header Covering Headings on Non-Homepage Templates? One Header Behavior Cannot Serve Both Page Types

This theme's header was originally designed as a fixed transparent overlay, specifically for the homepage hero. Not a general purpose header that happened to be used on the homepage, but one genuinely built for that single page. It is position: fixed, so it sits outside the document flow, and the hero starts at the top of the viewport with the header laid over it.

.site-header {
  position: fixed;
  top: 32px; /* below the announcement bar */
  background: transparent;
}

For as long as the work stayed on the homepage, all of it looked right. The background was transparent, the large hero behind it carried the contrast, and once you scrolled past the hero the header turned solid with a glass-blur effect, plus an auto-hide that tucked it away on downward scroll.

Then the client flagged something on the other pages. On the FAQ page and on product pages, that header floated over and overlapped the page heading along with its content. The client flagged it twice. The pattern note I keep records exactly the same symptom and names the FAQ heading specifically.

Why this happens

The cause is not complicated and has nothing to do with mistyped CSS. Only the homepage variant was ever built. My source states it plainly: pages without a full-viewport hero need a solid sticky header with content pushed below it, and one header behavior cannot serve both. The pattern note puts the consequence in a single sentence, if you only build the homepage variant, other pages get content cut off behind the floating header.

The mechanism itself is ordinary CSS behavior. position: fixed takes an element out of the document flow, so no space is reserved for it. The first content in the document still starts at zero, and the header sits in front of it. On the homepage that is exactly what you want, because the only thing being covered is the top of a hero that was left open on purpose. On a page whose first content is a heading, the thing being covered is that heading.

The pattern itself is common in premium themes. The homepage has a hero that fills the viewport, so a transparent overlay that becomes solid past the hero makes sense. Other pages, product, FAQ, content pages, have no hero at all. They need a solid sticky header from the first line, with page content sitting below the header rather than under it.

The fix

The fix is two variants gated by template.name == 'index' in Liquid. The homepage keeps its fixed transparent overlay with scroll-solid and auto-hide, other pages get a static variant class. The gate is resolved once in markup, with a variable set from the template name, and then the class and the attribute are only attached on non-homepage pages.

{%- assign is_home = false -%}
{%- if template.name == 'index' -%}{%- assign is_home = true -%}{%- endif -%}
<header class="site-header{% unless is_home %} site-header--static{% endunless %}" {% unless is_home %}data-static-nav{% endunless %}>

The static variant is sticky at top: 36px with z-index: 98, always solid, no transparent state, no auto-hide. Auto-hide is switched off by neutralizing the transform on the hidden state, so the state may still be set but it no longer produces any displacement. Content is pushed down through a padding-top on the body, triggered by a :has() selector.

.site-header--static {
  position: sticky;
  top: 36px;
  z-index: 98;
}
 
.site-header--static[data-header-hidden] {
  transform: none;
}
 
body:has(.site-header--static) {
  padding-top: 36px;
}

The JavaScript side reads the same attribute and skips attaching the scroll handlers when it is present.

const isStatic = header.hasAttribute('data-static-nav');
if (!isStatic) {
  /* attach scroll handlers for the solid state and auto-hide */
}

So on pages that do not need it, there is no listener measuring scroll direction at all. Not a listener that gets attached and then asked to stay quiet, but one that never gets attached.

Why sticky, why the padding on body, and why :has()

The reason for position: sticky rather than relative is written down in my notes. With static or relative the header scrolls away with the content, while what is wanted is a header that stays visible at the top while the content scrolls. Sticky pins it just below the announcement bar.

The reason the padding-top lives on the body rather than on a content container also comes back to the announcement bar. That bar is position: fixed; top: 0 and takes 36px, so content has to start below it. With a sticky header, a body padding-top achieves that naturally without computing anything in JavaScript.

The reason for :has() is that it lets the parent react to its child's variant without JS. The body does not need to know which template is rendering, it only needs to know that a static header variant exists inside it. My notes put browser support for that selector above 92 percent.

The offset numbers my notes do not agree on

Inside the same pattern note, the static variant is written as top: 36px with a comment saying 36px matches the announcement bar height, while the homepage variant is written as top: 32px. Two numbers for an offset that should come from the same object, a difference of 4px. The project note takes the other side, recording the announcement bar as 32px tall with the header sitting at top: 32px.

I am not going to pretend I know which one is right. The only thing I can read out of it is that the announcement bar height is written down in more than one place, and those places are allowed to disagree without anyone complaining.

A similar small divergence shows up in the wording rather than the numbers. The project spec writes the reporting party as Client, the pattern note writes User, for a report whose content is identical.

The four pixels that covered the header itself

Later, in an incident separate from this one, it was the header that ended up covered by the announcement bar. The diagnosis came from DevTools screenshots the client sent.

The header section file contained two duplicate @media (max-width: 767px) blocks. The first, on line 122, set top: 32px. The second, on line 237, set top: 28px. Because of cascade order, the later one wins, so 28px. Meanwhile the content padding was written as calc(32px + 56px), which assumes a 32px announcement equals a 32px header offset.

The arithmetic runs all the way to the visible symptom. The announcement bar is 32px tall and the header sits at 28px, so 32 minus 28 leaves 4px of the header's top covered by the bar. The header itself carries padding: 8px 14px, and once its topmost 4px is covered, the top padding you actually see is 8 minus 4, which is 4px, while its bottom padding stays at 8px. What the client saw was not those numbers, but a logo sitting tighter to the top than to the bottom.

This fix was eventually removed entirely

The static variant did work for a while. Sticky below the announcement, content pushed down, across content pages, product pages, and collection pages.

Then the requests changed, several times, and the project note records the sequence. The starting state was a transparent overlay on the homepage and sticky-static everywhere else. The next request consolidated every page into one variant, position: sticky; top: 36px with body padding-top: 36px. After that the downward push of content was rejected, because what was wanted was a header joined directly to the announcement bar, so the header moved to position: fixed; top: 36px with no body push at all. The final state is a header that is always fixed and solid with dark text and a dark logo, no auto-hide.

What went out with that change was the entire mechanism above: the static variant class, its marker attribute, the scroll listener for state transitions, and the :has() padding-top on the body. It was replaced by a class on the body tag and a directly computed padding.

<body{% unless template.name == 'index' %} class="page--inner"{% endunless %}>
 
<style>
  body.page--inner { padding-top: calc(36px + 80px); }
</style>

The number is the announcement height plus the header height, and it differs per breakpoint. The one above resolves to 36 plus 80, which is 116px. The two other breakpoints are recorded as 36 plus 72, which is 108px, for mobile, and 32 plus 56, which is 88px, for tablet. The homepage stays an overlay, its header fixed at top: 36px with no push.

The spec had already recorded how this ends. Later client iterations consolidated everything to always-fixed-solid, and what survives as the reusable pattern is the per-template gate.

The lesson

The reuse advice in my notes is a single sentence, and I still agree with it. In any premium theme with a hero-driven homepage, design two header variants from the start, hero-overlay for the homepage and sticky-solid for everywhere else, then switch between them with Liquid template detection.

What survived from this work was indeed not the CSS. The top, padding-top, and z-index values were all pulled out and replaced by the iterations that followed. What survived is the gate, the template.name == 'index' question answered once in markup and then consumed by both CSS and JavaScript, with neither of them needing to know which page is rendering.

And one small thing I carry out of the disagreeing numbers above. The header offset and the content padding are the same number written in two places or more. As long as those places are free to differ with nobody objecting, the difference will sooner or later show up as pixels covering something.