Symptom
A feed inside position:fixed; inset:0; overflow-y:auto with scroll-snap-type: y mandatory opened already scrolled past the first card in Firefox. Chrome and iOS Safari landed at offset 0. Permalinks that pin an item to the top showed the second item instead; the back-to-top button and logo bounced straight back down. JS never scrolled — scrollTop was written as 0 and the UA moved it afterwards.
Measured on the real page: #page-scroll.scrollTop settled at 848 in Firefox, 0 in Chromium, same HTML/CSS/JS.
Root cause
Per css-scroll-snap, the resting offset of a start-aligned snap area is:
scrollTop = areaTop - scrollMarginTop - scrollPaddingTopThe layout had the usual sticky-header pattern:
.scroller { scroll-snap-type: y mandatory; scroll-padding-top: 86px; }
#page-top { scroll-snap-align: start; } /* zero-height sentinel at offset 0 */
.card { scroll-snap-align: start; }
.card[data-index="0"] { scroll-snap-align: none; } /* deliberate opt-out */The sentinel sits at areaTop = 0, so its rest point computes to -86px — outside the scroll range.
- Blink clamps that into
[0, maxScroll], so 0 stays a valid snap position and the top rest point works. - Gecko discards the out-of-range position instead of clamping it.
With the sentinel dropped and card zero deliberately opted out, the first reachable snap position in Firefox was card one — and scroll-snap-type: mandatory is obligated to sit on a snap position, so every arrival was pulled there. Any programmatic scrollTo({top:0}) was re-snapped away for the same reason.
Fix
Cancel the padding on the sentinel so its rest point is exactly 0, in range for both engines:
:root { --snap-pad: 86px; }
.scroller { scroll-padding-top: var(--snap-pad); }
#page-top {
scroll-snap-align: start;
scroll-margin-top: calc(-1 * var(--snap-pad)); /* rest point 0, not -86 */
}Keep the two values in one custom property — they are only correct together.
Equivalent alternative if you prefer no negative margin: move scroll-snap-align: start onto the first in-flow element below the sticky header, whose areaTop already equals the padding (86 - 86 = 0). Verified working too, but it couples the rest point to DOM order and to the header height matching the padding exactly.
Things that did NOT fix it (measured, not guessed)
- Giving the sentinel real height (
height:1px; margin-bottom:-1px) — still 683 in an isolated repro. Zero height is not the problem; the out-of-range offset is. scroll-snap-type: y proximity— still snapped away. Proximity does not rescue an unreachable offset.- Disabling scroll anchoring (
layout.css.scroll-anchoring.enabled=false) — no change. Noteoverflow-anchor:noneonhtmldoes not cover a new element scroll container, so it is easy to misattribute this to anchoring.
Why it slips through review
The symptom looks like broken application logic ("the pin didn't apply", "scroll restoration replayed a stale offset"), so you go hunting in JS. The tell that it is snap, not JS: the item is in the DOM at index 0 with the right content, and scrollTop is non-zero without any code having written it. Reproduce in a standalone HTML file with no app code — 40 lines of CSS plus a JS loop that appends the children — before touching the application.
Reproducing Firefox headlessly (no geckodriver / no Playwright)
firefox -screenshot fires at the load event and will race an async mount, silently reporting a false pass. Two things make it deterministic:
- Put the measurement on screen in large text and screenshot it, or beacon it out with
new Image().src = 'http://127.0.0.1:PORT/report?...'(an<img>GET dodges CORS entirely). - Hold the
loadevent past your sample with a slow subresource:<script src="http://127.0.0.1:PORT/slow.js"></script>where the local serversleeps ~9s before responding.
For a cross-origin production page, load it in a same-origin iframe from a probe page served by your own dev server and read iframe.contentDocument.getElementById('scroller').scrollTop. Also suppress Firefox's first-run tab (browser.aboutwelcome.enabled=false, browser.startup.homepage_override.mstone="ignore" in the throwaway profile's user.js) — otherwise your page loads in a background tab, timers are throttled, and nothing reports.
Rule of thumb
Whenever you combine scroll-padding-* with a snap area at the very start or very end of a scroll container, compute the rest point by hand. If it falls outside [0, maxScroll], add the matching negative scroll-margin-*. The same reasoning applies at the bottom edge: a trailing start-aligned area whose rest point exceeds maxScroll is unreachable in Gecko too.