Skip to main content

Mobile Nav Accessibility: The Bottom Sheet Dialog Pattern

Apr 6, 20263 min readAccessibility, React, Mobile, ARIA, Testing

The Redesign That Broke Things

We shipped a mobile bottom navigation bar with much excitement. The iOS-style pattern with Home, Services, Projects, Experience across the bottom of the screen, plus a "More" button that opens a bottom sheet with secondary links. It's UI ergonomics. It looked great. It passed unit tests. Then Playwright's axe integration flagged 6 failures, all on mobile viewports.

The failures fell into two categories: the search trigger was unreachable, and the "More" bottom sheet had broken dialog semantics.

The Hidden Search Button

The original design had Search and DarkModeToggle in a top header bar above the content. On mobile, this bar was position: static — it scrolled with the page. But the new bottom nav was position: fixed at z-index: 50. Meaning that on short viewports, the top bar sat directly behind the bottom bar. The search button was technically in the DOM, but you couldn't tap it because it was inaccessible.

The fix was straightforward: move the search trigger into the bottom bar itself, as a peer to the other nav items. The DarkModeToggle moved into the More sheet, where it belongs: it's a preference, not a navigation action.

<Button
  name='mobile-search'
  variant='bare'
  onClick={handleSearchClick}
  aria-label='Search (⌘K)'
  data-testid='search-trigger'
  className='flex flex-col items-center justify-center flex-1 gap-0.5 ...'
>
  <Search className='h-5 w-5' aria-hidden='true' />
  Search
</Button>

The Dialog That Wasn't

The More sheet was a role="dialog" with aria-modal toggled between true and false. That sounds right — the sheet is modal when open and not modal when closed. But screen readers don't interpret it that way.

When aria-modal is false, the dialog is still visible in the accessibility tree. Screen reader users can Tab into an "invisible" sheet and interact with links that are not on the screen. The correct pattern has three parts:

  1. aria-modal="true" — always. The sheet is either a modal dialog or it isn't in the tree at all.
  2. aria-hidden={!isOpen} — hides the entire subtree from assistive technology when the sheet is closed.
  3. inert when closed — prevents focus from entering the hidden sheet via keyboard navigation.
<div
  role="dialog"
  aria-label="More navigation"
  aria-modal="true"
  aria-hidden={!sheetOpen}
  inert={!sheetOpen ? true : undefined}
  className={cn(
    'fixed bottom-0 left-0 right-0 z-50 ...',
    sheetOpen ? 'translate-y-0' : 'translate-y-full pointer-events-none'
  )}
>

The visual transition (translate-y-full) handles sighted users. The ARIA attributes handle everyone else. Both need to agree.

The Header Restructuring

There was a subtler structural issue. The original nav wrapped both desktop and mobile navigation in a single <header>:

<header className='fixed md:sticky bottom-0 md:top-0 ...'>
  <TabletUpNav />
  <MobileNav />
</header>

On mobile, this <header> was fixed to the bottom — but <MobileNav> renders its own fixed-position elements (the bottom bar, the backdrop, the sheet). Nesting fixed elements inside a fixed container with overflow handling creates unpredictable stacking behavior.

We split them: <header> should wrap only the desktop nav (hidden md:block), and MobileNav should render independently at the top level.

One More Thing: Layout-Matched Skeletons

While fixing the nav, we noticed the desktop nav's loading state was a centered Spinner. When the real nav loaded, everything shifted. The logo appeared on the left, links spread across the middle, CTAs landed on the right. Classic layout shift from a loading state that doesn't match the final layout.

We replaced it with a NavSkeleton component using the shared-ui Skeleton primitive. Each skeleton element matches the dimensions of the real element it represents: 32x32 for the logo, 68px for "Services," 82px for "Experience." The nav loads in place without anything jumping.

The Takeaway

A bottom sheet that slides out of view isn't hidden from assistive technology unless you explicitly declare it so. Visual CSS transitions and ARIA state are parallel systems that need to stay in sync, and inert is the missing piece that prevents keyboard focus from reaching elements that aria-hidden has removed from the accessibility tree.