Skip to content

Fix layout shift on home page load (font swap + menu band hydration)#910

Open
RisingOrange wants to merge 5 commits into
PauseAI:mainfrom
RisingOrange:fix-font-swap-layout-shift
Open

Fix layout shift on home page load (font swap + menu band hydration)#910
RisingOrange wants to merge 5 commits into
PauseAI:mainfrom
RisingOrange:fix-font-swap-layout-shift

Conversation

@RisingOrange

@RisingOrange RisingOrange commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

Fixes the layout jump on home page load: the orange menu band grows shortly after first paint, pushing the hero photo and everything below it down.

Causes

Two stacked issues, measured with a Playwright probe that samples element rects across the load (the numbers below are the measured top of the hero photo, i.e. the orange band height, at a 1280px viewport):

  1. --menu-orange hydration mismatch — the jump you see with a warm font cache. The orange band behind the menu defaults to 128px (desktop) / 280px (mobile) in CSS, but after hydration JS measures the real nav and sets 142px / 180px / 213px (depending on menu wrapping). The nav itself is absolutely positioned at the top, so it doesn't move — the band edge, photo and all content below jump down 14px on desktop (and up 67px on small mobile).
  2. Webfont swap — additional jumps on a cold cache. All fonts load with font-display: swap, so the first paint uses fallback fonts (Impact/serif) whose metrics differ a lot from Saira Condensed and Roboto Slab. The band goes 128px → 171px (hydration measures the fallback-font nav) → 142px (webfonts arrive, re-measure), with all hero/nav text reflowing along the way. Preloading doesn't prevent this — paint isn't blocked on fonts.

Fix

  1. The nav now sits in normal document flow inside an orange .menu-band wrapper instead of being absolutely positioned and measured by JS. The band is therefore exactly as tall as the menu content — in every locale and at every viewport width — and the JS measurement, the ResizeObserver, and all hardcoded band heights are gone. The band height matches what production renders after its JS correction (142/180/213px at desktop/tablet/phone).
  2. fontaine generates "<font> fallback" @font-face rules whose size-adjust/ascent-override/descent-override metrics match the webfonts, and the --font-body/--font-heading variables now include them. The fallback lists include Tinos, Arimo and Noto alongside Georgia/Times/Arial so src: local() also resolves on Linux, where the Microsoft fonts don't exist.

