Files
fusion/docs/solutions/ui-bugs/visually-hidden-unoffset-inflates-mobile-scrollwidth.md
gsxdsm 7040cd3511 docs: capture mobile .visually-hidden scrollWidth bug in solutions
Document the root cause (offset-less position:absolute sr-only utility
escaping the kanban board's overflow clip and inflating
documentElement.scrollWidth → iOS shrink-to-fit zoom), the top/left:0 fix,
and the CSS-fixture regression guard. Cross-links the sibling mobile
viewport-containment learnings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 17:26:12 -07:00

8.3 KiB
Raw Blame History

title, date, category, module, problem_type, component, symptoms, root_cause, resolution_type, severity, related_components, tags
title date category module problem_type component symptoms root_cause resolution_type severity related_components tags
Unoffset .visually-hidden inflates mobile scrollWidth and breaks kanban scroll 2026-06-13 ui-bugs packages/dashboard/app/styles.css ui_bug frontend_css
Mobile kanban board would not scroll left/right cleanly on iOS Safari
Dragging near the bottom slid whole columns off-screen
Table/list view rendered cut off and zoomed out, even though its own layout was clean
documentElement.scrollWidth measured ~1388px on a 390px viewport
mobile_viewport_containment css_fix high
packages/dashboard/app/__tests__/dashboard-overflow-containment.test.tsx
packages/dashboard/app/__tests__/mobile-horizontal-pan-containment.test.ts
mobile
viewport
overflow
visually-hidden
sr-only
containing-block
ios-safari
kanban-board

Unoffset .visually-hidden inflates mobile scrollWidth and breaks kanban scroll

Problem

A .visually-hidden (screen-reader-only) utility positioned absolute with no offsets sat at its static-flow position — off-screen-right inside the horizontally-scrolled kanban columns — and, because no ancestor was its containing block, escaped the board's overflow clipping and ballooned documentElement.scrollWidth to ~1388px on a 390px viewport. On iOS Safari this triggered a persistent shrink-to-fit zoom-out and let the whole page pan columns off-screen. Desktop was unaffected.

Symptoms

  • On a mobile (390px) viewport, document.documentElement.scrollWidth measured ~1388px while clientWidth stayed 390px.
  • iOS Safari zoomed the page out (shrink-to-fit) despite maximum-scale=1, user-scalable=no.
  • The zoom-out persisted after navigating to List view, making List look zoomed even though its own layout was clean (scrollWidth 390).
  • The over-wide document allowed the entire page to pan horizontally, dragging kanban columns out of the viewport.

What Didn't Work

The bug initially looked like a problem with the visible kanban columns — the natural assumption being that the .board flex scroller or the .column widths were leaking past the viewport. Setting .column { position: relative } did fix the measurement (scrollWidth → 390), proving the columns' containing block was implicated — but it was rejected as the fix: it only patches the board, would need repeating for lane mode and any future horizontal scroller, and treats the symptom rather than the cause.

The List view was a second red herring. It never overflowed (scrollWidth 390); it only appeared zoomed because iOS retains the board's shrink-to-fit zoom state across in-app navigation. Don't chase the List-view layout or the column widths — neither is the cause.

Solution

The culprit was the shared .visually-hidden utility in packages/dashboard/app/styles.css (~line 49). Pin it to the origin with top: 0; left: 0.

Before:

.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

After:

.visually-hidden {
  position: absolute;
  /* Pin to the containing block's origin. Without offsets an absolute box
     renders at its static-flow position; inside a horizontal scroller (e.g.
     the kanban board) that position is off-screen-right, and because the
     scroll container isn't a positioned containing block its overflow can't
     clip the span — so it balloons documentElement.scrollWidth and triggers
     iOS shrink-to-fit zoom-out + whole-page panning on mobile. */
  top: 0;
  left: 0;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

With the pin, the span's box stays 1px×1px (computed top/left = 0, still clipped and invisible) and documentElement.scrollWidth returns to 390. One global edit fixes the utility everywhere — board, lane mode, and any future horizontal scroller — with zero a11y or visual change. Shipped in commit 3cc82bdc4.

Why This Works

  1. No offsets → static-flow position. A position: absolute element with no top/left/right/bottom is painted at its static-flow position — where it would have sat in normal flow. So each hidden span inherited the x-position of its parent column.
  2. The columns' static positions are off-screen-right. .board is a horizontal flex scroller (overflow-x: auto); its 6 .column children lay out from x≈12 to x≈1872 at a 390px viewport. Columns past the fold sit at x≈1300+, and so do the hidden spans inside them.
  3. Overflow only clips descendants in its own containing block. An ancestor's overflow clips an absolutely-positioned descendant only if that ancestor is the descendant's containing block — the nearest ancestor with position != static (or one otherwise establishing a containing block). Both .column and .board were position: static, so the spans' containing block was the initial containing block (<html>), not the board. The board's overflow-x: auto therefore could not clip them.
  4. The document grew, not the board. Unclipped, the spans extended documentElement.scrollWidth to ~1388px while <body>/viewport stayed 390px.
  5. iOS shrink-to-fit. On iOS Safari, maximum-scale=1, user-scalable=no is ignored, and an over-wide document triggers an automatic shrink-to-fit zoom-out. That zoom state persists across in-app navigation (hence List view looking zoomed), and the over-wide document also makes the whole page pannable.

Pinning top: 0; left: 0 overrides the static-flow position and parks the span at its containing block's origin, so it can no longer push the document width — while the existing width:1px / clip / overflow:hidden keep it visually hidden and accessible exactly as before.

Prevention

  • Treat offset-less position: absolute sr-only utilities as unsafe inside scroll containers. Any visually-hidden / sr-only pattern that is position: absolute with no top/left floats to its static-flow position; inside a horizontal scroller that position can be off-screen and will widen the document. Pin such utilities to the origin (top: 0; left: 0), or guarantee every scroll container establishes a containing block. Pinning the utility is preferred — one edit, can't regress per-container.
  • Add a CSS-fixture regression assertion. This repo guards layout invariants with static CSS-text tests (packages/dashboard/app/test/cssFixture.ts → loadAllAppCss()), not layout measurement — see mobile-horizontal-pan-containment.test.ts and dashboard-overflow-containment.test.tsx. The matching guard here is an assertion that the .visually-hidden rule block contains top: 0; and left: 0; (or otherwise pins its position). That style of test would have caught this regression.
  • jsdom cannot catch it by measurement. jsdom has no layout engine, so scrollWidth is always 0 — a runtime-measurement test passes while the bug ships. Real-layout verification needs a browser/Playwright assertion: at a mobile viewport (e.g. 390×844), document.documentElement.scrollWidth <= document.documentElement.clientWidth.
  • Manual check after any horizontal-scroller change: load at 390px and compare documentElement.scrollWidth vs clientWidth; a gap means something escaped the scroll container's clip.
  • Mobile document horizontal pan containment — sibling fix to the same "document must stay at horizontal offset zero" invariant, via root-chrome touch-action/overflow-x (FN-6365). Its containment contract did not catch a stray absolutely-positioned descendant escaping a non-positioned scroll container — which this doc explains.
  • Mobile board iOS horizontal overscroll containment — same component + iOS Safari, different mechanism (overscroll-behavior-x: contain rubber-band, FN-6378).
  • Mobile auto-merge toggle document scroll blank — same failure class (unintended mobile document horizontal scroll), different trigger (FN-6243).
  • Commit 3cc82bdc4 — fix: lock mobile board to viewport by pinning .visually-hidden (changeset .changeset/mobile-board-sr-only-overflow.md).