Building a Responsive Material UI Navigation Drawer with Temporary and Permanent Variants
Implement a Material UI temporary Drawer as a responsive mobile navigation sidebar with controlled state, focus management, and a permanent desktop variant.
01 Jul 2026, 16:58 UTC

On a phone, a persistent sidebar can consume a third of the viewport before the user reads anything. On a desktop, hiding navigation behind a hamburger menu adds friction to every task. Material UI's Drawer component addresses both, but the temporary variant only works correctly when its open state is controlled, its backdrop and Escape handlers are wired, and focus is managed. This guide shows how to implement that responsive pattern and how to recover when the drawer overlays content it should not.
Desired outcome
You want a navigation drawer that behaves differently by viewport width. Below a chosen breakpoint, the drawer should slide over the page from the left, dim the background with a backdrop, close when the user clicks that backdrop or presses Escape, and trap focus while open. Above the breakpoint, the same navigation should appear as a permanent, non-overlaying sidebar so the desktop layout remains unchanged.
Prerequisites
- A React application with
@mui/materialand@emotion/reactinstalled. - An existing app shell with a header or app bar where you can place a menu button.
- A navigation list of links or menu items ready to render inside the drawer.
- Basic familiarity with React state hooks and JSX.
The examples below assume Material UI v5 or v6, where useMediaQuery, the sx prop, and Drawer variant behavior are stable. If you are on an older major version, verify that the variant and ModalProps APIs match.
Focused procedure
1. Add controlled state and a breakpoint check
The temporary drawer must be controlled: you own the open boolean and pass an onClose callback. Use useMediaQuery to decide whether to render the temporary or permanent variant.
import { useState } from 'react';
import { useMediaQuery, Drawer, Box, List, ListItem, ListItemButton, ListItemText, AppBar, Toolbar, IconButton, Typography } from '@mui/material';
import MenuIcon from '@mui/icons-material/Menu';
const drawerWidth = 240;
export function ResponsiveNav() {
const [mobileOpen, setMobileOpen] = useState(false);
const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('md'));
const handleDrawerToggle = () => setMobileOpen((prev) => !prev);
2. Render the drawer content once
Wrap your navigation list in a Box with role="presentation". Attach onClick and onKeyDown handlers that call the close handler so that selecting a link dismisses the drawer. Because the box is presentational, screen readers will not announce it as a separate landmark.
const drawerContent = (
<Box
sx={{ width: drawerWidth }}
role="presentation"
onClick={handleDrawerToggle}
onKeyDown={handleDrawerToggle}
>
<List>
{['Dashboard', 'Reports', 'Settings'].map((text) => (
<ListItem key={text} disablePadding>
<ListItemButton>
<ListItemText primary={text} />
</ListItemButton>
</ListItem>
))}
</List>
</Box>
);
3. Place the trigger in the app bar
Show the menu button only when the permanent drawer is not rendered. The button toggles the same state that controls the temporary drawer.
return (
<>
<AppBar position="fixed" sx={{ zIndex: (theme) => theme.zIndex.drawer + 1 }}>
<Toolbar>
{!isDesktop && (
<IconButton
color="inherit"
aria-label="open drawer"
edge="start"
onClick={handleDrawerToggle}
sx={{ mr: 2 }}
>
<MenuIcon />
</IconButton>
)}
<Typography variant="h6" noWrap>
Responsive App
</Typography>
</Toolbar>
</AppBar>
4. Switch between permanent and temporary variants
Render the permanent drawer on desktop and the temporary drawer on mobile. The temporary variant uses variant="temporary" and anchor="left". It renders inside a Modal by default, which provides the backdrop, scroll lock, Escape handling, and focus trap.
{isDesktop ? (
<Drawer
variant="permanent"
open
sx={{
width: drawerWidth,
flexShrink: 0,
'& .MuiDrawer-paper': { width: drawerWidth, boxSizing: 'border-box' },
}}
>
{drawerContent}
</Drawer>
) : (
<Drawer
variant="temporary"
anchor="left"
open={mobileOpen}
onClose={handleDrawerToggle}
ModalProps={{ keepMounted: true }}
sx={{
'& .MuiDrawer-paper': { width: drawerWidth, boxSizing: 'border-box' },
}}
>
{drawerContent}
</Drawer>
)}
</>
);
}
Why keepMounted: true? This keeps the drawer's DOM in memory while closed, which preserves scroll position and avoids a flash on first open. It is optional; remove it if your navigation tree is expensive to render.
Expected checks
After wiring the component, verify the following behaviors. These are the observable signals that the drawer is working as intended.
| Check | Expected result |
|---|---|
| Mobile open | The drawer slides in from the left and the page behind it dims. |
| Backdrop click | Clicking the dimmed area outside the drawer calls onClose and dismisses it. |
| Escape key | Pressing Escape while the drawer is open closes it. |
| Focus entry | When the drawer opens, keyboard focus moves into the drawer content. |
| Focus return | After closing, focus returns to the menu button that opened the drawer. |
| Desktop layout | Above the breakpoint, the permanent sidebar is visible and the menu button is absent. |
| Breakpoint switch | Resizing across the breakpoint swaps variants without a jarring layout shift. |
Recovery options
If a check fails, use these targeted diagnostics before changing the component structure.
The drawer does not close on backdrop click
Confirm that onClose is passed directly to the Drawer and that no parent overlay or transparent container is intercepting pointer events above the backdrop. If you wrapped the drawer in a custom div with a higher z-index, the click may never reach the modal backdrop.
Focus is not trapped or does not return
Ensure no other modal is active at the same time. Nesting a temporary Drawer inside a Dialog or another modal creates competing focus traps and scroll locks. Also verify that the drawer content contains focusable elements; an empty Box with no interactive children will not hold focus.
Desktop content is inaccessible
If the permanent drawer covers page content, check that the main content area has a left margin or padding equal to drawerWidth, or that your layout uses a flex container that accounts for the drawer's flexShrink: 0. If the breakpoint logic is inverted, confirm that useMediaQuery is evaluating the same theme breakpoint you expect.
Limitations and version notes
- No swipe gestures by default. The standard
Drawerdoes not support touch swipe-to-open. If you need that behavior, use theSwipeableDrawercomponent, which requiresopen,onOpen, andonCloseprops. - Modal stacking conflicts. Because the temporary variant renders a Modal, stacking it with another Dialog can cause z-index or scroll-lock conflicts. Avoid nesting without managing modal order explicitly.
- Responsive width. Hard-coding pixel widths can conflict with theme breakpoints. Prefer the
sxwidth or values derived fromtheme.shapeand breakpoints.
Practical verification
Open your browser's device toolbar and select a mobile viewport. Click the menu button and confirm the drawer opens, traps focus, and closes via both the backdrop and the Escape key. Press Tab after closing and confirm focus lands back on the menu button. Finally, drag the viewport wider until it crosses your chosen breakpoint and confirm the permanent sidebar appears and the hamburger button disappears without content jumping.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.