# Shadcn Sidebar Mobile: Fix the Sheet and Add a Tab Bar

> Shadcn sidebar mobile fixes: close the sheet on navigation, add the hidden title, stop height bugs, then pair the drawer with a bottom tab bar on phones.

- Author: [Serban Rusu](https://wingo-ui.com/blog/authors/serban), Founder of Wingo UI
- Published: Nov 5, 2026
- Category: [Mobile UI in React](https://wingo-ui.com/blog/category/mobile-ui)
- Reading time: 9 min
- Canonical: https://wingo-ui.com/blog/shadcn-sidebar-mobile

## TL;DR

Below 768px the shadcn sidebar renders inside a Sheet with its own openMobile state, and nothing closes that sheet when you tap a link, so call setOpenMobile(false) in each link's onClick. Keep a visually hidden SheetTitle in the mobile sheet, reset openMobile when the breakpoint flips, and let the document scroll instead of putting h-screen on the inset. For a phone layout that feels native, pair the drawer with a bottom tab bar for the three to five most used pages.

The shadcn sidebar mobile experience is where most admin layouts start to feel broken. On a laptop it collapses to icons, remembers its state and responds to Cmd+B. On a phone it slides in as a sheet, you tap a link, the page changes behind it, and the sheet stays open on top. Older copies also log a missing DialogTitle warning, and the last row of the page hides behind Safari's toolbar. This tutorial explains why each of those happens, fixes them in stock shadcn/ui, and then builds an app shell that turns the sidebar into a drawer plus a bottom tab bar below 768px.

## How does the shadcn sidebar work on mobile?

It renders a different tree. As of November 2026 the [shadcn/ui sidebar source](https://github.com/shadcn-ui/ui/blob/main/apps/v4/registry/new-york-v4/ui/sidebar.tsx) checks `isMobile` from its `useIsMobile` hook, and when it is true it returns a Radix-based `Sheet` instead of the fixed column:

- The breakpoint is 768px (`MOBILE_BREAKPOINT = 768` in `hooks/use-mobile.ts`).
- The sheet is `18rem` wide (`SIDEBAR_WIDTH_MOBILE`), the desktop column `16rem`, the icon rail `3rem`.
- The sheet has its own state. `SidebarProvider` keeps `open` for desktop and a separate `openMobile` for the sheet. Your `open` and `onOpenChange` props on the provider control the desktop state only.
- `toggleSidebar`, which `SidebarTrigger` and Cmd/Ctrl+B call, flips `openMobile` on phones and `open` on desktop.
- `collapsible="none"` skips the mobile branch entirely: that sidebar renders as a plain column at every width, with no sheet.

That split explains most of the bugs people report as "shadcn sidebar not working on mobile". Code that closes the sidebar with `setOpen(false)` does nothing on a phone, and a controlled `open` prop never reaches the sheet.

## Why doesn't the shadcn sidebar close when you tap a link?

Nothing in the component closes it, which is the bug behind searches like "shadcn sidebar close on click mobile". In the same source file, `openMobile` only changes through `setOpenMobile`, which runs from the trigger, the Cmd+B handler and the Sheet's `onOpenChange` (the overlay tap and Escape). `SidebarMenuButton` is a styled button with no close logic, and there is no effect that watches the route.

The reliable fix is to close it in the link's click handler:

```tsx
"use client"

import type { ElementType } from "react"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, useSidebar } from "@/components/ui/sidebar"

type Item = { title: string; url: string; icon: ElementType }

export function NavMain({ items }: { items: Item[] }) {
  const pathname = usePathname()
  const { isMobile, setOpenMobile } = useSidebar()

  return (
    <SidebarMenu>
      {items.map((item) => (
        <SidebarMenuItem key={item.url}>
          <SidebarMenuButton asChild isActive={pathname === item.url}>
            <Link href={item.url} onClick={() => isMobile && setOpenMobile(false)}>
              <item.icon />
              <span>{item.title}</span>
            </Link>
          </SidebarMenuButton>
        </SidebarMenuItem>
      ))}
    </SidebarMenu>
  )
}
```

A common alternative is an effect keyed on `usePathname()` that calls `setOpenMobile(false)`. We prefer the click handler for two reasons. A tap on the page you are already on does not change the pathname, so the effect never fires and the sheet stays open. And on a slow route without a `loading.tsx`, the pathname changes only once the server responds, so the sheet sits open over the old page in the meantime. Closing on tap gives instant feedback whatever the route does.

## How do you fix the missing DialogTitle warning?

Add a visually hidden `SheetHeader` with a title. The shadcn sidebar sheet is a Radix dialog, and Radix logs "DialogContent requires a DialogTitle" when a dialog has no title, because a screen reader has nothing to announce. [shadcn-ui issue #5746](https://github.com/shadcn-ui/ui/issues/5746), opened on November 6, 2024, reports exactly this for the sidebar.

The current source already contains a hidden header. If your `components/ui/sidebar.tsx` was generated earlier, the mobile branch probably lacks it. Add it inside `SheetContent`:

```tsx
<SheetContent
  data-sidebar="sidebar"
  data-slot="sidebar"
  data-mobile="true"
  className="w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
  style={{ "--sidebar-width": SIDEBAR_WIDTH_MOBILE } as React.CSSProperties}
  side={side}
>
  <SheetHeader className="sr-only">
    <SheetTitle>Navigation</SheetTitle>
    <SheetDescription>Links to every section of the app.</SheetDescription>
  </SheetHeader>
  <div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
```

Import `SheetHeader`, `SheetTitle` and `SheetDescription` from `@/components/ui/sheet` at the top of the file. Pick a title a screen reader user would understand: "Navigation" or your app's name says more than the stock "Sidebar".

## What else breaks the shadcn sidebar on phones?

### The sheet reopens after a rotation

Nothing resets `openMobile` when `isMobile` flips. Open the sheet on a phone, rotate to landscape (most current phones are wider than 768px there), rotate back, and the sheet is open again. One effect inside the provider fixes it:

```tsx
"use client"

import { useEffect } from "react"
import { useSidebar } from "@/components/ui/sidebar"

export function ResetMobileSidebar() {
  const { isMobile, setOpenMobile } = useSidebar()
  useEffect(() => {
    if (!isMobile) setOpenMobile(false)
  }, [isMobile, setOpenMobile])
  return null
}
```

Render `<ResetMobileSidebar />` anywhere inside `SidebarProvider`.

### The first render thinks it is on desktop

`useIsMobile` starts as `undefined`, returns `false`, and measures the window in an effect. The desktop column is hidden with CSS below `md`, so the sidebar itself does not flash. Anything you branch on `isMobile` for visible markup does: a header that renders a different button on phones will swap after hydration. Switch visible layout with Tailwind breakpoints and keep the hook for behavior. Our [SSR-safe use mobile hook tutorial](https://wingo-ui.com/blog/react-use-mobile-hook-ssr) compares the versions.

### The page is clipped or the toolbar never hides

Let the document scroll. To keep the sidebar in place, a common pattern puts `h-screen overflow-y-auto` on `SidebarInset`. That turns the inset into its own scroll container. On iOS Safari `100vh` is the height with the toolbar hidden, so the last rows sit under the toolbar, and because the window never scrolls, the toolbar never minimizes. The shadcn wrapper already uses `min-h-svh` and the desktop column is `h-svh` and fixed, so you can let the document scroll and make your header `sticky top-0`. The [100vh on mobile guide](https://wingo-ui.com/blog/100vh-mobile-dvh-tailwind) explains `svh`, `dvh` and `lvh` in detail.

### The shadcn sidebar collapsible icon mode does nothing on phones

`collapsible="icon"` only shapes the desktop column: a 3rem rail with tooltips. Below 768px the sidebar always opens as the 18rem sheet with full labels. That is correct for touch, since tooltips need hover, but it means the rail is not a phone navigation pattern.

## Why pair the sidebar with a bottom tab bar on phones?

Because the pages people open every day should sit where the thumb already is. A drawer hides navigation behind a tap on a 44px button in the top corner, the hardest spot to reach with one hand. Apps people open every day put their three to five main destinations at the bottom edge and keep the long list in a drawer or a More tab. shadcn/ui has no tab bar component, so you would build one yourself: a fixed `nav` with `md:hidden`, `pb-[env(safe-area-inset-bottom)]` for the home indicator, an active state from the pathname, and bottom padding on the page so the last row is not covered. Our [React bottom navigation bar tutorial](https://wingo-ui.com/blog/react-bottom-navigation-bar) covers that build and its edge cases.

## How do you build a sidebar that becomes a drawer plus a tab bar?

The Wingo UI [App Shell](https://wingo-ui.com/components/app-shell) does the layout switch for you. From 768px up it renders the [Sidebar](https://wingo-ui.com/components/sidebar) as a column that folds into a 64px icon rail (Mod+B, remembered in a cookie). Below 768px the same sidebar moves into a drawer, and the [Bottom Tab Bar](https://wingo-ui.com/components/bottom-tab-bar) appears at the bottom edge. The drawer has a visually hidden title, closes on navigation, Escape, a tap on the dimmed page or a swipe back, and returns focus to the menu button. The layout switch is CSS, so the server HTML matches the phone. The files land in your repo as source, styled with Tailwind v4 and your own tokens.

> Live demo (App Shell): Press Mod+B or the panel button to fold the sidebar into an icon rail; on a phone, tap the menu button, then a link, and the drawer closes as the page changes. Try it at [App Shell](https://wingo-ui.com/components/app-shell) and install it with `npx wingo-ui@latest add app-shell`.

### 1. Install it

```bash
npx wingo-ui@latest add app-shell
```

The command also installs the sidebar, top bar, bottom tab bar and sheet it uses.

### 2. Share one link component

Every nav part reads the router from a `LinkProvider`. That is also how the drawer knows a navigation started, so it closes on the tap without a pathname effect:

```tsx
// app/providers.tsx
"use client"

import type { ReactNode } from "react"
import Link from "next/link"
import { usePathname, useRouter } from "next/navigation"
import { LinkProvider } from "@/lib/navigation"

export function Providers({ children }: { children: ReactNode }) {
  const router = useRouter()
  return (
    <LinkProvider component={Link} currentPath={usePathname()} navigate={router.push}>
      {children}
    </LinkProvider>
  )
}
```

### 3. Define the navigation once and add a More tab

```tsx
// app/(app)/app-frame.tsx
"use client"

import { useState, type ReactNode } from "react"
import { FileText, House, Inbox, LayoutDashboard, Menu, Settings, Users } from "lucide-react"
import { AppShell } from "@/components/ui/app-shell"
import { Sidebar, type SidebarNavGroup } from "@/components/ui/sidebar"
import { Topbar } from "@/components/ui/topbar"
import type { NavItem } from "@/lib/navigation"

const nav: SidebarNavGroup[] = [
  {
    items: [
      { label: "Home", href: "/", icon: <LayoutDashboard /> },
      { label: "Inbox", href: "/inbox", icon: <Inbox />, badge: 4 },
    ],
  },
  {
    label: "Sales",
    items: [
      { label: "Clients", href: "/clients", icon: <Users /> },
      { label: "Invoices", href: "/invoices", icon: <FileText /> },
    ],
  },
  { label: "Account", items: [{ label: "Settings", href: "/settings", icon: <Settings /> }] },
]

export function AppFrame({ defaultCollapsed, children }: { defaultCollapsed: boolean; children: ReactNode }) {
  const [menuOpen, setMenuOpen] = useState(false)

  const tabs: NavItem[] = [
    { label: "Home", href: "/", icon: <House /> },
    { label: "Clients", href: "/clients", icon: <Users /> },
    { label: "Inbox", href: "/inbox", icon: <Inbox />, badge: 4 },
    { id: "more", label: "More", icon: <Menu />, onSelect: () => setMenuOpen(true) },
  ]

  return (
    <AppShell
      defaultCollapsed={defaultCollapsed}
      mobileOpen={menuOpen}
      onMobileOpenChange={setMenuOpen}
      sidebar={<Sidebar groups={nav} filter />}
      topbar={<Topbar title="Summit CRM" />}
      tabs={tabs}
      labels={{ navigation: "Main menu" }}
    >
      {children}
    </AppShell>
  )
}
```

The More tab has no `href`, so it acts as a button and never becomes the active tab. Controlling `mobileOpen` lets it open the same drawer the top bar's menu button opens.

### 4. Read the collapsed cookie on the server

```tsx
// app/(app)/layout.tsx
import type { ReactNode } from "react"
import { cookies } from "next/headers"
import { AppFrame } from "./app-frame"

// the shell's default persistKey is "app-shell", so the cookie is "app-shell-collapsed"
export default async function AppLayout({ children }: { children: ReactNode }) {
  const stored = (await cookies()).get("app-shell-collapsed")?.value
  return <AppFrame defaultCollapsed={stored === "true"}>{children}</AppFrame>
}
```

Write the cookie name as a string here. `persistedCookieName` returns the same value, but its module imports React hooks, and importing it into a Server Component fails with "You're importing a module that depends on `useInsertionEffect`". Reading the cookie in the layout means a collapsed rail is collapsed on the first paint. Stock shadcn needs the same step: its provider writes a `sidebar_state` cookie but never reads it, so until your layout passes that cookie to `defaultOpen`, a collapsed sidebar comes back expanded on every full page load.

## What does the sidebar look like inside the drawer?

The shell renders a second copy of your sidebar with `mode="sheet"`, mounted only while the drawer is open. In that mode the rows grow to 44px with 15px text, the collapse toggle disappears, and a tap on an in-app link closes the drawer. On desktop, rows are 32 or 36px, and they still grow to 44px on any touch screen, so a tablet in landscape gets usable targets too.

> Live demo (Sidebar): The sidebar in sheet mode, as the drawer shows it on a phone: 44px rows, no collapse button, and a tap on a link closes the drawer. Try it at [Sidebar](https://wingo-ui.com/components/sidebar) and install it with `npx wingo-ui@latest add sidebar`.

If you pass your own wrapper component as `sidebar`, forward its props (`mode`, `variant`, `collapsible`, `width`, `className`, `style`) to the `Sidebar`, or the drawer copy renders as a desktop column.

## Which App Shell props matter on phones?

Four props decide how the phone layout behaves; the defaults suit most apps.

- **Breakpoint.** `mobileBreakpoint="lg"` moves the switch to 1024px, so tablets get the drawer and tab bar too.
- **Scrolling.** Keep `scroll="window"`, the default, for apps: the document scrolls, the iOS toolbar minimizes and router scroll restoration works. Use `scroll="content"` only for embedded apps.
- **Edge swipe.** `edgeSwipe` opens the drawer with a swipe from the start edge. Turn it on in installed PWAs only, since in Safari that edge is the browser's back gesture.
- **Hide on scroll.** Pass a `BottomTabBar` element with `hideOnScroll` instead of the item array if you want the bar to slide away while reading.

For a full back office in one block, the [Admin Shell](https://wingo-ui.com/components/admin-shell) wires all of this with a workspace switcher, search, notifications, a command menu and a five-tab bar whose More tab opens the drawer. The [Next.js admin dashboard tutorial](https://wingo-ui.com/blog/nextjs-admin-dashboard-tutorial) builds a dashboard on it, and more patterns for phones live in the [Mobile UI in React](https://wingo-ui.com/blog/category/mobile-ui) category and our [mobile-first React components guide](https://wingo-ui.com/blog/mobile-first-react-components).

## What should you do next?

If you stay on stock shadcn/ui, add the `onClick` close, the hidden title and the reset effect above, and remove any `h-screen` from the inset. Then open your app at 390px and tap through every link. If you want the drawer, rail and tab bar done for you, the App Shell, Sidebar and Bottom Tab Bar are part of [Wingo UI Pro](https://wingo-ui.com/pricing); a free account lets you try the free components first.

## Components in this post

- [Sidebar](https://wingo-ui.com/components/sidebar) (Pro): A React sidebar for admin apps: grouped links with icons, nested sections, badges, a sliding active pill, an icon rail and a filter. Install: `npx wingo-ui@latest add sidebar`
- [App Shell](https://wingo-ui.com/components/app-shell) (Pro): A React app shell for admin layouts: a collapsible, resizable sidebar, a top bar and the page, with a drawer and a tab bar on phones. Install: `npx wingo-ui@latest add app-shell`
- [Admin Shell](https://wingo-ui.com/components/admin-shell) (Pro): A React admin shell layout with a workspace sidebar, a top bar with search and notifications, a command menu and a bottom tab bar on phones. Install: `npx wingo-ui@latest add admin-shell`
- [Bottom Tab Bar](https://wingo-ui.com/components/bottom-tab-bar) (Pro): A React bottom tab bar for phones: 3 to 5 tabs at the thumb with an animated pill, badges, a raised center action and hide on scroll. Install: `npx wingo-ui@latest add bottom-tab-bar`

## FAQ

### Why doesn't the shadcn sidebar close when I click a link on mobile?

The mobile sidebar is a Sheet whose open state, openMobile, only changes through the trigger, the overlay, Escape or setOpenMobile. SidebarMenuButton has no close logic, so call setOpenMobile(false) in the link's onClick when isMobile is true.

### How do I fix the DialogContent requires a DialogTitle warning in the shadcn sidebar?

Put a SheetHeader with the sr-only class inside the mobile SheetContent, holding a SheetTitle and a SheetDescription. The current shadcn source already does this; copies generated before that change log the warning until you add it.

### Does collapsible icon work on mobile in the shadcn sidebar?

No. collapsible="icon" only shapes the desktop column. Below 768px the sidebar opens as an 18rem sheet with full labels, which is what you want on a touch screen.

### At what width does the shadcn sidebar switch to mobile?

At 768px. The useIsMobile hook in shadcn/ui uses a MOBILE_BREAKPOINT of 768 and returns false until it has measured the window after mount.

### Should a mobile app use a sidebar drawer or a bottom tab bar?

Both. Put the three to five pages people open every day in a bottom tab bar where the thumb rests, and keep the full navigation in the drawer behind a menu button or a More tab.

---

Source: https://wingo-ui.com/blog/shadcn-sidebar-mobile
