はじめに
俗に 「Visually Hidden」 と呼ばれるCSSテクニックがある。
.visually-hidden {
width: 1px !important;
height: 1px !important;
padding: 0 !important;
margin: -1px !important;
overflow: hidden !important;
clip: rect(0, 0, 0, 0) !important;
white-space: nowrap !important;
border: 0 !important;
position: absolute !important;
}
上記はBootstrapが用意しているCSSクラス .visually-hidden に適用されるスタイルの例(※説明のため一部簡略化したもの)だが、
- サイズは1px四方に強制
- padding, borderは持たせず、-1pxのネガティブマージンでズレを打消し
- overflow: hidden; でハミ出し防止
- clip で 0px四方 にマスク(実質見えない)
white-space: nowrap;で中身の折り返しもさせないposition: absolute;で浮かせて周囲に影響を与えさせない
と念には念を重ねまくって対象の要素を非表示にしている。なぜそこまでして非表示にさせようとこだわるかというと、このクラスが付いた要素はいっけん「見えない」だけで、 Webブラウザやクローラー・スクリーンリーダーのような機械的には「見えたまま」 として扱えるからである。
つまり display: none; や visibility: hidden; を使ってしまって機械的にも「見えない」=「存在しない」と認識されてしまうと困るような見出しや代替テキストを、SEOやデータ構造・アクセシビリティ対応上は置きたいけれど……デザイン上「目に見える」形では置きたくない……という場合に重宝される。
本当に誰にとっても重要だと思うのならそのまま置けばいいと思うかもしれないが、愚直にそうと言ってもいられないのがWebデザインというもの。そのため、現代においても(流石に text-indent: -9999px はやめるべきなど多少の差異はあれど)古くから使われるテクニックである。
……のだが、今回はこの 「見せない」ためのテクニックを使っていたことで生まれた「意図しない余白が見えてしまう」 現象に遭遇したのでここに記しておく。
再現
少し特殊な例だが、Webアプリのような感じで画面(viewport)の高さに応じてサイトのフレーム全体を収める構成を考える。簡単なデモとしては以下のようなコードである。
HTML
<body>
<main>
<div class="frame">
<div class="page">
<div class="scroll">
<!--<h1 class="visually-hidden">(スクリーンリーダー用の隠し見出し)</h1>-->
<section>
<!-- <h2 class="visually-hidden">(スクリーンリーダー用の隠し見出し)</h2> -->
<p>セクション1</p>
</section>
<section>
<!-- <h2 class="visually-hidden">(スクリーンリーダー用の隠し見出し)</h2> -->
<p>セクション2</p>
</section>
<section>
<h2 class="visually-hidden">(スクリーンリーダー用の隠し見出し)</h2>
<p>セクション3(この直前に隠し見出しがある)</p>
</section>
</div><!-- /.scroll -->
</div><!-- /.page -->
</div><!-- /.cap -->
</main>
</body>
CSS ※抜粋
body { /* (省略)背景にストライプイメージを指定 */ }
main {
position: relative;
overflow-x: clip; /* 横方向には絶対にスクロールさせない */
background: #eef2f8; /* 灰色背景 */
}
.frame {
margin: 20px;
height: calc(100vh - 40px); /* フレームの高さ = 100vh - #{margin} * 2(上下分); */
background: #FFF; /* 白背景 */
padding: 16px;
border-radius: 10px; /* 外側の角丸 */
overflow: hidden; /* ハミ出し防止 */
}
.page {
height: 100%; /* フレームいっぱいに高さを広げる = ページとして見える範囲 */
display: flex;
flex-direction: column; /* 縦積み */
}
.scroll {
flex: 1; /* flex: 1 1 0; と同等。ページとして見える範囲いっぱいに広がる */
min-height: 0; /* ないと min-height: auto; (範囲ぴったり)になりスクロールできない */
overflow-y: auto; /* 縦向きにスクロール可能にする */
padding: 12px;
border: 1px dashed #9bb0d0; /* 点線 */
border-radius: 8px; /* 内側の角丸 */
}
ブラウザ上の表示(レンダリング)
上記HTML/CSSにより、 margin: 20px 分が上下の余白として取られた状態のページになる。
(※margin, padding, border-radiusなどの数値は適当なので、今回の原因とは関係ない)
.frame → .page → .scroll の三段階で役割を分けているためややこしく見えるかもしれないが、実際のブラウザ上では内側の点線( dashed )と、内側の padding: 12px; で囲んだ部分を縦方向にスクロールできるだけのシンプルな構造である。

(※スクリーンリーダー用の隠し見出し については、隠したままだと説明としてわかりにくすぎるので、図中では セクション3 のものだけ意図的に表示させている)
本来 .visually-hidden による隠し要素はページの中身のどこにあっても閲覧上の問題は起きないはずだが、画面の高さが狭くなると見切れてしまうような位置……以下でいうと セクション3 に当たる部分に隠し見出しがあった場合に、今回の現象が発生する。
逆に言うと、ページ内の要素すべてが画面内に入り切るような内容量・広い画面では発生しない。

(※画面の縦幅が低い場合、セクション3以降が見切れる)
このような縦が狭い画面で セクション3 を見たい場合はもちろん縦向きのスクロールが発生するのだが、 .page の中身が最後まで見える位置= .scroll 範囲の末尾まで来てもなぜか (この直前に隠し見出しがある) の上に置かれているはずの隠し見出しがそのセクション内に存在しない。
それどころかページとして想定しているスクロール範囲 .scroll を超えたところまでスクロールできてしまい、内包セクションよりはるか下に隠し見出しが存在してしまっていることがわかる。

ところで本来の隠し見出しは見えないので、ブラウザ上には何もないのに <body> の下に余白が出るような状態になってしまい、「見えない要素で目に見える余白」が発生していることになる。

(※実際のブラウザ画面のスクショ+DevToolsによる <body> のフォーカス)
しかもなんなら <body> の範囲からもはみ出してしまっている……おわかりいただけただろうか。
原因と解説
スクロール領域の超過
原因としては冒頭でふれた「Visually Hidden」テクニックのうち、最後に触れた以下による。
position: absolute;で浮かせて周囲に影響を与えさせない
absolute(絶対)というその名の通り、 position: absolute; は適用した要素を浮かせることで絶対的な配置を可能にするため、本来は他要素のフローに影響せず配置(out-of-flow)することができるプロパティ。この特徴によりどこに置いても <body> や .frame の大きさは広がらず、隠し見出しがあることでサイズがズレたりすることはない。
ただページ全体においても「影響を与えさせない」かというと例外が存在し、以下の図のように overflow: visible; の状態で<body> やビューポートから「下・右」方向にはみ出す場合は、 はみだした分だけの領域を自動的に広げたうえで表示されようとする挙動になる。
このスクロールが必要なはみ出し領域は「scrollable overflow」と呼ばれ、いわゆる「意図しないスクロールが発生してしまう」のはこの仕様が原因であることもほとんど。

https://www.w3.org/TR/css-overflow-3/#scrollable-overflow-region
たとえ絶対配置 position: absolute; の要素であっても外側が overflow: visible; (デフォルト)のまま縛られなければブラウザは見せてあげようとスクロールを設けてくれる。それだけを聞くとありがたい仕様のはずなのだが、大抵の場合は「なんか知らんけど横とか更に奥までスクロールできちゃう」だけなので迷惑な話である。
せめて憂慮すると、この仕様自体は要素がページ上に「存在する」以上は、ユーザがそれの全体を見られない状況はあるべきでないという考えによるものかと思われる。
ただし今回はそのはみ出した要素が .visually-hidden なので、開発者側が 「(意図して)見せていない」要素をブラウザは「(はみだしているので)見せよう」としてしまい、結果的にユーザにはからっぽの余白だけが目に見えてしまう 状態になってしまっていた。
隠し見出しが押し出された位置
そもそも今回はハミ出し防止の overflow: hidden; に関しては .frame できちんと指定している。合わせて height も画面(ビューポート)内に必ず収まるよう指定しているので、本来隠し見出しが <body> の彼方にさぁ行くぞと飛び出すことはあり得ないはずである。
/* 必要な部分のみを抜粋 */
.frame {
height: calc(100vh - 40px); /* フレームの高さ = 100vh - #{margin} * 2(上下分); */
overflow: hidden; /* ハミ出し防止(だが効かない?) */
}
そうなると、何故 セクション3 の内部にいるはずの隠し見出しが <body> の外に飛び出してしまっているのか?と疑問が浮かぶが、この理由は以下の2つの仕様が組み合わさったものになる。
posiiton: absolute;位置調整の基準となるposition: relative;が直近に存在しなかった場合、その要素の位置は「最も近い祖先」を基準に配置される- かつ明確な位置(
top/right/bottom/left)の指定がない=autoの場合、要素は「本来フローで居たはずの位置(=静的位置)」に置かれる
つまり現状のコードでは <main> にしか position: relative; がないため、隠し見出しである .visually-hidden の position: absolute; は直近の親である .scroll → .page → .frame の3段階すべてを飛び越えてしまい、 main を基準に配置されてしまっていることだった。
/* 必要な部分のみを抜粋 */
main {
position: relative;
overflow-x: clip; /* 横方向には絶対にスクロールさせない(※縦方向はクリップされない) */
}
HTML(DOM)構造上だと隠し見出しは .frame の中にいるが、CSS上は外側 <main> を基準に配置されているため .frame 内部のルールではそもそも制御できないようになっているというのが、ややこしいけれど根本的な原因になる。図示するとなると以下のようなカンジ。

