Rebounder Tech Blog

Written by the people who actually run these systems in production.

A Leftover sticky top:0 Makes a fixed Bar Fill the Screen

公開 読了時間 約4分執筆: Rebounder 開発チーム(当該システムの運用当事者)

※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。

結論

Switch from position: sticky to position: fixed without clearing top: 0, and it coexists with bottom: 0 to stretch the element to full viewport height — an accidental full-screen overlay.

Conclusion

Leave the top: 0 you put in for position: sticky in place while a media query switches to position: fixed, and it coexists with bottom: 0 to stretch the element from top to bottom. We hit this on a component whose tab bar was stuck to the top on desktop and became a fixed bottom bar on mobile. The mobile block added bottom: 0 but the base top: 0 survives unless a media query overrides it. The result was a tab bar meant to sit at the bottom spanning the entire screen and swallowing every other interaction. The fix is one line — top: auto in the mobile block — but the CSS is valid from the start, so nothing short of looking at it in a browser reveals it.

Symptom

On desktop the component shows a tab bar at the top of the screen, kept in place while scrolling with position: sticky. On mobile (640px and below) it becomes a fixed bottom navigation, with position: fixed and bottom: 0 set inside a media query.

What was reported in production on 2026-06-15 was the mobile bottom bar stretching across the whole screen, covering everything else and making the page unusable. There were no changes to the DOM structure or to the JavaScript; CSS was the only suspect.

Cause

The base rule (desktop) before the fix:

/* apps/web/app/app/editor/[classId]/_components/ClassEditorShell.module.css (pre-fix, lines 13-25) */
.tabBar {
  display: flex;
  gap: 0.25rem;
  padding: 0.25rem;
  margin-bottom: 1rem;
  background: #f7f8fa;
  border-radius: 0.6rem;
  /* Desktop: keep the tabs pinned to the top while scrolling */
  position: sticky;
  top: 0;
  z-index: 30;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}

And the mobile media query, before the fix:

/* apps/web/app/app/editor/[classId]/_components/ClassEditorShell.module.css (pre-fix, lines 56-68) */
@media (max-width: 640px) {
  .tabBar {
    position: fixed;
    left: 0;
    right: 0;
    bottom: 0;
    z-index: 50;
    margin-bottom: 0;
    border-radius: 0;
    border-top: 1px solid #e5e7eb;
    background: #fff;
    padding: 0.25rem 0.25rem calc(0.25rem + env(safe-area-inset-bottom, 0px));
    box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.06);
  }
}

In the CSS cascade, any property the media query does not override keeps its value from the base rule. The mobile block overrides position, left, right and bottom, but never touches top. So the base top: 0 remains in effect on mobile and .tabBar renders with position: fixed; top: 0; bottom: 0;.

A position: fixed element with both top and bottom set and no explicit height stretches to fill the gap between them. The tab bar became a box spanning from the top of the viewport (top: 0) to the bottom (bottom: 0). The tabs themselves were laid out at the top by flex, so it was not obvious visually, but the element carried a click area covering the entire screen and absorbed every tap and click meant for other UI.

The fix

Add one line, top: auto, to .tabBar in the mobile block.

/* apps/web/app/app/editor/[classId]/_components/ClassEditorShell.module.css (post-fix, lines 56-72) */
@media (max-width: 640px) {
  .tabBar {
    position: fixed;
    /* Always clear the base sticky top:0. With fixed, top:0 and bottom:0 together stretch
     * the height to the full viewport, so the footer covers the screen and blocks every
     * interaction (production incident 2026-06-15). top:auto keeps it bottom-fixed only. */
    top: auto;
    left: 0;
    right: 0;
    bottom: 0;
    z-index: 50;
    margin-bottom: 0;
    border-radius: 0;
    border-top: 1px solid #e5e7eb;
    background: #fff;
    padding: 0.25rem 0.25rem calc(0.25rem + env(safe-area-inset-bottom, 0px));
    box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.06);
  }
}

With top: auto the browser treats top as unconstrained. Only bottom: 0 remains as a constraint, so the height returns to the content — the tabs — and the element sits against the bottom edge as intended. The base sticky top: 0 is still needed for desktop, so the base rule is untouched and the mobile block cancels it explicitly.

The change is CSS only; no schema, type or logic changes.

Preventing a repeat

This reached production because position: fixed; top: 0; bottom: 0; is not wrong as CSS. Type checking and the build do not inspect declaration values, and no warning is produced when top and bottom are both in effect. The browser draws exactly the stretched box it was asked for, so only looking at the rendered result finds it.

When writing a media query that switches position, check whether any offset property the new position depends on — top, right, bottom, left — is still set from the base rule. The fix leaves top: auto with a comment, so the next person does not have to work out again why that one line is there.

よくある質問

Q1Why did adding bottom:0 cover the whole screen?

A fixed element with both top and bottom set stretches to fill the gap. The base rule for .tabBar had position: sticky; top: 0 for desktop, and the mobile media query added position: fixed and bottom: 0 without resetting top. The inherited top: 0 made the box span the full viewport.

Q2Why didn't the build or tests catch it?

The combination is valid CSS and is not something type checking or the build inspects. The browser honours top: 0 and bottom: 0 exactly as written, so nothing short of looking at the rendered result on a device or in a browser would reveal the stretch.

Q3What did the fix change?

One line: top: auto on .tabBar inside the mobile media query. With position: fixed kept and only bottom: 0 in effect, the browser is free to size the element by its content, so the height returns to the bar itself.

確認した環境

  • Next.js ^16.0.0 / CSS Modules
  • Occurred in production 2026-06-15, fixed the same day

この記事の根拠

  • CSSファイル 13〜25行目コミット 3b4ea03
  • CSSファイル 56〜68行目コミット 3b4ea03
  • CSSファイル 56〜72行目コミット 98d3816

本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。