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:
aria-modal="true"— always. The sheet is either a modal dialog or it isn't in the tree at all.aria-hidden={!isOpen}— hides the entire subtree from assistive technology when the sheet is closed.inertwhen 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.