※ざっくりClaudeに作ってもらった図なので細かい専門用語っぽい部分については御愛嬌。
なお配置基準の位置を明確にするため簡略化しているが、実際のブラウザ上における <main> のレンダリングサイズは .frame の下辺すぐまでとして扱われるので、実際の .visually-hidden 自体は <main> だけでなく <body> からもはみ出してしまったように見えてしまう。
このような関係のため、 .frame でいくら overflow: hidden; してもCSSのルール上そもそも .visually-hidden は別の親 <main> に帯同しているのでその影響を受けない。かつ .frame は以下の .page を内包しており画面の範囲いっぱいに広がるせいで、 「内側にあるはずの要素を画面の外側まで押し出してしまう」 という一人相撲がしめやかに行われていたことが原因だった。
.page {
height: 100%; /* フレームいっぱいに高さを広げる = ページとして見える範囲 */
display: flex;
flex-direction: column; /* 縦積み */
}
この一見バグのような決まり手は画面を埋め尽くすほど恰幅のよい .page 関の影響によるものなので、そもそも .page の内容量が大きい画面内に収まっていれば .visually-hidden が押し出されることもなく余白も生まれない。
ただし 内容量というものは基本増えて然るべき ものなので、 セクション4 セクション5 が増えるだけでビッグスクリーン上でも同じ現象が再発し、むしろ余白がどんどん広がっていくことに……。
ページ内の要素すべてが画面内に入り切るような内容量・広い画面では発生しない。
という条件は、今回の構成に限って見事に決まったミラクルコンボだった。ごっつぁんです。
対策
起こっている現象としてはとてつもなくややこしく見えるのに、対策は極めてカンタンである。
更に外側から overflow を縛る
main {
position: relative; /* 基準位置 */
overflow: clip; /* またはhiddenで絶対にスクロールさせない(※縦も横もクリップ) */
}
今回はそもそも内部でスクロールする構成なので、<main> をX方向だけではなくて overflow: hidden; // clip; にしておけばよかっただけの話ではある。
ただ <body> や <main> のようなデカい要素に対して overflow という制約をかけるのは限られた機会にしておきたい気もするので、X方向ぐらいしか勇気が出なかったのも事実としてはある。
※余談だが、スクロール制御目的でも <body> に overflow: clip; をかけるのはFirefox的にやめておいた方が今のところは良さそう。
https://qiita.com/m_shinada/items/4b17036c53a63f11ab49
そのため、もっと身近な位置での対策としては……。
こまめな基準位置の更新を忘れない
最も簡単なのは隠し見出しに最も近い セクション3 つまり <section> に以下を書くだけである。
section {
position: relative; /* 基準位置の更新 */
}
これだけで .visually-hidden の位置は本来 <section> 内の自然なフロー=HTML上とほぼ同じ位置に留まるようになるので、必ず画面や親要素からはみ出すことがなくなる。
<main> の下までスクロールさせたくないだけ=見出しが下に来るのが嫌であれば直接 top: 0; を指定することなどでも解決はできるが……好き好んで「絶対に人には見せたくない」子要素の位置をわざわざ決めるほどおかしなことはないので、親どうしで解決するのがスジである。
また、おそらくわざわざ言われなくても position: absolute; を多用するサイトでは至る所に基準位置の更新として position: relative; の指定がセットになっていることかとは思うが……。無駄に多用してしまうとスタッキングコンテキストがバンバン重なってしまうので、めくるめく z-index との戦いの火蓋が切って落とされてしまう可能性になってしまうのも注意が必要。
あくまで見出しが必要(だけど見せたくはない?)セクションを区切るような場面であれば、 position: relative; の指定を忘れないようにするのがセーフティな運用かもしれない。
感想
そもそも「Visually Hidden」が開発者目線にとっても隠れている のが解決に時間がかかった原因なので、見出しにはなるたけ使わない・無理に隠さないよう進められるといいなぁと思いました。