Notable details:

  • .menu-band carries position: relative; z-index: 1 so the language-switcher dropdown (rendered on multi-locale builds, opens downward past the band) paints above the hero — the old absolute nav had z-index: 1, and the in-flow restructure would otherwise have lost that.
  • The navbar's wrap spacing moved from a child margin-bottom to row-gap on the nav; nav boxes on all pages lose 4px of stray trailing space the old hack added.
  • fontaine emits one fallback @font-face per src URL × unicode-range subset — 186 metric-identical copies. postcss-discard-duplicates (wired into Vite's css.postcss) collapses them to the 15 unique rules, shrinking the render-blocking layout CSS from 77K to 44K.
  • The hero's width: 100vw + translateX(-50%) full-bleed trick is gone (its parent is already full-bleed, and 100vw overflowed by the scrollbar width), and the shared hero orange is named --hero-orange.

Before

Jump 1 — hydration band correction (fonts cached; this is the commonly seen jump). Nav stays put, the orange band grows 14px and the photo + content shift down:

Before: pre-hydration, band 128px Before: hydrated, band 142px — photo and content shifted down
pre-hydration — band 128px hydrated — band 142px, content shifted down

Jump 2 — font swap (cold cache; webfonts blocked to hold the fallback state). Text reflows and the band overshoots to 171px before settling at 142px:

Before: fallback fonts, band 171px Before: webfonts loaded, band 142px — text reflowed
fallback fonts — band 171px webfonts — band 142px, text reflowed

After

The photo edge sits at the same position in every state — pre-hydration, hydrated, fallback fonts, webfonts. Hardest case shown (cold cache): the fallback glyphs differ slightly, but nothing moves when the webfonts swap in:

After: fallback fonts — same layout After: webfonts — identical layout
fallback fonts webfonts — identical layout

Verification

  • Playwright probe, band height across load states at 1280px: before 128 → 142 (warm cache) and 128 → 171 → 142 (cold cache); after: constant in all four states (142px at 1280px, 180px at 700px, 213px at 375px — matching production's settled values), with hero rects identical across the font swap at all three viewports.
  • pnpm check passes; pnpm build succeeds, the built CSS contains the generated fallback rules, and the duplicate-rule dedupe is confirmed in the output (186 → 15 fallback @font-face rules).
  • CI (pnpm check + pnpm lint) passes.

Two stacked causes:

1. Webfonts use font-display: swap, so the page first paints with
   fallback fonts (Impact / serif) whose metrics differ a lot from
   Saira Condensed and Roboto Slab. When the webfonts arrive, the nav
   and hero text reflow and everything below jumps (~36px at 1280px).
   Fixed with fontaine, which generates '<font> fallback' @font-face
   rules with size-adjusted metrics. The fallback lists include
   Tinos/Arimo/Noto so src:local() also resolves on Linux.

2. The pre-hydration --menu-orange defaults (128px / 280px) didn't
   match the band height JS measures after hydration (142px / 180px /
   213px depending on menu wrapping), so the orange band behind the
   menu jumped again once hydration ran. Defaults now match the
   measured values per breakpoint.

Verified with a Playwright probe (fonts delayed 2s): hero, slogan and
campaign rects are now identical before and after the font swap at
1280px, 700px and 375px viewports; previously the hero shrank 36.2px
and then grew 14px.
@netlify

netlify Bot commented Jun 10, 2026

Copy link
Copy Markdown

👷 Deploy request for pauseai pending review.

Visit the deploys page to approve it

Name Link
🔨 Latest commit 2ba2995

RisingOrange and others added 4 commits June 10, 2026 17:41
The previous commit hardcoded the band's pre-hydration heights
(142/180/213px), but those were measured for English nav labels —
other locales wrap the menu differently, so the hydration correction
would still jump there.

Restructure instead: the nav now sits in normal document flow inside
a .menu-band wrapper, so the orange band is exactly as tall as the
menu content in every locale and at every viewport width. This
removes the JS measurement + ResizeObserver and all hardcoded band
heights; the photo, scrim and slogan paddings no longer need the
--menu-orange offset.

Side effect: the band is ~5px taller than the JS-measured value used
to be (the nav's own box includes child margins the measurement
excluded).

Verified: photo-top/hero rects identical across pre-hydration,
hydrated, fallback-font and webfont states at 1280/700/375px.
The in-flow band was ~5px taller than the old JS-measured value
because the nav's children carry a 0.25rem wrap margin that now lands
inside the band. Compensate in the band nav's bottom padding so the
menu sits optically centred and the band height matches production
(142/180/213px at desktop/tablet/phone).

Also remove rules the restructure made redundant: the hero's
100vw/translateX full-bleed trick (its parent is already full-bleed,
and 100vw overflows by the scrollbar width) and the hero-section's
position: relative (nothing anchors to it anymore).
- Give .menu-band position:relative + z-index:1 so the language
  switcher dropdown (absolute, opens downward past the band) paints
  above the .hero sibling on multi-locale builds. The old absolute
  nav carried z-index:1; the in-flow restructure had lost that.
  Remove the now-ineffective z-index from .inverted-header (static
  position ignores it).
- Replace the navbar's nav > * { margin-bottom: 0.25rem } wrap-gap
  hack with row-gap on the nav itself. This makes the band's
  padding compensation unnecessary (band heights unchanged:
  142/180/213) and drops 4px of stray trailing space from nav boxes
  on all pages.
- Name the shared hero orange (--hero-orange) instead of duplicating
  #ff9416 in the band and the slogan backdrop.
- Add postcss-discard-duplicates: fontaine emits one fallback
  @font-face per src URL x unicode-range subset, all metric-identical
  copies; deduping collapses 186 rules to 15 and shrinks the
  render-blocking layout CSS from 77K to 44K.
- Drop position:relative on .hero (nothing anchors to it).
@RisingOrange RisingOrange marked this pull request as ready for review June 10, 2026 20:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant