Astro scoped CSS silently skips child components
Short version: Astro compiles .foot__links a into
.foot__links[data-astro-cid-jo6i4kqk] a[data-astro-cid-jo6i4kqk] — the scope
id is added to every compound selector, not just the first. An anchor
rendered by a child component carries that child’s id instead, so the rule
misses it entirely. Nothing errors and nothing warns.
In our footer that costs a link its tap target: 44px down to 16px, which clears neither WCAG 2.2 target-size criterion — not SC 2.5.8 Target Size (Minimum), 24×24 CSS px at AA, and not SC 2.5.5’s 44×44 at AAA, which is the floor this codebase sets for itself.
The symptom: an 11px nudge in the footer
The footer holds three items — an email link, an RSS link, a copyright. The
email one is rendered by EmailLink.astro; the RSS one is a plain anchor
written into Footer.astro. One rule is meant to size both:
.foot__links a {
display: inline-flex;
align-items: center;
min-height: 44px;
}
It matches the RSS link. It does not match the email link. Both are anchors,
both are inside .foot__links, and one of them is invisible to the selector.
To measure the difference I took the shipped build, deleted the :global() half
of the rule that fixes this from the compiled CSS, and served both versions.
Chrome 151 at 1280px wide:
| Footer item | display |
Height | Centre Y |
|---|---|---|---|
| Email link, rule as written above | inline |
16px | 1312 |
| RSS link | inline-flex |
44px | 1323 |
An 11px difference in vertical centre, which is about what “slightly off” looks like. The height column is the actual story: one of those is a 44px tap target and the other is a line of 16px text pretending to be a link.
Being straight about provenance: this repo’s first commit already carries the fix and a comment explaining it, so the broken state isn’t in the history. It was found and patched during the same session that wrote the footer, and the table above comes from reintroducing it deliberately. What makes it worth writing up is that nothing about that catch was systematic — I noticed a row looked uneven.
Why a scoped selector misses the child’s anchor
Astro scopes a component’s styles by stamping a data-astro-cid-* attribute on
the elements that component renders, then rewriting its selectors to require
that attribute. The styling guide documents the mechanism and
the guarantee it buys — “scoped styles are compiled behind-the-scenes to only
apply to HTML written inside of that same component” — with an example
compiling h1 to h1[data-astro-cid-hhnqfkh6].
What the docs show is a single-element selector, which is where the intuition goes wrong. Reading a two-element selector out of the built CSS instead of trusting the description — this is the whole rule as shipped, Astro 7.1.6:
.foot__links[data-astro-cid-jo6i4kqk] a[data-astro-cid-jo6i4kqk],
.foot__links[data-astro-cid-jo6i4kqk] .email {
align-items: center;
min-height: 44px;
display: inline-flex;
}
The attribute appears twice in the first selector. That’s the part that
catches people out — it’s easy to assume scoping works like a prefix, bounding
the rule to a subtree, so that anything inside .foot__links would qualify. It
doesn’t. Every compound selector in the chain has to match an element the
component itself rendered.
Now the served markup, with the address spans inside the anchor omitted:
<ul class="foot__links meta" data-astro-cid-jo6i4kqk>
<li data-astro-cid-jo6i4kqk>
<a class="email" role="link" tabindex="0" data-email data-astro-cid-pq4ue42j>
…
</a>
The <ul> and the <li> carry jo6i4kqk, because Footer.astro wrote them.
The <a> carries pq4ue42j, because EmailLink.astro wrote it. So
a[data-astro-cid-jo6i4kqk] has nothing to match, and the second selector —
the :global(.email) half, which compiles to a bare .email compound with no
attribute on it — is what actually does the work.
The boundary isn’t the DOM tree. It’s authorship.
The cosmetic fix that would have buried it
The only visible symptom of this bug was misalignment, and there is a perfectly reasonable fix for misalignment that has nothing to do with scope boundaries, child components, or tap targets:
.foot__links {
align-items: center;
}
Centre the flex items and the 11px goes away. I re-measured the built page with
align-items: center present but the :global(.email) half of the selector
removed:
| Footer item | display |
Height | Centre Y |
|---|---|---|---|
| Email link | inline |
16px | 1322 |
| RSS link | inline-flex |
44px | 1323 |
Centres one pixel apart. It looks fixed. It is not fixed — the tap target is still 16px, and now there is no symptom left to lead anyone back to it.
That’s the trap in miniature. The accessibility regression had exactly one outward sign, the sign was cosmetic, and the natural cosmetic fix removes the sign while leaving the regression in place. Had the alignment been the thing that got addressed, the 16px target would still be there, permanently, with nothing on the page to suggest otherwise.
For completeness, the rule as it actually ships:
| Footer item | display |
Height | Centre Y |
|---|---|---|---|
| Email link | inline-flex |
44px | 1323 |
| RSS link | inline-flex |
44px | 1323 |
align-items: center is still there and still earns its place — the copyright
<span> is plain 16px text and genuinely does need centring against two 44px
neighbours. It just wasn’t the fix.
Three places a scoped selector misses in this repo
A scoped selector failing to match isn’t one anecdote; it’s a category. Anywhere the element you’re styling was not written by the component doing the styling, the scoped selector misses. Three of them in this repo:
An element rendered by a child component — the case above.
.foot__links :global(.email), using Astro’s :global() escape
hatch to drop the scope attribute from that compound.
An element created by script after load. GameEmbed.astro builds its
iframe in a click handler, so the element never passes through Astro’s
compiler and never receives a scope id:
frame.className = 'embed__frame';
/* Set on the element created above, so it is out of this component's scope. */
.embed :global(.embed__frame) {
width: 100%;
height: 100%;
border: 0;
}
Markdown injected via <Content />. Every rule styling post bodies in
blog/[...slug].astro is :global, for the same reason — the <h2>, <p> and
<pre> elements come from the markdown renderer, not from the page component:
.post__body :global(pre) { /* … */ }
.post__body :global(:not(pre) > code) { /* … */ }
That third one is instructive because nobody gets it wrong. Markdown styling fails so immediately and so totally — every paragraph unstyled — that you fix it in the first thirty seconds and never think about it again. The failure is loud, so it teaches nothing.
The child-component case is the same mechanism with one element instead of fifty, and that’s the entire difference between a bug you fix instantly and a bug that ships.
Which side of the boundary the fix belongs on
Instead of reaching for :global, the 44px min-height in Footer.astro could
have moved into EmailLink.astro, where it would be in scope and no escape
hatch would be needed. That’s tempting, and it’s wrong
here: the same component is used mid-sentence in prose, where the link has to
stay display: inline and flow with the text. A 44px flex box in the middle of
a paragraph is worse than the bug.
So the sizing stayed in the consumer. :global is not the smell in that rule —
the smell would be a component that dictates its own layout to every context
that embeds it. The scope boundary is real and mostly right; the question each
time is which side of it a decision belongs on, and “how big is the tap target
in the footer” is a footer question.
Why there’s no warning, and why that’s still expensive
Astro can’t reasonably warn here. A selector matching nothing is legal CSS, it’s
frequently intentional, and the compiler has no way to know whether the element
you meant exists in some other component, some other page, or not yet. This very
repo would trip such a warning legitimately — the markdown rules in
blog/[...slug].astro match nothing at compile time and everything at render
time.
Which leaves the cost with you. A rule that crosses a component boundary is a claim about markup you didn’t write, and the only way to know it landed is to check the output — grep the built CSS for the selector, or measure the element in the rendered page. Reading the source tells you what you intended.
Chase the cause, not the visible symptom
The failure mode here isn’t “scoping is confusing.” It’s that a CSS rule which
matches nothing is indistinguishable, from every vantage point except the
rendered page, from one that works. It’s valid. It’s in the bundle. It survives
review, because review reads source. Nothing about .foot__links a looks like a
question.
And the sharper lesson is about which symptom you chase. This bug had two consequences — one cosmetic and visible, one accessibility-related and invisible — and they shared a single cause. The visible one was the only thread back to the invisible one, and the obvious fix for the visible one cuts that thread. When something looks slightly off after a refactor, it’s worth one minute asking why it’s off before nudging it back into place. The misalignment was not the problem. It was the only evidence.
Same species as a drop-shadow on this site that had never once rendered and a Phaser boot splash whose two comments both described the wrong thing: present, valid, doing nothing, invisible until something was put next to it.
Astro 7.1.6 — the data-astro-cid-* naming and the per-compound rewriting are
compiler behaviour, documented in the styling guide but not
pinned by it, and could move between majors. The scope ids themselves are
content hashes and change whenever the component does, so don’t copy the ones
above; grep your own dist/ for the selector you care about.