@shinyaz

Making a Next.js Blog Responsive — And the backdrop-filter Trap That Broke Everything

Table of Contents

The Problem

I pulled up this blog on my phone for the first time and immediately saw problems. Navigation links overflowed horizontally. Padding was sized for desktops, wasting precious mobile screen space. Buttons were too small to tap comfortably. Headings were comically large.

The fix involved four areas: a hamburger menu, responsive spacing, responsive typography, and WCAG-compliant touch targets. All implemented with Tailwind CSS utility classes and standard React hooks — no external libraries.

But the most educational part of this work had nothing to do with responsive design. It was a CSS specification edge case that silently broke my drawer implementation.

The backdrop-filter Trap

This is the gotcha I wish someone had warned me about. It cost me hours of debugging, and it's worth understanding even if you never build a mobile menu.

The Setup

The blog's header uses sticky top-0 z-50 with backdrop-blur-sm for a frosted glass effect. The MobileNav component is a child of this header. I implemented the drawer as a fixed positioned element that should cover the viewport below the header.

Issue 1: z-index Stacking Context

My first implementation used fixed inset-0 z-40 for the overlay. Two problems surfaced:

  1. The overlay covered the header area — inset-0 targets the entire viewport, so the overlay's blur stacked on top of the header's blur
  2. The hamburger button became unclickable — its z-index was effectively 0, below the z-40 overlay

I fixed the range to top-14 right-0 bottom-0 left-0 and added relative z-50 to the button.

Issue 2: The Real Surprise

The drawer's height matched the header's height (~57px) instead of stretching to the bottom of the viewport. The menu was virtually invisible.

The root cause: the header's backdrop-blur-sm generates backdrop-filter: blur(4px). Per the CSS spec, an element with backdrop-filter becomes a containing block for fixed-positioned descendants. This means fixed top-14 bottom-0 was interpreted as "from the header's top to the header's bottom" — not the viewport. The drawer height was effectively zero.

This behavior is triggered by transform, perspective, filter, backdrop-filter, and will-change. It's in the spec, but it's rarely discussed in practical tutorials.

The Fix: createPortal

The solution was to render the overlay and drawer outside the header's DOM tree entirely:

src/components/layout/mobile-nav.tsx
import { createPortal } from "react-dom";
 
export function MobileNav({ locale, t }: MobileNavProps) {
  const [isOpen, setIsOpen] = useState(false);
 
  return (
    <div className="md:hidden">
      <button
        className="relative z-50 inline-flex h-9 w-9 ..."
        aria-expanded={isOpen}
      >
        {/* Hamburger icon */}
      </button>
 
      {isOpen &&
        createPortal(
          <>
            <div
              className="fixed top-14 right-0 bottom-0 left-0 z-40 bg-black/50"
              onClick={() => setIsOpen(false)}
              aria-hidden="true"
            />
            <nav className="fixed top-14 right-0 bottom-0 z-50 w-3/4 max-w-xs bg-background p-6 shadow-xl">
              {/* Menu content */}
            </nav>
          </>,
          document.body,
        )}
    </div>
  );
}

By portaling to document.body, the drawer's fixed positioning resolves against the viewport as expected, bypassing the header's backdrop-filter containing block entirely.

The lesson: when fixed positioning doesn't behave as expected inside a sticky header, check the parent's styles for backdrop-filter, transform, or filter. Or just use createPortal and avoid the problem altogether.

Hamburger Menu Architecture

The Header component stays a Server Component. Only the interactive mobile navigation is extracted as a "use client" component:

Header (Server Component)
├── Desktop Nav (hidden md:flex) — existing
└── MobileNav (Client Component, md:hidden) — new
    ├── Hamburger button
    ├── Overlay (portaled to body)
    └── Drawer (portaled to body)

The dictionary (getDictionary(locale)) result is passed to MobileNav as props, keeping the client component free of i18n library dependencies.

Key behaviors: body scroll locks when the drawer is open, Escape key closes it, and each link's onClick calls setIsOpen(false). I initially tried watching usePathname() in a useEffect, but React 19's react-hooks/set-state-in-effect ESLint rule flagged it — explicit onClick handlers are cleaner anyway.

Responsive Spacing, Typography, and Touch Targets

These changes were less dramatic but impactful in aggregate:

Spacing — All page containers changed from py-12 to py-6 md:py-12. This halved vertical padding on mobile across 11 files, significantly improving the visible content area.

Typography — Heading sizes in .prose scale down on mobile via CSS @media queries (e.g., h1 from 2rem to 1.625rem). Code block font sizes also scale from 0.8125rem to 0.875rem.

Touch targets — WCAG 2.5.5 recommends 44x44 CSS pixels minimum. Social share buttons, pagination, and footer icons were updated to h-11 w-11 (44px) on mobile, scaling back to their original size at md:

// Social share button — 44px on mobile, 36px on desktop
"inline-flex h-11 w-11 ... md:h-9 md:w-9";

Viewport — Added maximumScale: 5 to the Next.js Viewport export. Using maximumScale: 1 disables pinch-to-zoom and violates WCAG accessibility guidelines.

Summary

  • backdrop-filter creates containing blocks for fixed children — this is the most useful thing to remember from this post. It's in the CSS spec but rarely discussed. transform, filter, and will-change do the same. When fixed positioning breaks inside a parent, createPortal is the reliable escape hatch.
  • Keep Server Components as the default — only the interactive hamburger menu needed "use client". The header, navigation links, and all page layouts remain server-rendered.
  • Mobile-first spacing compoundspy-6 md:py-12 is a tiny change per file, but applied across all pages it noticeably improves the mobile reading experience.
  • WCAG touch targets are easy to implementh-11 w-11 md:h-9 md:w-9 gives you 44px on mobile without changing the desktop design.

Share this post

Shinya Tahara

Shinya Tahara

Solutions Architect @ AWS

I'm a Solutions Architect at AWS, providing technical guidance primarily to financial industry customers. I share learnings about cloud architecture and AI/ML on this blog.

Related Posts