Nextjs Admin Dashboard Tutorial: 5 Screens, Phone-Ready
TL;DR
Build a Next.js admin dashboard as five screens in one App Router route group: an app shell layout, a home page with KPIs, list pages with a server-side data table whose state lives in the URL, a settings page and a team page with roles. Pages fetch through a server-only data access layer, and every change goes through a Server Action that checks the session itself. On phones the sidebar becomes a drawer with a bottom tab bar, table rows become cards and menus open as bottom sheets.
Most admin dashboard tutorials stop at a sidebar, four stat cards, a chart and a table that scrolls sideways on a phone. A back office needs more: list pages that filter on the server, a settings page that keeps what someone typed when a save fails, a team page with roles, loading and error states on every route, and a layout that works when the person approving an invoice is on a phone. This nextjs admin dashboard tutorial builds all of it on the Next.js App Router with React 19 and Tailwind CSS v4, screen by screen, with the code. We build Wingo UI and the examples use our blocks, so we are not neutral; the App Router patterns work with any component library, and the last section compares the free starters.
What does a Next.js admin dashboard need?
Five screens and two states: a shell around a home page, list pages, a settings page and a team page, each with a loading and an error state. The React admin dashboard components we use for each, and their phone behavior:
| Screen | Its job | Built with | On a phone |
| --- | --- | --- | --- |
| Shell | Navigation, search, the account | App Shell with Sidebar | The sidebar moves into a drawer, a tab bar takes the bottom edge |
| Home | What changed, what needs attention | Admin Dashboard Page or KPI Dashboard | KPIs two per row, panels in one column |
| Lists | Find, filter and act on records | Data Table in server mode | Rows become cards, menus become bottom sheets |
| Settings | Profile, account, billing, security | Settings Page | A list that pushes each section in, like iOS Settings |
| Team | Members, roles, audit log | Users Admin Page | Member cards, one role at a time in the matrix |
| Loading and errors | Every route | loading.tsx, error.tsx and each block's loading prop | Skeletons in the phone layout |
How should you structure the App Router for an admin dashboard?
Put every admin route in one route group with its own layout. A folder in parentheses does not appear in the URL, so app/(admin)/clients/page.tsx serves /clients while the group's layout.tsx wraps it in the shell. Sign-in lives in its own group and never sees the sidebar.
app/
layout.tsx root: html, body, <Providers>
providers.tsx LinkProvider (next/link) and the Toaster
(auth)/
sign-in/page.tsx no shell
(admin)/
layout.tsx the shell: AppShell, Sidebar, Topbar
nav.tsx sidebar groups and phone tabs
error.tsx the admin error boundary
page.tsx /: the home with KPIs
dashboard-home.tsx
actions.ts order actions for the home
clients/
page.tsx reads the URL, fetches one page
clients-table.tsx
query.ts
loading.tsx
[id]/page.tsx one client
settings/
page.tsx
settings-view.tsx
actions.ts
team/
page.tsx
team-view.tsx
actions.ts
server/
dal.ts "server-only": verifySession, getUser, requireAdmin
Three rules hold it together:
1. Pages fetch, views render. Each page.tsx is a server component that loads data. The blocks are client components, so function props (a save handler, a router call) live in a small *-view.tsx next to the page.
2. Auth lives in the data access layer. Layouts do not re-render on navigation, so a layout check misses route changes. Call verifySession() from a server-only module in every page and every Server Action; the data security guide says to treat each Server Action as reachable by a direct POST request.
3. The URL holds the view. Period, filters, page, open tab: if it changes what the screen shows, it goes in the search params, so a link reproduces the screen.
How do you build the app shell for a Next.js admin dashboard?
Render one AppShell in the group's layout with a Sidebar and a Topbar, and read the sidebar's cookie on the server so the first paint is right. The shell shows the sidebar beside the page on desktop, and a drawer plus a bottom tab bar on phones. It switches with CSS, so the server HTML is already right.
First, the router wiring: the Wingo UI navigation links render through LinkProvider, so one client provider turns them all into next/link:
// app/providers.tsx
"use client";
import type { ReactNode } from "react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { Toaster } from "@/components/ui/toast";
import { LinkProvider } from "@/lib/navigation";
export function Providers({ children }: { children: ReactNode }) {
const router = useRouter();
return (
<LinkProvider component={Link} currentPath={usePathname()} navigate={router.push}>
{children}
{/* phones have a tab bar at the bottom, so toasts drop from the top */}
<Toaster mobilePosition="top" />
</LinkProvider>
);
}
The sidebar and the tab bar share the NavItem shape, so one file feeds both:
// app/(admin)/nav.tsx
import { House, Settings, ShieldCheck, Users } from "lucide-react";
import type { SidebarNavGroup } from "@/components/ui/sidebar";
import type { NavItem } from "@/lib/navigation";
export const nav: SidebarNavGroup[] = [
{ items: [{ label: "Home", href: "/", icon: <House /> }] },
{ label: "Sales", items: [{ label: "Clients", href: "/clients", icon: <Users />, badge: 3 }] },
{
label: "Workspace",
items: [
{ label: "Team", href: "/team", icon: <ShieldCheck /> },
{ label: "Settings", href: "/settings", icon: <Settings /> },
],
},
];
export const tabs: NavItem[] = [
{ label: "Home", href: "/", icon: <House />, match: "exact" },
{ label: "Clients", href: "/clients", icon: <Users /> },
{ label: "Team", href: "/team", icon: <ShieldCheck /> },
{ label: "Settings", href: "/settings", icon: <Settings /> },
];
And the layout, a server component:
// app/(admin)/layout.tsx
import type { ReactNode } from "react";
import { cookies } from "next/headers";
import { AppShell } from "@/components/ui/app-shell";
import { Sidebar } from "@/components/ui/sidebar";
import { Topbar } from "@/components/ui/topbar";
import { getUser } from "@/server/dal";
import { nav, tabs } from "./nav";
export default async function AdminLayout({ children }: { children: ReactNode }) {
const [store, user] = await Promise.all([cookies(), getUser()]);
// the shell writes `${persistKey}-collapsed` ("app-shell" by default) as "true" or "false"
const collapsed = store.get("app-shell-collapsed")?.value === "true";
return (
<AppShell
defaultCollapsed={collapsed}
mobileBreakpoint="lg"
sidebar={<Sidebar groups={nav} filter user={{ name: user.name, email: user.email, avatar: user.avatarUrl }} />}
topbar={<Topbar title="Northwind" />}
tabs={tabs}
>
{children}
</AppShell>
);
}
Mod+B (Cmd on a Mac, Ctrl elsewhere) folds the sidebar into a 64px icon rail and stores that in the app-shell-collapsed cookie. Without the server read, a user who collapsed it sees it expanded on every first paint and then snap to the rail after hydration. Use the literal cookie name on the server: the persistedCookieName helper lives in a hook file that imports client-only React hooks, which a server layout cannot import. Below the breakpoint the sidebar becomes a drawer that closes on navigation, Escape, a tap outside or a swipe back, and focus returns to the menu button. A "Skip to content" link is the first Tab stop.
We pass mobileBreakpoint="lg" (1024px) on purpose. At md (768px), an 820px tablet in portrait keeps a 256px sidebar and 564px for the page, too little for a table. At lg it gets the phone layout and the full width.
Keep the awaits in this layout cheap: a slow await here holds every page behind it. A cookie read and a cached session lookup are fine; a dashboard query is not.
For comparison, the shadcn/ui Sidebar (as of October 2026) stores its open state in a sidebar_state cookie for seven days and renders a Sheet when its useIsMobile hook reports a phone. It has no bottom tab bar, so on a phone every destination is two taps away.
For the whole back office layout in one block, the Admin Shell adds a workspace switcher, a global search on /, notifications, a command menu on Mod+K and a five-tab bar whose More tab opens the drawer. Our React command palette guide covers that menu's shortcuts, nested pages and phone sheet.
How do you build the dashboard home with KPIs?
Fetch the numbers in the page for the period in the URL, and pass them to a dashboard block as props. The Admin Dashboard Page is the home of an orders business (four KPIs, revenue against the previous period, recent orders, what needs attention, team activity); the KPI Dashboard suits a B2B or analytics app.
The page reads the period, defaults to the last 30 days and fetches:
// app/(admin)/page.tsx
import { isValid, parseISO, startOfDay, subDays } from "date-fns";
import { getDashboard } from "@/server/dashboard";
import { getUser } from "@/server/dal";
import { DashboardHome } from "./dashboard-home";
function readPeriod(from?: string, to?: string) {
const today = startOfDay(new Date());
const end = to ? parseISO(to) : today;
const start = from ? parseISO(from) : subDays(end, 29);
return isValid(start) && isValid(end) ? { from: start, to: end } : { from: subDays(today, 29), to: today };
}
export default async function HomePage({ searchParams }: { searchParams: Promise<{ from?: string; to?: string }> }) {
const { from, to } = await searchParams;
const period = readPeriod(from, to);
// { kpis, revenue, orders, tasks, activity, topProducts } for the period
const [user, data] = await Promise.all([getUser(), getDashboard(period)]);
return <DashboardHome firstName={user.firstName} period={period} data={data} />;
}
The view writes a new period back to the URL and sends order actions to Server Actions:
// app/(admin)/dashboard-home.tsx
"use client";
import { useRouter } from "next/navigation";
import { format } from "date-fns";
import { AdminDashboardPage } from "@/components/blocks/admin-dashboard-page";
import type { DashboardData } from "@/components/blocks/admin-dashboard-page-data";
import type { DateRange } from "@/components/ui/date-range-picker";
import { runOrderAction } from "./actions";
type Props = { firstName: string; period: DateRange; data: DashboardData };
export function DashboardHome({ firstName, period, data }: Props) {
const router = useRouter();
return (
<AdminDashboardPage
{...data}
userName={firstName}
defaultPeriod={period}
onPeriodChange={({ from, to }) => {
if (!from || !to) return;
const params = new URLSearchParams({ from: format(from, "yyyy-MM-dd"), to: format(to, "yyyy-MM-dd") });
router.replace(`/?${params}`, { scroll: false });
}}
onOrderAction={async (action, order) => {
const { error } = await runOrderAction(action, order.id);
if (error) throw new Error(error);
}}
/>
);
}
The period is uncontrolled on purpose (defaultPeriod): the date field updates the moment someone applies a range, and the panels follow when the server answers. onOrderAction is optimistic: the status changes at once, the row shows a spinner while the promise runs, and a rejection rolls it back with an error toast, which is why the handler throws on an error. One catch: after a cancel, the toast's Undo restores the row on screen only and calls none of your code, so the next server render shows the order as cancelled again. Rewrite labels.cancelDescription so users know a cancel is final; its default text promises an email and a refund, which only your server can keep.
> Live demo: Admin Dashboard Page. The original article embeds an interactive demo here (Pick another period in the date field, then click an order in Recent orders to open its sheet. This frame is narrower than 1024px, so you see the phone layout: the sidebar sits behind the menu button and the tab bar holds the bottom edge). Try it live on wingo-ui.com or open the Admin Dashboard Page docs. Install: npx wingo-ui@latest add admin-dashboard-page
On a phone the KPIs sit two by two, recent orders become cards with Load more, and the date field and the order details open as bottom sheets you drag down to close.
When should you use the KPI Dashboard instead?
When the home is about analytics more than orders: revenue over time, sales by source, a conversion funnel, top salespeople and invoices. It takes one data object and hides a widget whose data is missing, so it fits a company with no funnel. It has the controls an analytics page needs: onRefresh (a promise spins the button and announces the update), onExport with CSV and PDF, a widgets list to pick and order panels, and widgetErrors with onWidgetRetry to show an error and a Try again button inside the one widget whose query failed, so the rest of the page stays up.
Its grid uses container queries: one column, two from 672px and three from 1152px of its own width. Inside a shell that matters, because a 1280px window with a 256px sidebar leaves about 1000px, and viewport breakpoints would lay the grid out for space it does not have. The DashboardWidget and KpiDashboardGrid parts are exported for your own panels.
Can you render KPI cards from a server component?
Yes, as long as every prop is data. The Stat Card takes numbers, strings and icons, so a server component can render a row directly:
// app/(admin)/reports/page.tsx
import { FileText, Timer, UserPlus, Wallet } from "lucide-react";
import { StatCard, StatGroup } from "@/components/ui/stat-card";
import { getKpis } from "@/server/reports";
export default async function ReportsPage() {
const k = await getKpis();
return (
<StatGroup variant="joined">
<StatCard label="Revenue" icon={<Wallet />} value={k.revenue} format="currency" delta={k.revenueChange} deltaLabel="vs. last month" sparkline={k.dailyRevenue} goal={k.revenueGoal} locale="en-US" />
<StatCard label="Invoices issued" icon={<FileText />} value={k.invoices} delta={k.invoicesChange} deltaFormat="number" locale="en-US" />
<StatCard label="New clients" icon={<UserPlus />} value={k.newClients} delta={k.newClientsChange} locale="en-US" />
<StatCard label="Time to payment" icon={<Timer />} value={k.secondsToPay} format="duration" delta={k.timeToPayChange} invertTrend locale="en-US" />
</StatGroup>
);
}
The server HTML holds the final numbers, the count-up runs on the client when a card scrolls into view, and screen readers hear one sentence with the final value and the change. invertTrend makes a drop in time to payment green. Two limits: a function prop such as formatDelta needs a client file, and href renders a plain anchor (a full page load in Next.js), so linked tiles need a client wrapper whose onClick calls event.preventDefault() and then router.push.
How do you build list pages with a server-side data table?
Run the Data Table in server mode, keep its query in the URL, and let the page fetch one page at a time. In mode="server" the table does no sorting, filtering or paging itself: data is the current page, rowCount the total, and every change arrives in onQueryChange as one object (page, pageSize, sorting, globalFilter, columnFilters). One module translates it to and from the search params:
// app/(admin)/clients/query.ts
import type { DataTableQuery } from "@/components/ui/data-table";
type Params = Record<string, string | string[] | undefined>;
const one = (value: string | string[] | undefined) => (Array.isArray(value) ? value[0] : value) ?? "";
export function readQuery(params: Params): DataTableQuery {
const [sortId, dir] = one(params.sort).split(".");
const status = one(params.status);
const size = Number(one(params.size));
return {
page: Math.max(1, Number(one(params.page)) || 1),
pageSize: [10, 25, 50, 100].includes(size) ? size : 25,
sorting: sortId ? [{ id: sortId, desc: dir === "desc" }] : [],
globalFilter: one(params.q),
columnFilters: status ? [{ id: "status", value: status.split(",") }] : [],
};
}
export function writeQuery(query: DataTableQuery): string {
const params = new URLSearchParams();
if (query.page > 1) params.set("page", String(query.page));
if (query.pageSize !== 25) params.set("size", String(query.pageSize));
if (query.globalFilter) params.set("q", query.globalFilter);
const sort = query.sorting[0];
if (sort) params.set("sort", `${sort.id}.${sort.desc ? "desc" : "asc"}`);
const status = query.columnFilters.find((filter) => filter.id === "status")?.value;
if (Array.isArray(status) && status.length) params.set("status", status.join(","));
return params.toString();
}
The page parses the URL and queries one page:
// app/(admin)/clients/page.tsx
import { listClients } from "@/server/clients";
import { ClientsTable } from "./clients-table";
import { readQuery } from "./query";
type Search = Promise<Record<string, string | string[] | undefined>>;
export default async function ClientsPage({ searchParams }: { searchParams: Search }) {
const query = readQuery(await searchParams);
// checks the session, whitelists sort ids and filter values before building SQL
const { rows, total } = await listClients(query);
return <ClientsTable rows={rows} total={total} initial={query} />;
}
The view starts from the URL and writes changes back in a transition, so isPending drives the refreshing bar:
// app/(admin)/clients/clients-table.tsx
"use client";
import { useTransition } from "react";
import { useRouter } from "next/navigation";
import { DataTable, createColumns, type DataTableQuery } from "@/components/ui/data-table";
import { writeQuery } from "./query";
export type ClientRow = { id: string; name: string; city: string; status: string; balance: number; lastInvoice: string | null };
const STATUSES = ["Active", "Overdue", "Prospect"];
const col = createColumns<ClientRow>();
const columns = col([
col.accessor("name", { header: "Client", mobile: "title" }),
col.accessor("city", { header: "City", mobile: "subtitle" }),
col.accessor("status", {
header: "Status",
type: "badge",
badge: { Active: { tone: "success" }, Overdue: { tone: "danger" }, Prospect: { tone: "info" } },
filter: "multi-select",
// server mode only sees one page, so list every option yourself
filterOptions: STATUSES.map((status) => ({ value: status, label: status })),
mobile: "meta",
}),
col.accessor("balance", { header: "Balance", type: "currency" }),
col.accessor("lastInvoice", { header: "Last invoice", type: "relative" }),
]);
type Props = { rows: ClientRow[]; total: number; initial: DataTableQuery; loading?: boolean };
export function ClientsTable({ rows, total, initial, loading }: Props) {
const router = useRouter();
const [pending, startTransition] = useTransition();
return (
<DataTable
data={rows}
columns={columns}
mode="server"
rowCount={total}
defaultPage={initial.page}
defaultPageSize={initial.pageSize}
defaultSorting={initial.sorting}
defaultGlobalFilter={initial.globalFilter}
defaultColumnFilters={initial.columnFilters}
onQueryChange={(query) => {
const search = writeQuery(query);
startTransition(() => router.replace(search ? `/clients?${search}` : "/clients", { scroll: false }));
}}
refreshing={pending}
loading={loading}
quickFilters={[{ id: "overdue", label: "Overdue", columnFilter: { id: "status", value: ["Overdue"] } }]}
onRowClick={(row) => router.push(`/clients/${row.id}`)}
caption="Clients"
locale="en-US"
/>
);
}
Details that save debugging time: onQueryChange does not fire on the first render (the server already sent that page), the search arrives debounced (300ms), and the page resets to 1 when the search, a filter or the sort changes. The table is uncontrolled (default* props), so the search box never lags behind a server round trip. The trade-off: the back button changes the data while the toolbar keeps the last query. If your users live on the back button, control page, sorting, globalFilter and columnFilters from the URL instead.
> Live demo: Data Table. The original article embeds an interactive demo here (Search, sort or click the Overdue chip: in server mode the table hands your code one query object, printed under the pager. Below 768px every row is a card you can long press to select). Try it live on wingo-ui.com or open the Data Table docs. Install: npx wingo-ui@latest add data-table
On a phone the columns become cards: mobile: "title" is the heading, subtitle the line under it, meta the badge in the corner. Sorting, filters and the row menu open as bottom sheets, and with selectionMode on, a long press starts selection. Server mode keeps a compact pager on phones; client mode uses Load more.
Server mode is not always the right call. When one request can load every row a user may see, client mode filters instantly and needs no query API. We switch to server mode past a few thousand rows, or when permissions decide which rows exist for a user. To build the table yourself on TanStack Table instead, with the same server pagination, follow our TanStack Table v9 data table tutorial.
How do you build a settings page that never loses input?
Let one block own the drafts, and save through Server Actions that return their errors. The Settings Page has six sections (profile, account, notifications, billing, security, danger zone). A text change slides up a save bar with Discard and Save (or Mod+S), switches save on their own, a failed save keeps the draft and shows the message, and leaving a section with unsaved changes asks first.
Next.js recommends modeling expected errors as return values in Server Functions, and in production the client does not get the original message of a server error, so a thrown "This workspace address is taken" would arrive as a generic error:
// app/(admin)/settings/actions.ts
"use server";
import { verifySession } from "@/server/dal";
import { saveSettings } from "@/server/settings";
export type ActionResult = { error?: string };
export async function saveSection(section: string, values: unknown): Promise<ActionResult> {
const { userId } = await verifySession();
// validates with zod and returns { error } for anything the user can fix
return saveSettings(userId, section, values);
}
// savePreference(preference, value) has the same shape. verifyEmailCode(email, code),
// verifyTwoFactorCode(code) and checkWorkspaceAddress(slug) check the session too
// and resolve to a boolean (true: the code is right, the address is free)
The view turns a returned error into the rejection the block listens for, and replaces the block's demo checks with real ones:
// app/(admin)/settings/settings-view.tsx
"use client";
import { SettingsPage, type SettingsPageProps } from "@/components/blocks/settings-page";
import {
checkWorkspaceAddress,
savePreference,
saveSection,
verifyEmailCode,
verifyTwoFactorCode,
type ActionResult,
} from "./actions";
type Props = Pick<
SettingsPageProps,
| "profile"
| "account"
| "notifications"
| "billing"
| "plans"
| "sessions"
| "passwordChangedAt"
| "defaultTwoFactorEnabled"
| "twoFactorSecret"
| "twoFactorUri"
| "backupCodes"
>;
const LABELS = { codeHint: "Enter the 6-digit code." };
async function check(result: Promise<ActionResult>) {
const { error } = await result;
if (error) throw new Error(error);
}
export function SettingsView(props: Props) {
return (
<SettingsPage
{...props}
onSave={(section, values) => check(saveSection(section, values))}
onPreferenceChange={(preference, value) => check(savePreference(preference, value))}
verifyEmailCode={verifyEmailCode}
verifyTwoFactorCode={verifyTwoFactorCode}
checkWorkspaceAddress={checkWorkspaceAddress}
labels={LABELS}
/>
);
}
Pass every data prop from the page: anything you leave out falls back to the block's sample data. The security defaults are demo values on purpose. Without verifyEmailCode and verifyTwoFactorCode, the code 123456 passes; without twoFactorSecret, twoFactorUri and backupCodes, the setup dialog shows sample ones; and the default codeHint label reads "In this demo, the code is 123456." Generate the secret and backup codes on the server for the signed-in user.
The dialogs take handlers with the same promise contract (onEmailChange, onPasswordChange, onTwoFactorChange, onSessionRevoke, onPlanChange, onDeleteAccount). One naming detail: the section ids are internal (profil, cont, notificari, facturare, securitate, pericol), so map them to your own segments if each section gets a URL through onSectionChange.
> Live demo: Settings Page. The original article embeds an interactive demo here (Open Profile, edit the full name and press Save. This demo drops the first save on purpose: the bar shows the error and your edit stays, and the next Save goes through. In a frame this narrow the sections are a list that pushes each one in). Try it live on wingo-ui.com or open the Settings Page docs. Install: npx wingo-ui@latest add settings-page
From 768px the sections are a column on the left (or tabs with nav="tabs"). On a phone each section pushes in with a back button and a swipe back, dialogs open as bottom sheets, and the save bar is pinned above the tab bar. The block validates each form before it calls onSave: invalid fields turn red and the first one takes focus, the pattern our React form components guide covers field by field.
How do you build the team and roles page?
Treat every control on it as a request the server can refuse. The Users Admin Page has three tabs: Members (the team and pending invitations against your seats, a role picker per row), Roles and permissions (a matrix with a save bar) and Audit log (a filterable history with CSV export). The server decides who may manage and enforces the rules the interface shows:
// app/(admin)/team/actions.ts
"use server";
import { requireAdmin } from "@/server/dal";
import { changeMemberRole } from "@/server/team";
export async function changeRole(memberId: string, role: string): Promise<{ error?: string }> {
const admin = await requireAdmin();
// refuses to demote the last administrator, the same rule the picker shows
return changeMemberRole(admin.workspaceId, memberId, role);
}
// invite, removeMember and savePermissions follow the same shape
// app/(admin)/team/team-view.tsx
"use client";
import { UsersAdminPage, type UsersAdminPageProps } from "@/components/blocks/users-admin-page";
import { changeRole, invite, removeMember, savePermissions } from "./actions";
type Props = Pick<
UsersAdminPageProps,
"companyName" | "members" | "invites" | "roles" | "permissionRoles" | "permissionGroups" | "defaultPermissions" | "auditEvents" | "seats" | "canManage"
>;
async function check(result: Promise<{ error?: string }>) {
const { error } = await result;
if (error) throw new Error(error);
}
export function TeamView(props: Props) {
return (
<UsersAdminPage
{...props}
onInvite={(input) => check(invite(input))}
onRoleChange={(member, role) => check(changeRole(member.id, role))}
onRemove={(member) => check(removeMember(member.id))}
onPermissionsSave={(value) => check(savePermissions(value))}
/>
);
}
The page computes canManage from the session and passes it with the data. With canManage={false} the block turns read-only and says why, which beats hiding the page from people who only need to see who has access.
The interface guards the obvious mistakes: the last administrator cannot be demoted, nobody removes themselves or the owner, and invitations hold a seat. Those guards are for the person clicking; your actions enforce them again, because a Server Action cannot tell which button sent the request. Likewise the block adds this page's changes to the top of the audit log as feedback, but the real audit rows are the ones your server writes in the same transaction.
On a phone, members become cards with the role picker inside, the invite dialog is a bottom sheet, and the matrix shows one role at a time with chips, because five role columns do not fit 390px. The parts also ship alone: Members Table, Permissions Matrix and Audit Log.
How do you handle loading and error states in the admin?
Give every route a loading.tsx that renders the same component with its loading prop, and the group one error.tsx. loading.js shows its fallback immediately on navigation while the shell stays interactive, and a skeleton in the real layout means nothing jumps when the data arrives:
// app/(admin)/clients/loading.tsx
import { ClientsTable } from "./clients-table";
import { readQuery } from "./query";
export default function Loading() {
return <ClientsTable rows={[]} total={0} initial={readQuery({})} loading />;
}
The Data Table draws skeleton rows in its column layout (cards on phones), and the dashboard, settings and team blocks have the same prop. A route without its own loading.tsx falls back to the nearest parent's, so one group-level skeleton would flash the same layout before every page.
For errors, Next.js 16.3 made the retry prop of error.js stable; it re-fetches and re-renders the segment:
// app/(admin)/error.tsx
"use client";
import { Button } from "@/components/ui/button";
export default function AdminError({ error, retry }: { error: Error & { digest?: string }; retry: () => void }) {
return (
<div role="alert" className="mx-auto flex max-w-md flex-col items-center gap-3 px-4 py-16 text-center">
<h2 className="text-lg font-semibold">This page did not load</h2>
<p className="text-sm text-muted-foreground">If it keeps failing, send us this reference: {error.digest ?? "none"}</p>
<Button onClick={retry}>Try again</Button>
</div>
);
}
Empty states belong in the components: the Data Table's emptyState for a list with no rows yet and noResultsState when filters match nothing, so "No clients yet" and "Nothing matches" never get mixed up.
How do you make every admin screen usable on a phone?
Change behavior as well as layout, and check each screen at 390px on a real phone. People open a back office on phones to approve an expense, check an order or answer a client. The checklist we hold every admin screen to:
- Navigation: everything in a drawer behind the menu button, plus a tab bar for the three to five daily destinations; tablets in portrait get the phone layout.
- One bar on the bottom edge: the tab bar pads the home indicator and publishes its height as --bottom-bar-offset, and the settings and team save bars read it to sit above the tab bar.
- Numbers: KPIs two per row, with values that shrink to fit 360px instead of wrapping.
- Tables: rows become cards (title, subtitle, badge, up to four fields); nobody should scroll sideways to read one record.
- Overlays: menus, filters, date pickers and detail views open as bottom sheets you can drag down; confirmations stay centered.
- Container queries: inside a shell, measure the container (as the KPI grid does), so a sidebar never squeezes a layout built for the viewport.
- Touch: 44px targets on coarse pointers, no hover-only actions, and every gesture backed by a visible button.
Our mobile-first React components guide covers the bottom sheets, touch targets, safe areas and phone tables in depth, with code, and the React bottom navigation bar guide covers the tab bar and when the sidebar alone is enough.
Should you start from a Next.js admin template or build it yourself?
Start from a free starter to learn the App Router or when you already use its stack; build from blocks when settings, roles and phone layouts must work on day one. Before you pick a "nextjs admin template" or a "shadcn admin dashboard", compare these free starting points with the blocks in this guide (as of October 2026):
| Option | Price and license | What you get | What is left to you |
| --- | --- | --- | --- |
| Next.js Learn dashboard course | Free | A financial dashboard with Postgres, Server Actions, search and pagination in the URL, NextAuth.js and Proxy; its invoices table turns into cards on phones | Settings, team and roles; it teaches the patterns and leaves the screens to you |
| shadcn/ui dashboard-01 block | Free, MIT | Inset sidebar, summary cards, an interactive area chart and a TanStack data table (npx shadcn add dashboard-01) | Settings, team pages, auth; the table keeps its rows on a phone and scrolls sideways |
| next-shadcn-dashboard-starter | Free, MIT | Next.js 16, shadcn/ui on Base UI, Clerk auth, product and user tables with URL state, team, billing and profile pages | Team, roles and billing come from Clerk's prebuilt components, so they need Clerk |
| Wingo UI blocks (this guide) | Pro: $8 a month, $80 a year or $150 lifetime | App Shell, Admin Shell, the two dashboards, Data Table, Settings Page and Users Admin Page, all with phone layouts | Your data layer and auth; the blocks are UI only |
Where the free options win: to learn, take the Next.js course, which teaches the data and auth side this guide skips. If you run shadcn/ui and need a dashboard tonight, dashboard-01 is one command and you own the code, which makes it the shortest path if you searched for "nextjs admin dashboard shadcn"; for desk-only internal tools a sideways-scrolling table is acceptable. If you already use Clerk, the Kiranism starter wires organizations, team management and billing through Clerk's prebuilt components. Wingo UI earns its price when phone users matter and you do not want to build the settings, team and table behaviors yourself. For libraries in general, our shadcn alternatives comparison covers fourteen with dated prices.
What should you build first?
Build the shell first, then one list page, then the home. The shell sets the navigation and phone layout every screen inherits, a list page exercises the server path (URL, query, Server Actions) that settings and team reuse, and the home is easiest once real data exists.
The admin blocks in this guide are part of Wingo UI Pro. With it, start with npx wingo-ui@latest add app-shell (it brings the Sidebar, Topbar, Bottom Tab Bar and the hooks along) and drop it into app/(admin)/layout.tsx as above. The free basics they build on, such as the Button, Card, Badge, Dialog and Toast, install without an account. For back offices that bill Romanian customers, two tutorials go deeper: e-Factura VAT calculation computes VAT per rate the way ANAF validates it, with an editable invoice, and the Romanian address format guide builds a delivery address form with county, locality and postal code checks. More screen-by-screen tutorials live in Build it: screens and apps.
Components in this post
- App Shell (Pro): The whole admin layout in one component: a collapsible, resizable sidebar, a top bar and the page; a drawer and a tab bar on phones. Install: npx wingo-ui@latest add app-shell
- Admin Dashboard Page (Pro): The home of a back office: greeting and period, four KPIs, revenue against the previous period, recent orders, alerts and team activity. Install: npx wingo-ui@latest add admin-dashboard-page
- KPI Dashboard (Pro): The first screen of a back office: KPI tiles with deltas, revenue against the last period, sources, funnel, top sellers, invoices, activity. Install: npx wingo-ui@latest add kpi-dashboard
- Data Table (Pro): The admin table on TanStack Table v9: search, filters, sorting, bulk actions, pinning, resizing, virtualization and cards on phones. Install: npx wingo-ui@latest add data-table
- Sidebar (Pro): The admin sidebar: grouped links with icons, nested sections, badges, a sliding active pill, an icon rail with flyouts and a filter. Install: npx wingo-ui@latest add sidebar
- Stat Card (Pro): The KPI tile of a dashboard: a label, a big number that counts up, a delta pill that knows good from bad, a sparkline and a goal bar. Install: npx wingo-ui@latest add stat-card
- Settings Page (Pro): The account settings of a back office: profile, account, notifications, billing, security and a danger zone, with a save bar. Install: npx wingo-ui@latest add settings-page
- Users Admin Page (Pro): The team page of a back office: members and invitations with seats, roles with a permissions matrix and a save bar, and the audit log. Install: npx wingo-ui@latest add users-admin-page
FAQ
How should I structure a Next.js App Router project for an admin dashboard?
Put every admin route in a route group such as app/(admin) whose layout renders the shell, and keep sign-in in a separate group without it. Pages are server components that fetch through a server-only data access layer, and each route gets its own loading.tsx.
Is there a free shadcn admin dashboard for Next.js?
Yes. As of October 2026 shadcn/ui ships a free dashboard-01 block (npx shadcn add dashboard-01) with a sidebar, summary cards, a chart and a data table, but settings, roles and a phone layout for the table are left to you. The MIT next-shadcn-dashboard-starter adds Clerk auth, product and user tables, and team, billing and profile pages built on Clerk's prebuilt components.
Should an admin data table filter on the server or in the browser?
Filter in the browser when one request can load every row the user may see; it is instant and needs no query API. Filter on the server when the data grows past a few thousand rows or permissions decide which rows a user sees, and keep the page, sort, search and filters in the URL.
Where do auth checks go in a Next.js admin dashboard?
In a server-only data access layer that every page and every Server Action calls. A check in the layout or in proxy.ts alone is too weak: layouts do not re-render on client navigation, and Server Actions can be called with a direct POST request.
How do I make an admin dashboard usable on a phone?
Move the sidebar into a drawer with a bottom tab bar, show KPIs two per row, turn table rows into cards, open menus and detail views as bottom sheets, and keep a single bar on the bottom edge above the safe area. Test at 390px on a real phone, because a narrowed desktop window has no touch and no on-screen keyboard.
Which Wingo UI admin components are free?
None of the admin blocks in this guide: the App Shell, Sidebar, Data Table, Stat Card, KPI Dashboard, Admin Dashboard Page, Settings Page and Users Admin Page are Pro. The free basics, such as Button, Card, Badge, Dialog, Select, Tabs and Toast, install with the CLI without an account.
---
This article was originally published on the Wingo UI blog: Nextjs Admin Dashboard Tutorial: 5 Screens, Phone-Ready. The original has the live, interactive demos and is kept up to date.
React Use Mobile Hook: SSR-Safe, No Hydration Mismatch
TL;DR
A React use mobile hook returns false on the server because the server has no screen: it renders the HTML before any browser reports a width, and hydration has to repeat that guess. Switch visible layout with CSS breakpoints, which apply at first paint, and keep the hook for behavior after an interaction, such as opening a bottom sheet instead of a dialog. Build the hook on useSyncExternalStore with a server snapshot, so hydration matches and every later mount reads the real value on its first render.
Sooner or later, a Next.js app that has to work on phones grows a React use mobile hook: a useIsMobile() that returns true below 768px, copied from a starter or from shadcn/ui. It works in the browser, then surprises you three ways: it returns false on the server, a careless version throws a hydration error, and a phone can briefly see the desktop version of whatever the hook controls. This tutorial explains where each one comes from, which jobs belong to CSS breakpoints, and how to write an SSR-safe media query hook on useSyncExternalStore that hydrates cleanly.
Why is useIsMobile false on the server?
Because the server has no screen. React renders the HTML before any browser has reported a width, so a hook that reads matchMedia has nothing to read and must return a fixed guess. In the Next.js App Router the gap is wider still: a page that uses no request-time APIs is prerendered once at build time, with no request and no visitor at all.
The guess itself is harmless. The trouble is hydration: the first client render has to produce the same markup as the server, so the browser repeats the guess, and the value may only change after hydration. On a phone, the server's answer stays on screen from the first paint until the JavaScript has downloaded, parsed and hydrated, and on a slow connection that takes a while. Anything visible that depends on the hook is wrong for that whole window.
So the useful question is which jobs can live with a guess for that window.
Why does a useIsMobile hook cause a hydration mismatch?
Only when it reads window during render: the browser's first render then disagrees with the server's HTML. Search for a React is mobile hook and you find three shapes, and only the first one does that. We server-rendered each one with renderToString from React 19.3, hydrated it with hydrateRoot in Chromium at 390px through Playwright, and mounted a second copy 300ms later, the way a dialog, a lazy panel or a client-side route mounts after hydration:
| Pattern | Hydration at 390px | First render of a later mount |
| --- | --- | --- |
| typeof window check in render | Text: an error, the tree is rebuilt on the client. A class: no error, the server's class stays | true |
| useState + useEffect | No error: false, then true after the effect | false, then true |
| useSyncExternalStore with a server snapshot | No error: false, then true right after hydration | true |
The first shape is the obvious one:
// wrong: the server says false, a phone says true during hydration
const isMobile = typeof window !== "undefined" && matchMedia("(max-width: 767.98px)").matches;
When the hook changes text, React 19 reports "Hydration failed because the server rendered text didn't match the client" and rebuilds that tree on the client, which throws away the work SSR did. The Next.js hydration error page lists this exact check as a common cause.
When the hook only picks a className, nothing fails, and that is worse. In development React 19 logs "A tree hydrated but some attributes of the server rendered HTML didn't match the client properties. This won't be patched up." A production build logs nothing. In our test the phone kept class="desktop" after hydration in both builds, and nothing corrects it while the value stays the same.
The second shape is the shadcn use-mobile hook that ships with the shadcn/ui Sidebar. As of October 2026 its source keeps undefined in state, measures in an effect and returns !!isMobile:
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
// ...subscribes to mql, then:
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}, [])
return !!isMobile
It never mismatches, because every first render returns false. The catch is the word every: a component that mounts long after hydration also renders as a desktop once, then corrects itself in the effect. An autoFocus={!isMobile} on a field in that component is true on the first commit, and React focuses the field during that commit, so a phone can still get the keyboard you meant to avoid.
The shadcn Sidebar gets away with the false start for a good reason. The desktop markup it renders on the server carries hidden md:block, so a phone never sees it, and on a phone the correction swaps in a Sheet that stays closed until someone opens it. That is the pattern to copy, and the next section spells it out.
When should you use CSS breakpoints instead of a hook?
For everything visible before the first interaction. CSS media queries apply at first paint, before any JavaScript runs, so they are already right in the server's HTML. Render both versions and let a breakpoint hide one:
// app/orders/page.tsx: a server component, no hook to correct
import { getOrders } from "@/lib/orders";
import { OrderCard } from "./order-card";
import { OrderRow } from "./order-row";
export default async function OrdersPage() {
const orders = await getOrders();
return (
<>
<ul className="flex flex-col gap-2 md:hidden">
{orders.map((order) => (
<OrderCard key={order.id} order={order} />
))}
</ul>
<table className="hidden w-full md:table">
<tbody>
{orders.map((order) => (
<OrderRow key={order.id} order={order} />
))}
</tbody>
</table>
</>
);
}
Both trees are in the DOM, which costs little for a nav or a page of 20 rows. When the switch depends on the space a component gets rather than the window, Tailwind v4's @container and @md: variants do it in CSS with no hook at all.
Keep the hook for decisions that start after the person does something, or that never show on the first paint:
- which overlay opens: a bottom sheet below 768px, a popover or a dialog above it
- the edge a panel slides in from
- whether a field takes focus when a panel opens, since focusing it on a phone pops the keyboard over the content
- where a click goes: a full page on a phone, a side panel next to the list on desktop
A closed overlay renders only its trigger, and the trigger is usually the same button in both modes, so the hook can switch the overlay freely. The shadcn responsive dialog tutorial builds that switch for a dialog, and the mobile-first React components guide applies the same rule across a whole component library.
How do you write an SSR-safe useMediaQuery hook?
Read the query through useSyncExternalStore, React's API for values that live outside React, and give it a server snapshot. That is the whole fix for useMediaQuery SSR problems:
// hooks/use-media-query.ts
import { useCallback, useSyncExternalStore } from "react";
// the complement of Tailwind's md: variant, in the same unit
export const MOBILE_QUERY = "(width < 48rem)";
const lists = new Map<string, MediaQueryList>();
function getList(query: string) {
let list = lists.get(query);
if (!list) {
list = window.matchMedia(query);
lists.set(query, list);
}
return list;
}
export function useMediaQuery(query: string, serverFallback = false) {
// stable per query, or React unsubscribes and subscribes again on every render
const subscribe = useCallback(
(onChange: () => void) => {
const list = getList(query);
list.addEventListener("change", onChange);
return () => list.removeEventListener("change", onChange);
},
[query],
);
return useSyncExternalStore(
subscribe,
() => getList(query).matches,
// the server and the hydration render
() => serverFallback,
);
}
export function useIsMobile() {
return useMediaQuery(MOBILE_QUERY);
}
// the same check in event handlers and effects; false on the server
export function matchesMedia(query: string) {
return typeof window !== "undefined" && getList(query).matches;
}
Here is what React does with it:
- Server and hydration. React calls the third function, so the server HTML and the hydration render agree. Leave it out and the React docs are blunt: "rendering the component on the server will throw an error."
- Right after hydration. Once the effects run, React compares the server snapshot with the browser's answer and, when they differ, re-renders synchronously. That is the false, then true in the table above.
- Every later mount. React calls the second function during the first render, so a sheet that opens on a phone renders as a phone from its first frame.
- Updates. A MediaQueryList fires change only when the query flips, so dragging the window edge costs no renders until it crosses the breakpoint, unlike a resize listener. The lists are cached per query because matches is read on every render.
If the shadcn Sidebar added hooks/use-mobile.ts to your project, point that file at the new hook. The Sidebar keeps its import, and its hidden md:block desktop markup now flips at the same width as the hook:
// hooks/use-mobile.ts
import { MOBILE_QUERY, useMediaQuery } from "@/hooks/use-media-query";
export const useIsMobile = () => useMediaQuery(MOBILE_QUERY);
Event handlers do not need the hook at all. Read the query at the moment of the click:
// app/orders/use-open-order.ts
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { MOBILE_QUERY, matchesMedia } from "@/hooks/use-media-query";
export function useOpenOrder() {
const router = useRouter();
const [selected, setSelected] = useState<string | null>(null);
function openOrder(id: string) {
// phones get a full page, desktops a panel next to the list
if (matchesMedia(MOBILE_QUERY)) router.push(`/orders/${id}`);
else setSelected(id);
}
return { selected, openOrder };
}
Which serverFallback should you pass?
The answer whose mistake costs least during the hydration window. For useIsMobile, false is usually right, because the decisions it drives start after a tap and by then the real value is in. For a query that changes something visible, pick the answer that works on every device. With (hover: hover) that is false: the server renders the always-visible version of a control, and a mouse user gets the hover polish a moment later.
Should the query use px or rem?
Use the unit of your CSS breakpoints. Tailwind v4 defines --breakpoint-md: 48rem, and our build compiles md: to @media (min-width: 48rem). Media Queries Level 4 says relative units in media queries are "based on the initial value", so 48rem follows the default font size set in the visitor's browser and ignores your html { font-size }. A hook written in px does not move with it.
We checked this in Chromium through Playwright, with a 900px window and the default font size set to 20px. (min-width: 48rem) stopped matching, so Tailwind showed the phone layout, while (max-width: 767.98px) did not match either, so a px hook still reported a desktop. (width < 48rem) matched, in step with the CSS. Range syntax also retires the .98px trick that Bootstrap's docs explain with "viewports with fractional widths". As of October 2026, MDN's compatibility data lists range syntax from Chrome 104, Firefox 102 and Safari 16.4.
Breakpoints are tokens like colors and radii, so keep them in one place: the Tailwind v4 design tokens guide shows how to layer them. A caveat about our own code: as of October 2026 the MOBILE_QUERY in Wingo UI is still (max-width: 767.98px), so it has the gap described above. With a 20px default font size, md: classes switch at 960px while the Drawer and the Sheet, which call useIsMobile(), switch at 768px. In your own components, call useMediaQuery("(width < 48rem)") where the hook and your classes have to agree.
Can Next.js detect mobile on the server?
It can guess from request headers, and only on routes rendered per request. Most ways to make React detect mobile or desktop on the server read the User-Agent header. Next.js parses it with userAgent() from next/server, whose device.type is "mobile", "tablet" and a few others, or undefined for desktop browsers. Pass the guess down as the server snapshot and hydration still matches, because the prop travels with the page:
// app/orders/page.tsx
import { headers } from "next/headers";
import { userAgent } from "next/server";
import { OrdersScreen } from "./orders-screen";
export default async function OrdersPage() {
const { device } = userAgent({ headers: await headers() });
return <OrdersScreen phoneGuess={device.type === "mobile"} />;
}
// app/orders/orders-screen.tsx
"use client";
import { MOBILE_QUERY, useMediaQuery } from "@/hooks/use-media-query";
export function OrdersScreen({ phoneGuess }: { phoneGuess: boolean }) {
// the server and the hydration render both use the guess, then the real value takes over
const isMobile = useMediaQuery(MOBILE_QUERY, phoneGuess);
return <p>{isMobile ? "Tap an order to open it" : "Select an order to see it on the right"}</p>;
}
Know the cost before you add it:
- Dynamic rendering. headers() is a request-time API, so the page renders on every request instead of once at build time, and any shared cache has to vary by user agent. With Cache Components turned on, the read also has to sit inside a <Suspense> boundary, or next build fails.
- It names a device. The header stays the same for a desktop browser narrowed to 600px, a tablet app in split screen and a phone turned to landscape, so it cannot tell you the window width. Since Safari 13, Safari on iPad sends the same user agent as Safari on macOS by default, so most iPads read as desktops.
- Client hints cover Chromium only. Sec-CH-UA-Mobile arrives without opting in, but as of October 2026 MDN's compatibility data lists it in Chrome and Edge and not in Firefox or Safari.
It pays off on app pages that are dynamic anyway, such as a signed-in dashboard, and rarely on marketing pages you want served from a CDN.
How do the Wingo UI Sheet and Drawer use the hook?
Only for behavior, and their closed state renders the same markup at every width. The free useMediaQuery hook is built the same way, with the same serverFallback parameter, plus useIsMobile, useIsTouch for (pointer: coarse), useBreakpoint and matchesMedia. Resize this window across 768px, or open the page on a phone:
> Live demo: useMediaQuery. The original article embeds an interactive demo here (Resize the window across 768px: the useIsMobile() card and the (width < 48rem) card flip together at the default font size, and the ruler moves to the widest Tailwind breakpoint that matches). Try it live on wingo-ui.com or open the useMediaQuery docs. Install: npx wingo-ui@latest add use-media-query
The Drawer calls useIsMobile() in its root. Phones always get the draggable sheet; from 768px the desktop prop picks a centered sheet, a side panel or a dialog. Its trigger is the same Radix Dialog trigger in every mode, so the server's guess changes nothing on screen, and the open state lives in the root, so a tablet rotated across 768px keeps the drawer open.
> Live demo: Drawer. The original article embeds an interactive demo here (Tap Filters: below 768px the drawer slides up as a sheet you can drag down to close; on a wider screen the same component opens a centered dialog). Try it live on wingo-ui.com or open the Drawer docs. Install: npx wingo-ui@latest add drawer
The Sheet does the same for side panels. mobileSide="bottom" turns a filter panel from the right into a bottom sheet on phones, and the side only matters once the sheet is open:
import { SlidersHorizontal } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Sheet } from "@/components/ui/sheet";
export function OrderFilters() {
return (
<Sheet
side="right"
mobileSide="bottom"
title="Filters"
description="Status, date and amount"
trigger={
<Button variant="soft" leftIcon={<SlidersHorizontal />}>
Filters
</Button>
}
>
<p className="text-sm text-muted-foreground">Pick the orders you want to see.</p>
</Sheet>
);
}
What should you do next?
Paste the hook above into hooks/use-media-query.ts, or install ours with npx wingo-ui@latest add use-media-query. Then search your codebase for useIsMobile and sort every call: visible layout moves to md: classes, behavior stays on the hook. More posts on the same stack live in Next.js and Tailwind CSS.
Components in this post
- useMediaQuery (Free): SSR-safe hooks for media queries, phone widths, touch screens and the current Tailwind breakpoint. Install: npx wingo-ui@latest add use-media-query
- Sheet (Free): A panel that slides in from any edge on a spring: filters, a cart, an inspector or the phone navigation menu, swipeable to close on touch. Install: npx wingo-ui@latest add sheet
- Drawer (Free): The library's bottom sheet on vaul: drag to dismiss, snap points, nesting, and a side panel or dialog on desktop. Install: npx wingo-ui@latest add drawer
FAQ
Why does useIsMobile return false on the server?
The server renders the HTML before any browser reports a screen width, so the hook has nothing to measure and returns a fixed guess. Hydration has to repeat that guess, and the real value arrives right after it.
Does the shadcn useIsMobile hook cause a hydration mismatch?
No. The shadcn use-mobile hook keeps undefined in state and measures the screen in an effect, so its first render returns false on the server and in the browser alike. The cost is that a component mounted after hydration also renders as a desktop once, until the effect corrects it.
How do I fix a hydration mismatch caused by useMediaQuery?
Stop reading window during render and read the query through useSyncExternalStore, with a getServerSnapshot that returns the same value on the server and during hydration. React then corrects the value right after hydration, with no mismatch.
Can Next.js detect a mobile device on the server?
It can guess from the User-Agent header with userAgent() from next/server, which makes the route render per request. The header names a device and says nothing about the viewport width, so use the guess as the server snapshot only and keep layout in CSS.
Why does my useIsMobile hook disagree with Tailwind's md breakpoint?
Tailwind v4 breakpoints are in rem (md is 48rem), and media queries resolve rem from the browser's default font size, while many hooks, shadcn's included, hard-code 768px. When a visitor raises that font size the two disagree; write the hook's query as (width < 48rem) and both flip at the same width.
Is the Wingo UI useMediaQuery hook free?
Yes. useMediaQuery, useIsMobile and the rest of the hook file are free, and so are the Sheet and the Drawer that use it. Install the hook with npx wingo-ui@latest add use-media-query.
---
This article was originally published on the Wingo UI blog: React Use Mobile Hook: SSR-Safe, No Hydration Mismatch. The original has the live, interactive demos and is kept up to date.
✨ shadcn/ui
یه جعبهابزار حرفهای از کامپوننتهای خوشساخت برای اینکه سایتت شبیه پروژههای آماتوری نشه!
🔗 shadcn-ui/ui
6 likes · 218 views
Hi @shadcn this now supports presets as requested.
PR: ui#12533
Demo link: shadcn.spaceman.sh
P.S. Don't try to click any links, had to prune parts of the app to meet the cloudflare 10mb build size limit. t.co
5 likes · 1 repost · 418 views
React Bottom Navigation Bar in Next.js: Safe Areas, Badges
TL;DR
A React bottom navigation bar is a fixed nav of 3 to 5 links at the bottom of the screen, with aria-current="page" on the active link, padding for env(safe-area-inset-bottom) under viewport-fit=cover, and bottom padding on the page equal to the bar's height so the last row stays visible. Keep the labels visible, put badge counts in each link's accessible name, and only hide the bar on scroll if it follows the finger and comes back on scroll up. Use it below 768px with a sidebar above that, and with more than five destinations keep the four most used in the bar and the rest in a drawer.
A React bottom navigation bar is the tab bar that native apps have trained everyone to expect: three to five destinations on the bottom edge, where the thumb already is. On the web it takes more than a fixed <div> of icons. The bar has to clear the iPhone home indicator, keep the last row of the page visible, tell a screen reader that Inbox has 4 unread messages, follow the router, and decide what a big center button and a long scroll should do. This guide covers each with the Wingo UI Bottom Tab Bar, then the cases where a Sidebar is the better choice.
When does a web app need a bottom navigation bar?
When people use it on phones and move between three to five top-level sections many times a session. Android's navigation bar guidance lists the conditions: "Three to five destinations of equal importance", "Compact window sizes" and "Consistent destinations across app screens". A CRM with Home, Clients, Inbox and Profile fits. A marketing site, a docs site or a checkout does not, because nobody hops between their sections.
The bottom edge is the reason to bother. In Steven Hoober's 2013 field study (1,333 observations of people using mobile devices), 49% of those touching the screen held the phone in one hand. A hamburger menu in a top corner sits far from that thumb and hides every destination behind an extra tap. In a bottom tab bar web app, every destination stays visible and one tap away, and the active tab tells people where they are.
What does a bottom tab bar need to work in a browser?
Five jobs that a native tab bar gets from the platform and a web page has to do itself:
| Concern | What to do | In the Bottom Tab Bar |
| --- | --- | --- |
| Semantics | A <nav> of links, aria-current="page" on the active one | aria-label="Primary"; tabs without href render as buttons |
| Routing | The active tab follows the URL, nested routes included | currentPath picks the longest matching href; linkComponent takes next/link |
| Home indicator | viewport-fit=cover, then pad the inside with env(safe-area-inset-bottom) | safeArea, on by default |
| Covered content | Pad the page by the bar's real height | Writes --bottom-bar-offset on <html> |
| Touch | Targets of 44px or more | Each tab fills its slot, 56 or 64px tall |
Build the tabs from links. The ARIA tabs pattern describes "layered sections of content, known as tab panels, that display one panel of content at a time". A tab bar loads another page, so role="tablist" would announce a widget that is not there.
Here is a React mobile bottom navigation for the Next.js App Router, in two files:
// app/(app)/mobile-nav.tsx
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { House, Inbox, Plus, UserRound, Users } from "lucide-react";
import { BottomTabBar, type BottomTabBarItem } from "@/components/ui/bottom-tab-bar";
const tabs: BottomTabBarItem[] = [
{ label: "Home", href: "/", icon: <House />, activeIcon: <House className="fill-current/20" /> },
{ label: "Clients", href: "/clients", icon: <Users />, activeIcon: <Users className="fill-current/20" /> },
{ label: "Inbox", href: "/inbox", icon: <Inbox />, badge: 4 },
{ label: "Profile", href: "/profile", icon: <UserRound />, dot: true },
];
export function MobileNav() {
return (
<BottomTabBar
className="md:hidden"
items={tabs}
currentPath={usePathname()}
linkComponent={Link}
action={{ label: "New client", icon: <Plus />, href: "/clients/new" }}
/>
);
}
// app/(app)/layout.tsx
import type { ReactNode } from "react";
import type { Viewport } from "next";
import { MobileNav } from "./mobile-nav";
// without cover, iOS keeps the page inside the safe area and env() reports 0
export const viewport: Viewport = { width: "device-width", initialScale: 1, viewportFit: "cover" };
export default function AppLayout({ children }: { children: ReactNode }) {
return (
<>
<main className="pb-(--bottom-bar-offset)">{children}</main>
<MobileNav />
</>
);
}
The bar is fixed to the viewport and measures itself, safe area included, so the padding on <main> is always exact; hidden by md:hidden on a desktop, it measures 0. A guessed pb-16 is wrong as soon as the inset or the size changes. The safe area guide explains the CSS underneath. Render the bar in the server HTML and hide it with CSS: deciding with a useIsMobile() hook renders nothing on the server, and the bar pops in after hydration, as the useMobile hook guide shows.
> Live demo: Bottom Tab Bar. The original article embeds an interactive demo here (Tap Clients, open a client, then tap Clients again: the tab goes back to its list, and one more tap scrolls to the top. The raised Add button opens a sheet instead of switching tabs). Try it live on wingo-ui.com or open the Bottom Tab Bar docs. Install: npx wingo-ui@latest add bottom-tab-bar
How should tab bar badges and labels behave?
A count for things that need action, a dot for something new, both read as part of the tab's name. badge: 4 renders a count (99+ above 99) and dot: true an 8px dot, red by default (badgeTone="danger") because people read a red count as unread. The visible badge is aria-hidden, and the link is named "Inbox, 4 unread" (badgeLabel) or "Profile, new" (dotLabel), so a screen reader never reads a stray number. Apple's tab bar guidelines (updated June 2026) say "Reserve badges for critical information so you don't dilute their impact and meaning": unread messages and pending approvals, not a new feature.
Keep showLabels="always". The same guidelines say "Include tab labels to help with navigation" and "Use single words whenever possible". "active" and "never" exist for icon-heavy consumer apps; with "never", labels stay in the DOM for screen readers and become title tooltips. activeIcon swaps in a second glyph while the tab is active, such as a filled version of the outline icon. Apple goes further and asks you to "Prefer filled symbols or icons for consistency with the platform" on every tab.
Should the center button be a tab, an action or a FAB?
An action, never a tab, and only when one creation action dominates the app. Apple is strict here: "Use a tab bar to support navigation, not to provide actions." We still ship the raised center button, because in an app built around one verb (Post, Sell, Scan) that verb has earned the most reachable spot. The action prop renders it in the middle (with four tabs: two, the action, two), filled with the tone and named by its label. It never takes the active pill, so nobody mistakes it for where they are.
When the app has several things to create, or you follow Apple's rule, leave the bar to navigation and use a FAB. It reads --bottom-bar-offset, so it floats 16px above the tab bar and the home indicator without counting the inset twice:
import { FileText, UserPlus } from "lucide-react";
import { Fab, type FabAction } from "@/components/ui/fab";
const actions: FabAction[] = [
{ id: "client", label: "New client", icon: <UserPlus />, href: "/clients/new" },
{ id: "invoice", label: "New invoice", icon: <FileText />, href: "/invoices/new" },
];
export function CreateFab() {
return <Fab className="md:hidden" label="Add" actions={actions} />;
}
> Live demo: FAB. The original article embeds an interactive demo here (Tap Add: the page dims and the create actions rise from the button. Scroll the list and the label folds down to the plus). Try it live on wingo-ui.com or open the FAB docs. Install: npx wingo-ui@latest add fab
Should a bottom navigation bar hide on scroll?
Only on long feeds and reading screens, and only if it comes back the moment the user might need it. Apple warns that "If you hide the tab bar, people can forget which area of the app they're in". Its iOS guidance describes minimizing a tab bar that carries an accessory, such as the MiniPlayer in Music, while people scroll down; tapping a tab or scrolling to the top brings it back.
If you hide it, hideOnScroll shows the behavior to copy:
- It follows the finger. The bar moves pixel for pixel with the scroll until it has traveled its own height plus the safe area, and snaps to shown or hidden once scrolling stops. A boolean that flips on direction makes the bar jump on a 2px scroll.
- It comes back on scroll up, at the top, at the end of the content and whenever keyboard focus is inside it.
- It moves a transform only. The progress lives in a motion value, so scrolling never re-renders the bar or the page. A version that calls setState in the scroll handler renders on every scroll event.
- Reduced motion turns the slide into a flip after a full bar height of scrolling.
> Live demo: Bottom Tab Bar. The original article embeds an interactive demo here (Scroll the page: the floating bar follows your finger out of view and comes back as soon as you scroll up). Try it live on wingo-ui.com or open the Bottom Tab Bar docs. Install: npx wingo-ui@latest add bottom-tab-bar
When is a sidebar the better choice?
When there are more than five top-level destinations, when they nest, or when most sessions happen on a laptop. Apple suggests "a sidebar or a tab bar that adapts to a sidebar as an alternative for an app with a complex information structure" and asks you to "Avoid overflow tabs"; Android recommends the bar for compact windows.
| Your app | On a phone | From 768px |
| --- | --- | --- |
| 3 to 5 sections, used daily on phones | Bottom tab bar | Sidebar or top navigation |
| 6 or more sections, or nested ones | The 4 most used in the bar, everything in a drawer | Sidebar |
| Admin tool used mostly at a desk | Drawer, optional tab bar | Sidebar with an icon rail |
| Content or docs site | Top bar and a menu | Top navigation |
Most apps need both, so the App Shell renders the Sidebar beside the page from 768px and, below it, the same sidebar in a drawer plus a tab bar for the daily destinations:
// app/(app)/layout.tsx with the App Shell
import type { ReactNode } from "react";
import { AppShell } from "@/components/ui/app-shell";
import { Sidebar } from "@/components/ui/sidebar";
import { Topbar } from "@/components/ui/topbar";
import { nav, tabs } from "./nav";
export default function AppLayout({ children }: { children: ReactNode }) {
return (
<AppShell sidebar={<Sidebar groups={nav} filter />} topbar={<Topbar title="Summit CRM" />} tabs={tabs}>
{children}
</AppShell>
);
}
nav holds every destination in groups and tabs the four people open every day. The shell renders the tab bar itself and reads the route from a LinkProvider (component={Link}, currentPath={usePathname()}) set once in a client provider; the Next.js admin dashboard tutorial builds that provider and the whole layout. The Admin Shell adds a More tab that opens the drawer.
If you came looking for a shadcn mobile bottom navigation: as of October 2026, the shadcn/ui component list has none, and its Sidebar becomes an 18rem Sheet below 768px, two taps from any destination. Material UI's Bottom Navigation is a fine react bottom navigation component, and the better pick if your app already runs on Material UI. As of October 2026 its docs cover labels, fixed positioning and router links, and leave badges, safe areas and hide on scroll to you.
What breaks a bottom navigation bar in production?
- Home is active on every page. A hand-rolled pathname.startsWith(href) matches / everywhere. The bar matches / only exactly and picks the longest matching href, so /clients/northwind-tech keeps Clients active.
- Tapping the active tab does nothing. People expect it to scroll to the top or go back to the tab's first screen. scrollToTopOnReselect does both, and onReselect can refresh a feed.
- A sixth tab. The bar warns in development above five items. Move the rest into a More tab or the drawer.
- Safari's floating toolbar. Since iOS 26, Safari's toolbar floats over the bottom of the page in the Compact tab layout, and a September 2025 forum report says Safari would not render fixed content below those controls. Test on a real iPhone in the Compact, Bottom and Top tab layouts.
- Two bars, two insets. A buy bar above the tab bar must not pad the home indicator again; the mobile-first React components guide shows the stacking.
What should you install?
npx wingo-ui@latest add bottom-tab-bar copies the component into components/ui/ with the Badge, the navigation and motion helpers and the scroll hooks it imports. The Bottom Tab Bar, FAB, Sidebar and App Shell are part of Wingo UI Pro; the Badge is free. More guides like this one live in Mobile UI in React.
Components in this post
- Bottom Tab Bar (Pro): The phone's primary navigation: 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
- FAB (Pro): The floating action button for a phone screen's main action, with a labelled or radial speed dial and a label that folds while you scroll. Install: npx wingo-ui@latest add fab
- Sidebar (Pro): The admin sidebar: grouped links with icons, nested sections, badges, a sliding active pill, an icon rail with flyouts and a filter. Install: npx wingo-ui@latest add sidebar
- App Shell (Pro): The whole admin layout in one component: a collapsible, resizable sidebar, a top bar and the page; a drawer and a tab bar on phones. Install: npx wingo-ui@latest add app-shell
FAQ
How many items should a bottom navigation bar have?
Three to five. Android's guidance lists three to five destinations of equal importance, and Apple's tab bar guidelines ask you to avoid overflow tabs. With more sections, keep the four most used in the bar and put everything in a drawer or a sidebar.
Does shadcn/ui have a bottom navigation component?
Not as of October 2026: its component list has no bottom navigation or tab bar, and its Sidebar turns into an 18rem Sheet below 768px. Wingo UI's Bottom Tab Bar, a Pro component, installs the same way, as source code in your project, with npx wingo-ui@latest add bottom-tab-bar.
Should a bottom navigation bar use role="tablist"?
No. The ARIA tabs pattern is for panels that switch inside one page. A bar that changes the page is navigation: a nav element with links, and aria-current="page" on the active one.
Should a bottom navigation bar hide on scroll?
Only on long feeds and reading screens, because a hidden bar also hides where people are in the app. If it hides, it should follow the finger, move with a transform only, and come back on scroll up, at the top and end of the page, and while keyboard focus is inside it.
Is the Wingo UI Bottom Tab Bar free?
No. The Wingo UI Bottom Tab Bar is a Pro component, like the FAB, the Sidebar and the App Shell; the Badge it uses for counts is free.
---
This article was originally published on the Wingo UI blog: React Bottom Navigation Bar in Next.js: Safe Areas, Badges. The original has the live, interactive demos and is kept up to date.
Mobile-First React Components: 9 Rules With Code
TL;DR
Mobile-first React components are designed for a thumb on a 390px screen first and enhanced for a mouse after. The nine rules: open popovers, selects, menus and dialogs as bottom sheets below 768px; make targets 44px on coarse pointers; never hide a control behind hover; pad fixed bars with the safe-area insets; size full-height layouts in dvh, not vh; keep bottom bars out of the on-screen keyboard; turn table rows into cards; give every gesture a visible alternative; and switch layout with CSS breakpoints. Keep media query hooks for behavior only, because the server does not know the screen width.
Open a typical React app on a phone and the cracks show in the same places: a dropdown menu hanging off the edge of the screen, a 32px icon button you have to aim for, a checkout bar hidden behind the home indicator, a data table you scroll sideways to read one row. Mobile-first React components fix those problems once, inside the component, so every screen that uses them gets them right without a media query in the page. This is the rulebook we follow in Wingo UI, with the reason and the code for each rule. We build Wingo UI, so we are not neutral; outside facts link to their sources and are dated.
What makes a React component mobile-first?
A mobile-first component is designed for a thumb on a 360 to 390px screen with an on-screen keyboard, and then enhanced for a mouse and a wide window. Beyond reflowing its layout, the component changes how it behaves: which overlay it opens, how big its hit area is, what replaces hover, and where it sits relative to the notch and the keyboard.
The nine rules in one table, then a section for each:
| Rule | On a phone | Why |
| --- | --- | --- |
| Overlays | Popovers, selects, menus and dialogs open as bottom sheets below 768px | An anchored panel has no room and sits under the thumb |
| Touch targets | 44px on coarse pointers, 8px apart | A finger is less precise than a cursor |
| Hover | Nothing is hover-only | Touch screens have no hover |
| Safe areas | Fixed bars pad env(safe-area-inset-*) | The notch and the home indicator cover content |
| Height | dvh or svh, never vh | 100vh is taller than the visible screen |
| Keyboard | Bottom bars hide or ride above it, inputs use 16px text | Fixed bars end up behind the keyboard |
| Tables | Rows become cards | Sideways scrolling hides most of every row |
| Gestures | Every gesture has a visible alternative | Gestures are invisible, and not everyone can perform them |
| Layout and behavior | CSS breakpoints for layout, a hook for behavior | The server does not know the screen width |
If you are building a React mobile first UI from scratch, apply each rule in every component before adding the next one: people learn an interface from its most common pattern.
Why should a popover become a bottom sheet on a phone?
A popover anchored to a small trigger has nowhere good to go on a 390px screen, while a bottom sheet opens where the thumb already is. The finger that tapped the trigger covers it, the panel gets clipped or flipped above the trigger, and a tap that lands just outside it closes it. A sheet takes the full width, has room for 48px rows, can be dragged away, and keeps its actions in the bottom part of the screen, the part a thumb reaches without changing grip.
We render a bottom sheet below 768px for the popover, select, dropdown menu, context menu and dialog, by default. The exceptions matter as much as the rule:
- Tooltips never become sheets. Nothing hovers on a touch screen, so the text goes inline or behind a long press.
- Alert dialogs stay centered at every width. A short question that must be answered reads like an iOS alert.
- Suggestions under a text field stay attached to it, because a sheet would cover what you type, as in the Mention Input. The Combobox takes the other route: a full-height sheet with its own search box.
In Wingo UI the switch is a responsive prop that defaults to true on the Popover, Select, Dropdown Menu, Dialog and the date pickers. You write the component once:
"use client";
import { ChevronDown } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Popover, PopoverClose } from "@/components/ui/popover";
export function PriceFilter({ onApply }: { onApply: () => void }) {
return (
<Popover
trigger={
<Button variant="soft" rightIcon={<ChevronDown />}>
Price
</Button>
}
title="Price"
description="Pick a range or type your own."
footer={
<PopoverClose asChild>
<Button size="sm" onClick={onApply}>
Apply
</Button>
</PopoverClose>
}
>
<p className="text-muted-foreground">Between $500 and $1,500</p>
</Popover>
);
}
On a desktop that is a floating panel with a right-aligned Apply button. Below 768px the same JSX opens a bottom sheet with a handle and a full-width 48px Apply button. Pass responsive={false} when a panel has to stay anchored to a field.
The sheet underneath is the free Drawer, built on vaul. It follows your finger, closes when you let go past a quarter of its height or flick it, stops at snap points, and lets a long list scroll first and drag the sheet only once the list is back at the top. Opening it moves focus to the sheet itself instead of the first field, so the keyboard does not jump up over half the content. From 768px it stays a centered sheet, or becomes a side panel or a dialog, depending on the desktop prop.
> Live demo: Drawer. The original article embeds an interactive demo here (Tap Filters: on a phone the sheet slides up and you can drag it down to close; on a wide screen this one opens as a side panel). Try it live on wingo-ui.com or open the Drawer docs. Install: npx wingo-ui@latest add drawer
The demo is a listings page: Filters opens a draggable bottom sheet below 768px and a panel from the right edge on wider screens, with the result count on a pinned button in both.
Two posts go deeper on this rule. The React bottom sheet guide covers snap points, drag to dismiss, nested sheets and the keyboard, and the shadcn responsive dialog tutorial builds the same card-to-sheet switch on stock shadcn/ui.
How do you switch between a sheet and a dialog without a hydration flash?
Use CSS breakpoints for anything visible on the first paint, and a media query hook only for behavior that starts after an interaction, such as which overlay opens. The server has no screen, so any hook that reads matchMedia has to return a guess during server rendering and hydration. When that guess drives visible layout, phones see the desktop layout first and then a jump.
The use-mobile hook that ships with shadcn/ui's sidebar starts as undefined, returns false, and flips in an effect (as of October 2026). Our free useMediaQuery uses the same 768px breakpoint, reads it through useSyncExternalStore and makes the server value a parameter. This is the core of it:
import { useCallback, useSyncExternalStore } from "react";
export function useMediaQuery(query: string, serverFallback = false) {
const subscribe = useCallback(
(onChange: () => void) => {
const list = window.matchMedia(query);
list.addEventListener("change", onChange);
return () => list.removeEventListener("change", onChange);
},
[query],
);
return useSyncExternalStore(
subscribe,
() => window.matchMedia(query).matches,
// the server and the hydration render
() => serverFallback,
);
}
export const useIsMobile = () => useMediaQuery("(max-width: 767.98px)");
Both hooks say false on the server. That is harmless for overlays, because an overlay is closed during server rendering: by the time someone taps the trigger, the hook holds the real value. It is harmful for content:
// wrong: the server renders the table, phones swap to cards after hydration
const isMobile = useIsMobile();
return isMobile ? <InvoiceCards rows={rows} /> : <InvoiceTable rows={rows} />;
// right: both are in the HTML and CSS picks one before the first paint
return (
<>
<InvoiceTable rows={rows} className="hidden md:table" />
<InvoiceCards rows={rows} className="md:hidden" />
</>
);
This is the part of mobile first design React apps get wrong without noticing, because the wrong version looks fine when you drag a desktop window narrow after the page has loaded. The jump only shows when a phone loads the server-rendered page. Our guide to an SSR-safe React use mobile hook tests three common versions of the hook through hydration and sorts which jobs belong to CSS.
How big should touch targets be?
Make every target 44 by 44 CSS pixels on touch screens and keep at least 8px between neighbors. The standards disagree on the exact number; 44px meets the strictest web criterion (WCAG level AAA) and Apple's default control size:
| Source (as of October 2026) | Size | Status |
| --- | --- | --- |
| WCAG 2.2, 2.5.8 Target Size (Minimum) | 24 by 24 CSS px, or enough spacing | Level AA |
| WCAG 2.2, 2.5.5 Target Size (Enhanced) | 44 by 44 CSS px | Level AAA |
| Apple Human Interface Guidelines | 44 by 44 pt default on iOS, 28 by 28 pt minimum | Guideline |
| Android accessibility guide | 48 by 48 dp | Recommendation |
The catch is the desktop. A 44px button everywhere makes a toolbar or a settings page look bloated next to a mouse pointer. Tailwind CSS v4 has a pointer-coarse variant that compiles to @media (pointer: coarse), so the size can follow the input device instead of the screen width. A tablet and a phone both get the bigger targets; a laptop with a trackpad does not.
For a text button the size is h-10 pointer-coarse:h-11: 40px with a mouse, 44px under a finger. An icon button that must stay small gets an invisible hit area instead:
import { Ellipsis } from "lucide-react";
// looks 32px, takes 44px taps on touch
export function MoreButton() {
return (
<button
type="button"
aria-label="More actions"
className="relative grid size-8 touch-manipulation place-items-center [-webkit-tap-highlight-color:transparent] pointer-coarse:after:absolute pointer-coarse:after:-inset-1.5"
>
<Ellipsis className="size-4" />
</button>
);
}
The ::after trick adds 6px on every side of the 32px button without moving anything. touch-manipulation turns off double-tap zoom on the control, so taps register without the browser waiting for a second one, and the transparent tap highlight removes the gray box mobile browsers paint on a tapped element. Both live on the component itself, because a component copied into another project does not bring your global stylesheet along. The Button in Wingo UI is built this way: md is 40px with a mouse and 44px on touch, sm and icon-sm stay 32px and get the invisible hit area, and lg is 48px everywhere. The minimum touch target size guide compares what WCAG, Apple and Android require and shows how to test the hit areas.
Why do hover effects break on phones?
Touch screens have no hover, so anything that only appears on hover does not exist on a phone. In Tailwind CSS v4 the hover: variant only applies when the primary input can hover (@media (hover: hover)). That fixes the old sticky hover state after a tap, and it also means a hover-only control never shows up on touch at all.
The classic case is a row action that fades in when the pointer is over the row. The fix is a focus variant and a coarse-pointer variant:
import { Ellipsis } from "lucide-react";
export function ClientRow({ name }: { name: string }) {
return (
<li className="group/row flex h-12 items-center justify-between gap-3 px-4">
{name}
<button
type="button"
aria-label={`Actions for ${name}`}
className="grid size-8 place-items-center opacity-0 transition-opacity group-hover/row:opacity-100 group-focus-within/row:opacity-100 pointer-coarse:opacity-100"
>
<Ellipsis className="size-4" />
</button>
</li>
);
}
Hover reveals it for a mouse, focus reveals it for a keyboard, and on a coarse pointer it is always there. The Data Table puts the same classes on the wrapper of its row menu trigger, plus has-[[data-state=open]]:opacity-100: without it, the trigger fades out while its menu is open, as soon as the pointer moves from the row into the menu. The other two replacements for hover are a tap and a long press: a hover card opens as a sheet on the first tap, a tooltip shows on a long press, and a context menu opens as an action sheet when you hold a row.
How do you keep fixed bars clear of the notch and the home indicator?
Set viewport-fit=cover, then pad every element pinned to a screen edge with env(safe-area-inset-*), with a minimum for devices that report zero. cover scales the viewport to fill the whole display, notch and home indicator included, and MDN highly recommends the safe area inset variables with it so important content stays visible.
In the Next.js App Router the viewport meta tag comes from a viewport export in the root layout:
// app/layout.tsx
import type { Viewport } from "next";
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
viewportFit: "cover",
};
Then pad with an arbitrary value: pb-[max(env(safe-area-inset-bottom),12px)]. A plain pb-[env(safe-area-inset-bottom)] leaves buttons touching the screen edge on devices without a home indicator, where the inset is 0; max() keeps a little air.
The harder problem is two bars at once. A product page on a phone often has a tab bar at the bottom and a buy bar above it, and both want the safe area. The Bottom Tab Bar pads the home indicator and writes its height to a --bottom-bar-offset CSS variable. The Sticky CTA Bar sits at bottom: var(--bottom-bar-offset) and pads only the part of the safe area the tab bar does not already cover, so there is no double gap:
"use client";
import { House, Search, ShoppingBag, UserRound } from "lucide-react";
import { BottomTabBar, type BottomTabBarItem } from "@/components/ui/bottom-tab-bar";
import { StickyCtaBar } from "@/components/ui/sticky-cta-bar";
const tabs: BottomTabBarItem[] = [
{ label: "Home", href: "/", icon: <House /> },
{ label: "Search", href: "/search", icon: <Search /> },
{ label: "Cart", href: "/cart", icon: <ShoppingBag />, badge: 2 },
{ label: "Account", href: "/account", icon: <UserRound /> },
];
export function ProductChrome({ onAddToCart }: { onAddToCart: () => Promise<void> }) {
return (
<>
<StickyCtaBar
price={349}
compareAtPrice={449}
locale="en-US"
actionLabel="Add to cart"
tone="brand"
hideFrom="md"
onAction={onAddToCart}
/>
<BottomTabBar items={tabs} className="md:hidden" hideOnScroll />
</>
);
}
The tab bar takes 3 to 5 tabs, each far larger than 44px. With hideOnScroll it follows your finger out of view on the way down and comes back on the way up, moved by a motion value so nothing re-renders per frame. The buy bar shows a spinner while the onAction promise runs without changing its width. The Tailwind safe area insets tutorial covers the utilities, a FAB stacked above the tab bar and where toasts go, and the React bottom navigation bar guide covers badges, the center action and when a sidebar fits better.
> Live demo: Bottom Tab Bar. The original article embeds an interactive demo here (Tap the tabs, then tap the active one again to jump back to the top; the raised Add button opens a sheet). Try it live on wingo-ui.com or open the Bottom Tab Bar docs. Install: npx wingo-ui@latest add bottom-tab-bar
The demo is a small CRM with four tabs, an unread count on Inbox and a raised Add action. Open a client, then tap Clients again: the tab goes back to its first screen, the way iOS tab bars do.
Should you use 100vh, dvh or svh on mobile?
Use dvh for app shells and sheets, svh for blocks that must not change height while scrolling, and never vh. MDN defines vh as equal to lvh, the large viewport measured with the browser toolbars retracted. While the address bar is showing, 100vh (Tailwind's h-screen) is taller than the visible area, and the bottom of your layout, often the button that matters, sits under the toolbar.
dvh follows the toolbars as they expand and retract, which is what a full-height shell wants. MDN also warns that dynamic units can resize content while the user scrolls, so a hero section sized in dvh may twitch; svh is the stable choice there. Tailwind CSS v4 ships h-dvh, min-h-dvh, h-svh and h-lvh. In Wingo UI the App Shell uses min-h-dvh and bottom sheets cap at max-h-[92dvh], so a tall sheet keeps a strip of the page visible above it whatever the toolbars do. The 100vh mobile fix compares the three units in detail and builds both kinds of full-height shell.
What happens to bottom bars when the on-screen keyboard opens?
They end up behind the keyboard unless you move them. Since Chrome 108 on Android, the keyboard resizes only the visual viewport, which the Chrome team describes as matching Safari on iOS. An element with position: fixed; bottom: 0 is placed against the layout viewport, which still reaches the bottom of the screen, behind the keyboard.
There are two good answers. Hide the bar while a field has focus, which suits a checkout bar with a coupon field (the Sticky CTA Bar's default, keyboard="hide"). Or lift it by the height the keyboard covers, which suits a chat composer (keyboard="lift"). The lift comes from the visual viewport: the covered height is innerHeight - visualViewport.height - visualViewport.offsetTop while a text field has focus. The useKeyboardInset hook (Pro) measures it at most once per frame and exposes it as a motion value, so the bar moves without a re-render:
"use client";
import { motion, useTransform } from "motion/react";
import { useKeyboardInset } from "@/hooks/use-keyboard-inset";
export function Composer() {
const { inset } = useKeyboardInset();
const y = useTransform(inset, (value) => -value);
return (
<motion.form style={{ y }} className="fixed inset-x-0 bottom-0 bg-background px-4 pt-2 pb-[max(env(safe-area-inset-bottom),12px)]">
<input
aria-label="Message"
inputMode="text"
enterKeyHint="send"
autoComplete="off"
className="h-11 w-full rounded-full bg-foreground/5 px-4 text-base md:text-sm"
/>
</motion.form>
);
}
Two more keyboard rules. Give text inputs 16px text on phones (text-base md:text-sm): iOS Safari zooms the page into any focused field with smaller text and does not zoom back out. And set inputMode, enterKeyHint and autoComplete on every field, so the phone shows the right keyboard, the right return key label and the right autofill.
How should a data table work on a phone?
Below 768px, turn every row into a card: the primary field as the title, a status badge or an amount in the corner, two to four secondary fields as label and value pairs, and the row actions in a menu that opens as a sheet. Horizontal scrolling is acceptable only when the job is comparing columns, like a spreadsheet or a pricing matrix. For a list of invoices or clients it hides most of every row and makes people scroll in two directions to read one record.
The Data Table does this by default (mobileLayout="cards"). Each column can say where it goes on the card with a mobile role, and the table guesses sensibly when you do not (the first column is the title, the first badge or else the last number is the meta):
"use client";
import { Pencil } from "lucide-react";
import { DataTable, createColumns } from "@/components/ui/data-table";
type Invoice = { id: string; client: string; city: string; status: string; amount: number };
const col = createColumns<Invoice>();
const columns = col([
col.accessor("client", { header: "Client", mobile: "title" }),
col.accessor("city", { header: "City", mobile: "subtitle" }),
col.accessor("status", {
header: "Status",
type: "badge",
badge: { Paid: { tone: "success" }, Overdue: { tone: "danger" }, Draft: { tone: "neutral" } },
mobile: "meta",
}),
col.accessor("amount", { header: "Amount", type: "currency" }),
col.accessor("id", { header: "Invoice", mobile: "hidden" }),
]);
export function Invoices({ invoices }: { invoices: Invoice[] }) {
return (
<DataTable
data={invoices}
columns={columns}
caption="Invoices"
locale="en-US"
selectionMode="multiple"
rowActions={(row) => [{ label: "Edit", icon: <Pencil />, onSelect: () => console.log(row.id) }]}
/>
);
}
On a phone you long press a card to start selecting, tap to add more, and the bulk actions float at the bottom. Sorting, filters and the row menu open as bottom sheets, and Load more appends rows instead of a pager with tiny page numbers. The table and the cards are both in the server HTML, hidden and shown with breakpoints, so a phone never sees the table first. The TanStack Table v9 data table tutorial builds filters, bulk actions and server pagination on TanStack directly, along with the phone layout.
> Live demo: Data Table. The original article embeds an interactive demo here (On a phone every row is a card you can long press to select; on a wide screen you get the full table with sorting and column menus). Try it live on wingo-ui.com or open the Data Table docs. Install: npx wingo-ui@latest add data-table
The demo is a client list with search, quick filters, status badges and balances. Below 768px each row becomes a card with the client name as the title and the status in the corner. Which columns make it onto a card, and how sorting and bulk actions work without headers, are in the responsive table mobile guide.
Which gestures belong in a mobile web app?
Drag to dismiss, pull to refresh, swipe actions on rows and hold to confirm all belong, as long as each has a visible alternative and leaves native scrolling alone. Gestures are invisible until someone shows you, and people using a keyboard, a switch or a screen reader cannot perform them at all.
- Always a button path. Sheets close with Escape, a tap outside or a close button. Pull to refresh pairs with a refresh button. Swipe actions on a row also live in its menu.
- touch-action on draggable surfaces. touch-pan-y on something you swipe sideways keeps vertical scrolling native; touch-none is for surfaces you drag freely, like a signature pad or a slider thumb.
- overscroll-behavior: contain on scroll containers. It stops a scroll inside a sheet from chaining into the page, and keeps the browser's own pull to refresh from fighting yours.
- Motion values, not state. A drag that calls setState on every pointer move re-renders on every frame. Write the offset to a motion value and only commit state when the gesture ends.
Sortable lists and boards are where these rules collide with native scrolling; the dnd kit mobile tutorial sets up touch sensors that let a swipe still scroll the page.
The Pull To Refresh shows where the rules bend. It sets overscroll-behavior-y: contain on its scroller and moves the content with a motion value under rubberband resistance. It cannot use touch-action, because the list it wraps must keep scrolling natively, so it listens to touchmove with passive: false and cancels the scroll only for a downward pull at the very top. It ticks when the pull arms, spins until your promise settles (500ms at least, so it never flashes), and exposes the same refresh as an API for a button (the React pull to refresh guide covers the indicator styles, accessibility and long virtualized lists):
"use client";
import { useState, type ReactNode } from "react";
import { RotateCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { PullToRefresh, type PullToRefreshApi } from "@/components/ui/pull-to-refresh";
export function Feed({ reload, children }: { reload: () => Promise<void>; children: ReactNode }) {
const [api, setApi] = useState<PullToRefreshApi>();
return (
<div className="flex h-dvh flex-col">
<header className="flex h-14 items-center justify-end px-4">
<Button variant="ghost" size="icon" radius="full" aria-label="Reload" onClick={() => api?.refresh()}>
<RotateCw />
</Button>
</header>
<PullToRefresh scroll="self" className="min-h-0 flex-1" setApi={setApi} onRefresh={reload}>
{children}
</PullToRefresh>
</div>
);
}
Hold to confirm is the gesture for actions that deserve more than a tap and less than a dialog, like deleting a client. The Haptic Button in mode="hold" fills while you hold a finger, the mouse button, Space or Enter, and runs onConfirm when the fill completes. Screen readers and voice control, which activate with a plain click and cannot hold, get a two-step path instead: the first activation announces "Activate again to confirm" and a second one within 4 seconds confirms.
"use client";
import { Trash2 } from "lucide-react";
import { HapticButton } from "@/components/ui/haptic-button";
export function DeleteClient({ onDelete }: { onDelete: () => Promise<void> }) {
return (
<HapticButton tone="danger" variant="soft" mode="hold" fullWidth leftIcon={<Trash2 />} onConfirm={onDelete}>
Hold to delete
</HapticButton>
);
}
On Android the tick uses navigator.vibrate. Safari on iPhone has no Vibration API, so the haptics helper falls back to clicking a hidden native switch (<input type="checkbox" switch>). That played the system tick up to iOS 26.4; from iOS 26.5 WebKit ticks only for the user's own tap on the switch, so on a current iPhone the fill carries the feedback on its own. Desktops and mouse clicks stay silent. The Web Vibration API guide covers what still ticks on an iPhone and how to let people turn haptics off.
Does shadcn/ui handle mobile?
Partly. As of October 2026 the shadcn mobile story is one component and one pattern. The Drawer docs come in a Base UI version, which now uses Base UI's drawer instead of Vaul, and a Radix version, which is still built on Vaul. Both include a Responsive section that renders a Dialog on desktop and a Drawer on mobile; the example decides with useMediaQuery("(min-width: 768px)") and shares one form between both branches. The Popover docs describe no mobile behavior, and Select and Dropdown Menu stay floating panels at every width unless you write a wrapper for each.
That suits a library meant as a minimal, unopinionated starting point. shadcn/ui is free and MIT licensed, its GitHub repository shows about 125k stars (as of October 2026), and its docs let you choose between Base UI, Radix and React Aria primitives. If your app is used mostly on desktops, or you want to write and own every responsive wrapper yourself, it is the better choice. Our comparison of the best React component libraries for mobile checks shadcn/ui and eight others at 390px.
If most of your users arrive on phones, the wrappers add up: every overlay needs the swap, every control needs the coarse-pointer size, every fixed bar needs the safe area, and every one of them has to avoid the hydration flash. Wingo UI makes those decisions inside each component, so the defaults are already mobile-first and you opt out per instance.
Which components does a React mobile web app need?
A React mobile web app usually needs overlays that become sheets, a tab bar for the primary navigation, a bottom action bar on product and checkout pages, lists with swipe actions, and a table that becomes cards. Here is the set in Wingo UI (registry 1.7.0, 326 items as of October 2026), with what each one does on a phone:
| Component | Job on a phone | Access |
| --- | --- | --- |
| Drawer | The bottom sheet: drag to dismiss, snap points, nesting | Free |
| Popover, Select, Dropdown Menu, Dialog | Open as bottom sheets below 768px | Free |
| Segmented Control | A few options on a thumb you can drag | Free |
| Bottom Tab Bar | 3 to 5 primary destinations at the thumb | Pro |
| Sticky CTA Bar | Price and the main action, above the tab bar | Pro |
| Pull To Refresh | Reload a list with a pull, or a button | Pro |
| List | Grouped rows with swipe actions and drag to reorder | Pro |
| Data Table | Rows as cards, long press to select | Pro |
| Haptic Button | Press feedback, haptics and hold to confirm | Pro |
| Large Title Header | An iOS large title that collapses into a bar | Pro |
| Action Sheet | A short list of actions about one thing | Pro |
| Picker Wheel | iOS wheels for times and dates | Pro |
The whole Mobile category has 15 components, all built to these rules, with reduced motion support and designed light and dark themes.
How do you test mobile-first React components?
Test at 390 by 844 and 360 by 780 with touch enabled, then on a real iPhone for the parts emulators get wrong. Chrome's device mode switches the pointer to coarse, which is enough to see pointer-coarse styles and the sheets. It does not reproduce the iOS keyboard, the focus zoom, the home indicator or the toolbar resizing, so those need a phone.
For automated checks, Playwright can emulate a touch phone per test file:
import { expect, test } from "@playwright/test";
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true });
test("filters open as a bottom sheet on a phone", async ({ page }) => {
await page.goto("/listings");
await page.getByRole("button", { name: "Filters" }).tap();
await expect(page.getByRole("dialog", { name: "Filters" })).toBeVisible();
});
Run the same pass in light and dark and with reduced motion turned on. A sheet handle or a hairline drawn at a low opacity can be clear in one theme and vanish in the other, and under reduced motion every overlay still has to open and close, only with a shorter slide or a fade.
Which guides go deeper on each rule?
Each of these posts in Mobile UI in React takes one rule or one component further:
- Shadcn responsive dialog: a centered card on desktop and a bottom sheet on phones, with the content written once.
- React bottom sheet for the web: snap points, drag to dismiss, nested sheets and the keyboard on vaul.
- Minimum touch target size: the WCAG, iOS and Android numbers, and 44px on touch in Tailwind without a bloated desktop.
- Tailwind safe area insets: bottom bars, FABs and toasts clear of the notch and the home indicator.
- 100vh on mobile: when to use dvh, svh or lvh, and full-height shells that do not jump.
- React bottom navigation bar: a tab bar with badges, a center action and hide on scroll.
- React pull to refresh: a custom refresh in a PWA that does not fight the browser's own.
- Web Vibration API: the haptic feedback a page can trigger on Android and iPhone.
- React PWA install prompt: an install prompt for Chrome, Edge and iPhone, and when not to ask.
- Responsive tables on mobile: when rows become cards, which columns survive, and sorting and bulk actions without headers.
- React segmented control: the iOS thumb you can drag, radio semantics, and when tabs or a toggle group fit better.
- React wheel picker: iOS wheels for times and dates on a native snapping scroller, with a typed field on desktop.
Where should you start?
Start with the overlays, because they are the most visible mobile failure and the cheapest to fix. Install the free Popover with npx wingo-ui@latest add popover (it brings the Drawer, the Button and the media query hook along), swap it in for one panel that people open on phones, and try it on your own device. The tab bar, sticky CTA bar, pull to refresh, haptic button and data table are part of Wingo UI Pro.
Components in this post
- Drawer (Free): The library's bottom sheet on vaul: drag to dismiss, snap points, nesting, and a side panel or dialog on desktop. Install: npx wingo-ui@latest add drawer
- Popover (Free): Rich floating content anchored to a trigger, like a quick filter, form or share panel, that becomes a bottom sheet on phones. Install: npx wingo-ui@latest add popover
- Bottom Tab Bar (Pro): The phone's primary navigation: 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
- Pull To Refresh (Pro): Pull a list down past its top to reload it, with rubberband resistance, an arrow, iOS spokes or a Material ring, and a haptic tick. Install: npx wingo-ui@latest add pull-to-refresh
- Sticky CTA Bar (Pro): The bottom action bar of product, listing and checkout pages on phones: the price on one side, the main action on the other. Install: npx wingo-ui@latest add sticky-cta-bar
- Data Table (Pro): The admin table on TanStack Table v9: search, filters, sorting, bulk actions, pinning, resizing, virtualization and cards on phones. Install: npx wingo-ui@latest add data-table
- Haptic Button (Pro): A button that answers like a native control: spring press, touch ripple, a haptic tick, a long-press action and a hold-to-confirm mode. Install: npx wingo-ui@latest add haptic-button
FAQ
What is a mobile-first React component?
A component designed for touch, a narrow screen and an on-screen keyboard first, then enhanced for a mouse and a wide window. On a phone it changes its behavior as well as its layout: it opens a bottom sheet instead of a popover, keeps 44px hit areas and has no hover-only controls.
How big should buttons be in a mobile web app?
WCAG 2.2 asks for 24 by 24 CSS pixels at level AA and 44 by 44 at level AAA. Apple's guidelines use 44 by 44 points as the default iOS control size and Android recommends 48 by 48 dp. A practical rule for the web is 44px on coarse pointers with 8px between targets.
Should I use a useIsMobile hook or CSS media queries?
Use CSS breakpoints for anything visible on the first paint, because the server cannot know the screen width and a hook returns a guess there. Use the hook for behavior that starts after an interaction, such as opening a bottom sheet instead of a popover.
Does shadcn/ui have a bottom sheet for mobile?
Yes, the Drawer. As of October 2026 its Base UI version is built on Base UI's drawer and its Radix version on Vaul, and the docs show a responsive example that renders a Dialog on desktop and a Drawer on mobile. Popover, Select and Dropdown Menu do not switch to a sheet on their own.
Why do hover effects not work on phones?
Touch screens have no hover, and in Tailwind CSS v4 the hover variant only applies when the primary input can hover, so a control that only appears on hover never shows up on a phone. Reveal it on focus and on coarse pointers as well, or replace the hover with a tap or a long press.
Which Wingo UI mobile components are free?
The Drawer, Sheet, Popover, Dialog, Select, Dropdown Menu, Segmented Control, Button and the useMediaQuery hook are free, and the Popover, Dialog, Select and Dropdown Menu open as bottom sheets below 768px by default. The 15 components of the Mobile category (tab bar, sticky CTA bar, pull to refresh, haptic button and more) and the Data Table are Pro.
---
This article was originally published on the Wingo UI blog: Mobile-First React Components: 9 Rules With Code. The original has the live, interactive demos and is kept up to date.
1 reaction · 1 comment
做 React 页面,按钮和表单又要从头搭?🎨
shadcn/ui 提供可定制的组件代码。
想改样式,就直接改源码。🧩
shadcn-ui/ui
#React #WebDev #浇朋友 #蓝朋友 #互关 #fo
1 like · 75 views
Want a polished UI without being trapped in a component black box?
ui (~126k★): Customizable UI components you can own. shadcn-ui/ui
Which component should every starter kit include?
1 like · 9 views
shadcn's select component has no reset method. the top answer in the thread: give it a key prop tied to a counter, bump the counter to force a remount, which clears the value. a plain form reset won't touch it. ui#638
2 likes
why does shadcn's resizable component suddenly throw after installing the latest react-resizable-panels? the new version renamed the panel-group and resize-handle exports; shadcn still imports the old names. ui#9450
2 likes
Mentions
Nextjs Admin Dashboard Tutorial: 5 Screens, Phone-Ready TL;DR Build a Next.js admin dashboard as five screens in one App Router route group: an app shell layout, a home page with KPIs, list pages with a server-side data table whose state lives in the URL, a settings page and a team page with roles. Pages fetch through a server-only data access layer, and every change goes through a Server Action that checks the session itself. On phones the sidebar becomes a drawer with a bottom tab bar, table rows become cards and menus open as bottom sheets. Most admin dashboard tutorials stop at a sidebar, four stat cards, a chart and a table that scrolls sideways on a phone. A back office needs more: list pages that filter on the server, a settings page that keeps what someone typed when a save fails, a team page with roles, loading and error states on every route, and a layout that works when the person approving an invoice is on a phone. This nextjs admin dashboard tutorial builds all of it on the Next.js App Router with React 19 and Tailwind CSS v4, screen by screen, with the code. We build Wingo UI and the examples use our blocks, so we are not neutral; the App Router patterns work with any component library, and the last section compares the free starters. What does a Next.js admin dashboard need? Five screens and two states: a shell around a home page, list pages, a settings page and a team page, each with a loading and an error state. The React admin dashboard components we use for each, and their phone behavior: | Screen | Its job | Built with | On a phone | | --- | --- | --- | --- | | Shell | Navigation, search, the account | App Shell with Sidebar | The sidebar moves into a drawer, a tab bar takes the bottom edge | | Home | What changed, what needs attention | Admin Dashboard Page or KPI Dashboard | KPIs two per row, panels in one column | | Lists | Find, filter and act on records | Data Table in server mode | Rows become cards, menus become bottom sheets | | Settings | Profile, account, billing, security | Settings Page | A list that pushes each section in, like iOS Settings | | Team | Members, roles, audit log | Users Admin Page | Member cards, one role at a time in the matrix | | Loading and errors | Every route | loading.tsx, error.tsx and each block's loading prop | Skeletons in the phone layout | How should you structure the App Router for an admin dashboard? Put every admin route in one route group with its own layout. A folder in parentheses does not appear in the URL, so app/(admin)/clients/page.tsx serves /clients while the group's layout.tsx wraps it in the shell. Sign-in lives in its own group and never sees the sidebar. app/ layout.tsx root: html, body, <Providers> providers.tsx LinkProvider (next/link) and the Toaster (auth)/ sign-in/page.tsx no shell (admin)/ layout.tsx the shell: AppShell, Sidebar, Topbar nav.tsx sidebar groups and phone tabs error.tsx the admin error boundary page.tsx /: the home with KPIs dashboard-home.tsx actions.ts order actions for the home clients/ page.tsx reads the URL, fetches one page clients-table.tsx query.ts loading.tsx [id]/page.tsx one client settings/ page.tsx settings-view.tsx actions.ts team/ page.tsx team-view.tsx actions.ts server/ dal.ts "server-only": verifySession, getUser, requireAdmin Three rules hold it together: 1. Pages fetch, views render. Each page.tsx is a server component that loads data. The blocks are client components, so function props (a save handler, a router call) live in a small *-view.tsx next to the page. 2. Auth lives in the data access layer. Layouts do not re-render on navigation, so a layout check misses route changes. Call verifySession() from a server-only module in every page and every Server Action; the data security guide says to treat each Server Action as reachable by a direct POST request. 3. The URL holds the view. Period, filters, page, open tab: if it changes what the screen shows, it goes in the search params, so a link reproduces the screen. How do you build the app shell for a Next.js admin dashboard? Render one AppShell in the group's layout with a Sidebar and a Topbar, and read the sidebar's cookie on the server so the first paint is right. The shell shows the sidebar beside the page on desktop, and a drawer plus a bottom tab bar on phones. It switches with CSS, so the server HTML is already right. First, the router wiring: the Wingo UI navigation links render through LinkProvider, so one client provider turns them all into next/link: // app/providers.tsx "use client"; import type { ReactNode } from "react"; import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; import { Toaster } from "@/components/ui/toast"; import { LinkProvider } from "@/lib/navigation"; export function Providers({ children }: { children: ReactNode }) { const router = useRouter(); return ( <LinkProvider component={Link} currentPath={usePathname()} navigate={router.push}> {children} {/* phones have a tab bar at the bottom, so toasts drop from the top */} <Toaster mobilePosition="top" /> </LinkProvider> ); } The sidebar and the tab bar share the NavItem shape, so one file feeds both: // app/(admin)/nav.tsx import { House, Settings, ShieldCheck, Users } from "lucide-react"; import type { SidebarNavGroup } from "@/components/ui/sidebar"; import type { NavItem } from "@/lib/navigation"; export const nav: SidebarNavGroup[] = [ { items: [{ label: "Home", href: "/", icon: <House /> }] }, { label: "Sales", items: [{ label: "Clients", href: "/clients", icon: <Users />, badge: 3 }] }, { label: "Workspace", items: [ { label: "Team", href: "/team", icon: <ShieldCheck /> }, { label: "Settings", href: "/settings", icon: <Settings /> }, ], }, ]; export const tabs: NavItem[] = [ { label: "Home", href: "/", icon: <House />, match: "exact" }, { label: "Clients", href: "/clients", icon: <Users /> }, { label: "Team", href: "/team", icon: <ShieldCheck /> }, { label: "Settings", href: "/settings", icon: <Settings /> }, ]; And the layout, a server component: // app/(admin)/layout.tsx import type { ReactNode } from "react"; import { cookies } from "next/headers"; import { AppShell } from "@/components/ui/app-shell"; import { Sidebar } from "@/components/ui/sidebar"; import { Topbar } from "@/components/ui/topbar"; import { getUser } from "@/server/dal"; import { nav, tabs } from "./nav"; export default async function AdminLayout({ children }: { children: ReactNode }) { const [store, user] = await Promise.all([cookies(), getUser()]); // the shell writes `${persistKey}-collapsed` ("app-shell" by default) as "true" or "false" const collapsed = store.get("app-shell-collapsed")?.value === "true"; return ( <AppShell defaultCollapsed={collapsed} mobileBreakpoint="lg" sidebar={<Sidebar groups={nav} filter user={{ name: user.name, email: user.email, avatar: user.avatarUrl }} />} topbar={<Topbar title="Northwind" />} tabs={tabs} > {children} </AppShell> ); } Mod+B (Cmd on a Mac, Ctrl elsewhere) folds the sidebar into a 64px icon rail and stores that in the app-shell-collapsed cookie. Without the server read, a user who collapsed it sees it expanded on every first paint and then snap to the rail after hydration. Use the literal cookie name on the server: the persistedCookieName helper lives in a hook file that imports client-only React hooks, which a server layout cannot import. Below the breakpoint the sidebar becomes a drawer that closes on navigation, Escape, a tap outside or a swipe back, and focus returns to the menu button. A "Skip to content" link is the first Tab stop. We pass mobileBreakpoint="lg" (1024px) on purpose. At md (768px), an 820px tablet in portrait keeps a 256px sidebar and 564px for the page, too little for a table. At lg it gets the phone layout and the full width. Keep the awaits in this layout cheap: a slow await here holds every page behind it. A cookie read and a cached session lookup are fine; a dashboard query is not. For comparison, the shadcn/ui Sidebar (as of October 2026) stores its open state in a sidebar_state cookie for seven days and renders a Sheet when its useIsMobile hook reports a phone. It has no bottom tab bar, so on a phone every destination is two taps away. For the whole back office layout in one block, the Admin Shell adds a workspace switcher, a global search on /, notifications, a command menu on Mod+K and a five-tab bar whose More tab opens the drawer. Our React command palette guide covers that menu's shortcuts, nested pages and phone sheet. How do you build the dashboard home with KPIs? Fetch the numbers in the page for the period in the URL, and pass them to a dashboard block as props. The Admin Dashboard Page is the home of an orders business (four KPIs, revenue against the previous period, recent orders, what needs attention, team activity); the KPI Dashboard suits a B2B or analytics app. The page reads the period, defaults to the last 30 days and fetches: // app/(admin)/page.tsx import { isValid, parseISO, startOfDay, subDays } from "date-fns"; import { getDashboard } from "@/server/dashboard"; import { getUser } from "@/server/dal"; import { DashboardHome } from "./dashboard-home"; function readPeriod(from?: string, to?: string) { const today = startOfDay(new Date()); const end = to ? parseISO(to) : today; const start = from ? parseISO(from) : subDays(end, 29); return isValid(start) && isValid(end) ? { from: start, to: end } : { from: subDays(today, 29), to: today }; } export default async function HomePage({ searchParams }: { searchParams: Promise<{ from?: string; to?: string }> }) { const { from, to } = await searchParams; const period = readPeriod(from, to); // { kpis, revenue, orders, tasks, activity, topProducts } for the period const [user, data] = await Promise.all([getUser(), getDashboard(period)]); return <DashboardHome firstName={user.firstName} period={period} data={data} />; } The view writes a new period back to the URL and sends order actions to Server Actions: // app/(admin)/dashboard-home.tsx "use client"; import { useRouter } from "next/navigation"; import { format } from "date-fns"; import { AdminDashboardPage } from "@/components/blocks/admin-dashboard-page"; import type { DashboardData } from "@/components/blocks/admin-dashboard-page-data"; import type { DateRange } from "@/components/ui/date-range-picker"; import { runOrderAction } from "./actions"; type Props = { firstName: string; period: DateRange; data: DashboardData }; export function DashboardHome({ firstName, period, data }: Props) { const router = useRouter(); return ( <AdminDashboardPage {...data} userName={firstName} defaultPeriod={period} onPeriodChange={({ from, to }) => { if (!from || !to) return; const params = new URLSearchParams({ from: format(from, "yyyy-MM-dd"), to: format(to, "yyyy-MM-dd") }); router.replace(`/?${params}`, { scroll: false }); }} onOrderAction={async (action, order) => { const { error } = await runOrderAction(action, order.id); if (error) throw new Error(error); }} /> ); } The period is uncontrolled on purpose (defaultPeriod): the date field updates the moment someone applies a range, and the panels follow when the server answers. onOrderAction is optimistic: the status changes at once, the row shows a spinner while the promise runs, and a rejection rolls it back with an error toast, which is why the handler throws on an error. One catch: after a cancel, the toast's Undo restores the row on screen only and calls none of your code, so the next server render shows the order as cancelled again. Rewrite labels.cancelDescription so users know a cancel is final; its default text promises an email and a refund, which only your server can keep. > Live demo: Admin Dashboard Page. The original article embeds an interactive demo here (Pick another period in the date field, then click an order in Recent orders to open its sheet. This frame is narrower than 1024px, so you see the phone layout: the sidebar sits behind the menu button and the tab bar holds the bottom edge). Try it live on wingo-ui.com or open the Admin Dashboard Page docs. Install: npx wingo-ui@latest add admin-dashboard-page On a phone the KPIs sit two by two, recent orders become cards with Load more, and the date field and the order details open as bottom sheets you drag down to close. When should you use the KPI Dashboard instead? When the home is about analytics more than orders: revenue over time, sales by source, a conversion funnel, top salespeople and invoices. It takes one data object and hides a widget whose data is missing, so it fits a company with no funnel. It has the controls an analytics page needs: onRefresh (a promise spins the button and announces the update), onExport with CSV and PDF, a widgets list to pick and order panels, and widgetErrors with onWidgetRetry to show an error and a Try again button inside the one widget whose query failed, so the rest of the page stays up. Its grid uses container queries: one column, two from 672px and three from 1152px of its own width. Inside a shell that matters, because a 1280px window with a 256px sidebar leaves about 1000px, and viewport breakpoints would lay the grid out for space it does not have. The DashboardWidget and KpiDashboardGrid parts are exported for your own panels. Can you render KPI cards from a server component? Yes, as long as every prop is data. The Stat Card takes numbers, strings and icons, so a server component can render a row directly: // app/(admin)/reports/page.tsx import { FileText, Timer, UserPlus, Wallet } from "lucide-react"; import { StatCard, StatGroup } from "@/components/ui/stat-card"; import { getKpis } from "@/server/reports"; export default async function ReportsPage() { const k = await getKpis(); return ( <StatGroup variant="joined"> <StatCard label="Revenue" icon={<Wallet />} value={k.revenue} format="currency" delta={k.revenueChange} deltaLabel="vs. last month" sparkline={k.dailyRevenue} goal={k.revenueGoal} locale="en-US" /> <StatCard label="Invoices issued" icon={<FileText />} value={k.invoices} delta={k.invoicesChange} deltaFormat="number" locale="en-US" /> <StatCard label="New clients" icon={<UserPlus />} value={k.newClients} delta={k.newClientsChange} locale="en-US" /> <StatCard label="Time to payment" icon={<Timer />} value={k.secondsToPay} format="duration" delta={k.timeToPayChange} invertTrend locale="en-US" /> </StatGroup> ); } The server HTML holds the final numbers, the count-up runs on the client when a card scrolls into view, and screen readers hear one sentence with the final value and the change. invertTrend makes a drop in time to payment green. Two limits: a function prop such as formatDelta needs a client file, and href renders a plain anchor (a full page load in Next.js), so linked tiles need a client wrapper whose onClick calls event.preventDefault() and then router.push. How do you build list pages with a server-side data table? Run the Data Table in server mode, keep its query in the URL, and let the page fetch one page at a time. In mode="server" the table does no sorting, filtering or paging itself: data is the current page, rowCount the total, and every change arrives in onQueryChange as one object (page, pageSize, sorting, globalFilter, columnFilters). One module translates it to and from the search params: // app/(admin)/clients/query.ts import type { DataTableQuery } from "@/components/ui/data-table"; type Params = Record<string, string | string[] | undefined>; const one = (value: string | string[] | undefined) => (Array.isArray(value) ? value[0] : value) ?? ""; export function readQuery(params: Params): DataTableQuery { const [sortId, dir] = one(params.sort).split("."); const status = one(params.status); const size = Number(one(params.size)); return { page: Math.max(1, Number(one(params.page)) || 1), pageSize: [10, 25, 50, 100].includes(size) ? size : 25, sorting: sortId ? [{ id: sortId, desc: dir === "desc" }] : [], globalFilter: one(params.q), columnFilters: status ? [{ id: "status", value: status.split(",") }] : [], }; } export function writeQuery(query: DataTableQuery): string { const params = new URLSearchParams(); if (query.page > 1) params.set("page", String(query.page)); if (query.pageSize !== 25) params.set("size", String(query.pageSize)); if (query.globalFilter) params.set("q", query.globalFilter); const sort = query.sorting[0]; if (sort) params.set("sort", `${sort.id}.${sort.desc ? "desc" : "asc"}`); const status = query.columnFilters.find((filter) => filter.id === "status")?.value; if (Array.isArray(status) && status.length) params.set("status", status.join(",")); return params.toString(); } The page parses the URL and queries one page: // app/(admin)/clients/page.tsx import { listClients } from "@/server/clients"; import { ClientsTable } from "./clients-table"; import { readQuery } from "./query"; type Search = Promise<Record<string, string | string[] | undefined>>; export default async function ClientsPage({ searchParams }: { searchParams: Search }) { const query = readQuery(await searchParams); // checks the session, whitelists sort ids and filter values before building SQL const { rows, total } = await listClients(query); return <ClientsTable rows={rows} total={total} initial={query} />; } The view starts from the URL and writes changes back in a transition, so isPending drives the refreshing bar: // app/(admin)/clients/clients-table.tsx "use client"; import { useTransition } from "react"; import { useRouter } from "next/navigation"; import { DataTable, createColumns, type DataTableQuery } from "@/components/ui/data-table"; import { writeQuery } from "./query"; export type ClientRow = { id: string; name: string; city: string; status: string; balance: number; lastInvoice: string | null }; const STATUSES = ["Active", "Overdue", "Prospect"]; const col = createColumns<ClientRow>(); const columns = col([ col.accessor("name", { header: "Client", mobile: "title" }), col.accessor("city", { header: "City", mobile: "subtitle" }), col.accessor("status", { header: "Status", type: "badge", badge: { Active: { tone: "success" }, Overdue: { tone: "danger" }, Prospect: { tone: "info" } }, filter: "multi-select", // server mode only sees one page, so list every option yourself filterOptions: STATUSES.map((status) => ({ value: status, label: status })), mobile: "meta", }), col.accessor("balance", { header: "Balance", type: "currency" }), col.accessor("lastInvoice", { header: "Last invoice", type: "relative" }), ]); type Props = { rows: ClientRow[]; total: number; initial: DataTableQuery; loading?: boolean }; export function ClientsTable({ rows, total, initial, loading }: Props) { const router = useRouter(); const [pending, startTransition] = useTransition(); return ( <DataTable data={rows} columns={columns} mode="server" rowCount={total} defaultPage={initial.page} defaultPageSize={initial.pageSize} defaultSorting={initial.sorting} defaultGlobalFilter={initial.globalFilter} defaultColumnFilters={initial.columnFilters} onQueryChange={(query) => { const search = writeQuery(query); startTransition(() => router.replace(search ? `/clients?${search}` : "/clients", { scroll: false })); }} refreshing={pending} loading={loading} quickFilters={[{ id: "overdue", label: "Overdue", columnFilter: { id: "status", value: ["Overdue"] } }]} onRowClick={(row) => router.push(`/clients/${row.id}`)} caption="Clients" locale="en-US" /> ); } Details that save debugging time: onQueryChange does not fire on the first render (the server already sent that page), the search arrives debounced (300ms), and the page resets to 1 when the search, a filter or the sort changes. The table is uncontrolled (default* props), so the search box never lags behind a server round trip. The trade-off: the back button changes the data while the toolbar keeps the last query. If your users live on the back button, control page, sorting, globalFilter and columnFilters from the URL instead. > Live demo: Data Table. The original article embeds an interactive demo here (Search, sort or click the Overdue chip: in server mode the table hands your code one query object, printed under the pager. Below 768px every row is a card you can long press to select). Try it live on wingo-ui.com or open the Data Table docs. Install: npx wingo-ui@latest add data-table On a phone the columns become cards: mobile: "title" is the heading, subtitle the line under it, meta the badge in the corner. Sorting, filters and the row menu open as bottom sheets, and with selectionMode on, a long press starts selection. Server mode keeps a compact pager on phones; client mode uses Load more. Server mode is not always the right call. When one request can load every row a user may see, client mode filters instantly and needs no query API. We switch to server mode past a few thousand rows, or when permissions decide which rows exist for a user. To build the table yourself on TanStack Table instead, with the same server pagination, follow our TanStack Table v9 data table tutorial. How do you build a settings page that never loses input? Let one block own the drafts, and save through Server Actions that return their errors. The Settings Page has six sections (profile, account, notifications, billing, security, danger zone). A text change slides up a save bar with Discard and Save (or Mod+S), switches save on their own, a failed save keeps the draft and shows the message, and leaving a section with unsaved changes asks first. Next.js recommends modeling expected errors as return values in Server Functions, and in production the client does not get the original message of a server error, so a thrown "This workspace address is taken" would arrive as a generic error: // app/(admin)/settings/actions.ts "use server"; import { verifySession } from "@/server/dal"; import { saveSettings } from "@/server/settings"; export type ActionResult = { error?: string }; export async function saveSection(section: string, values: unknown): Promise<ActionResult> { const { userId } = await verifySession(); // validates with zod and returns { error } for anything the user can fix return saveSettings(userId, section, values); } // savePreference(preference, value) has the same shape. verifyEmailCode(email, code), // verifyTwoFactorCode(code) and checkWorkspaceAddress(slug) check the session too // and resolve to a boolean (true: the code is right, the address is free) The view turns a returned error into the rejection the block listens for, and replaces the block's demo checks with real ones: // app/(admin)/settings/settings-view.tsx "use client"; import { SettingsPage, type SettingsPageProps } from "@/components/blocks/settings-page"; import { checkWorkspaceAddress, savePreference, saveSection, verifyEmailCode, verifyTwoFactorCode, type ActionResult, } from "./actions"; type Props = Pick< SettingsPageProps, | "profile" | "account" | "notifications" | "billing" | "plans" | "sessions" | "passwordChangedAt" | "defaultTwoFactorEnabled" | "twoFactorSecret" | "twoFactorUri" | "backupCodes" >; const LABELS = { codeHint: "Enter the 6-digit code." }; async function check(result: Promise<ActionResult>) { const { error } = await result; if (error) throw new Error(error); } export function SettingsView(props: Props) { return ( <SettingsPage {...props} onSave={(section, values) => check(saveSection(section, values))} onPreferenceChange={(preference, value) => check(savePreference(preference, value))} verifyEmailCode={verifyEmailCode} verifyTwoFactorCode={verifyTwoFactorCode} checkWorkspaceAddress={checkWorkspaceAddress} labels={LABELS} /> ); } Pass every data prop from the page: anything you leave out falls back to the block's sample data. The security defaults are demo values on purpose. Without verifyEmailCode and verifyTwoFactorCode, the code 123456 passes; without twoFactorSecret, twoFactorUri and backupCodes, the setup dialog shows sample ones; and the default codeHint label reads "In this demo, the code is 123456." Generate the secret and backup codes on the server for the signed-in user. The dialogs take handlers with the same promise contract (onEmailChange, onPasswordChange, onTwoFactorChange, onSessionRevoke, onPlanChange, onDeleteAccount). One naming detail: the section ids are internal (profil, cont, notificari, facturare, securitate, pericol), so map them to your own segments if each section gets a URL through onSectionChange. > Live demo: Settings Page. The original article embeds an interactive demo here (Open Profile, edit the full name and press Save. This demo drops the first save on purpose: the bar shows the error and your edit stays, and the next Save goes through. In a frame this narrow the sections are a list that pushes each one in). Try it live on wingo-ui.com or open the Settings Page docs. Install: npx wingo-ui@latest add settings-page From 768px the sections are a column on the left (or tabs with nav="tabs"). On a phone each section pushes in with a back button and a swipe back, dialogs open as bottom sheets, and the save bar is pinned above the tab bar. The block validates each form before it calls onSave: invalid fields turn red and the first one takes focus, the pattern our React form components guide covers field by field. How do you build the team and roles page? Treat every control on it as a request the server can refuse. The Users Admin Page has three tabs: Members (the team and pending invitations against your seats, a role picker per row), Roles and permissions (a matrix with a save bar) and Audit log (a filterable history with CSV export). The server decides who may manage and enforces the rules the interface shows: // app/(admin)/team/actions.ts "use server"; import { requireAdmin } from "@/server/dal"; import { changeMemberRole } from "@/server/team"; export async function changeRole(memberId: string, role: string): Promise<{ error?: string }> { const admin = await requireAdmin(); // refuses to demote the last administrator, the same rule the picker shows return changeMemberRole(admin.workspaceId, memberId, role); } // invite, removeMember and savePermissions follow the same shape // app/(admin)/team/team-view.tsx "use client"; import { UsersAdminPage, type UsersAdminPageProps } from "@/components/blocks/users-admin-page"; import { changeRole, invite, removeMember, savePermissions } from "./actions"; type Props = Pick< UsersAdminPageProps, "companyName" | "members" | "invites" | "roles" | "permissionRoles" | "permissionGroups" | "defaultPermissions" | "auditEvents" | "seats" | "canManage" >; async function check(result: Promise<{ error?: string }>) { const { error } = await result; if (error) throw new Error(error); } export function TeamView(props: Props) { return ( <UsersAdminPage {...props} onInvite={(input) => check(invite(input))} onRoleChange={(member, role) => check(changeRole(member.id, role))} onRemove={(member) => check(removeMember(member.id))} onPermissionsSave={(value) => check(savePermissions(value))} /> ); } The page computes canManage from the session and passes it with the data. With canManage={false} the block turns read-only and says why, which beats hiding the page from people who only need to see who has access. The interface guards the obvious mistakes: the last administrator cannot be demoted, nobody removes themselves or the owner, and invitations hold a seat. Those guards are for the person clicking; your actions enforce them again, because a Server Action cannot tell which button sent the request. Likewise the block adds this page's changes to the top of the audit log as feedback, but the real audit rows are the ones your server writes in the same transaction. On a phone, members become cards with the role picker inside, the invite dialog is a bottom sheet, and the matrix shows one role at a time with chips, because five role columns do not fit 390px. The parts also ship alone: Members Table, Permissions Matrix and Audit Log. How do you handle loading and error states in the admin? Give every route a loading.tsx that renders the same component with its loading prop, and the group one error.tsx. loading.js shows its fallback immediately on navigation while the shell stays interactive, and a skeleton in the real layout means nothing jumps when the data arrives: // app/(admin)/clients/loading.tsx import { ClientsTable } from "./clients-table"; import { readQuery } from "./query"; export default function Loading() { return <ClientsTable rows={[]} total={0} initial={readQuery({})} loading />; } The Data Table draws skeleton rows in its column layout (cards on phones), and the dashboard, settings and team blocks have the same prop. A route without its own loading.tsx falls back to the nearest parent's, so one group-level skeleton would flash the same layout before every page. For errors, Next.js 16.3 made the retry prop of error.js stable; it re-fetches and re-renders the segment: // app/(admin)/error.tsx "use client"; import { Button } from "@/components/ui/button"; export default function AdminError({ error, retry }: { error: Error & { digest?: string }; retry: () => void }) { return ( <div role="alert" className="mx-auto flex max-w-md flex-col items-center gap-3 px-4 py-16 text-center"> <h2 className="text-lg font-semibold">This page did not load</h2> <p className="text-sm text-muted-foreground">If it keeps failing, send us this reference: {error.digest ?? "none"}</p> <Button onClick={retry}>Try again</Button> </div> ); } Empty states belong in the components: the Data Table's emptyState for a list with no rows yet and noResultsState when filters match nothing, so "No clients yet" and "Nothing matches" never get mixed up. How do you make every admin screen usable on a phone? Change behavior as well as layout, and check each screen at 390px on a real phone. People open a back office on phones to approve an expense, check an order or answer a client. The checklist we hold every admin screen to: - Navigation: everything in a drawer behind the menu button, plus a tab bar for the three to five daily destinations; tablets in portrait get the phone layout. - One bar on the bottom edge: the tab bar pads the home indicator and publishes its height as --bottom-bar-offset, and the settings and team save bars read it to sit above the tab bar. - Numbers: KPIs two per row, with values that shrink to fit 360px instead of wrapping. - Tables: rows become cards (title, subtitle, badge, up to four fields); nobody should scroll sideways to read one record. - Overlays: menus, filters, date pickers and detail views open as bottom sheets you can drag down; confirmations stay centered. - Container queries: inside a shell, measure the container (as the KPI grid does), so a sidebar never squeezes a layout built for the viewport. - Touch: 44px targets on coarse pointers, no hover-only actions, and every gesture backed by a visible button. Our mobile-first React components guide covers the bottom sheets, touch targets, safe areas and phone tables in depth, with code, and the React bottom navigation bar guide covers the tab bar and when the sidebar alone is enough. Should you start from a Next.js admin template or build it yourself? Start from a free starter to learn the App Router or when you already use its stack; build from blocks when settings, roles and phone layouts must work on day one. Before you pick a "nextjs admin template" or a "shadcn admin dashboard", compare these free starting points with the blocks in this guide (as of October 2026): | Option | Price and license | What you get | What is left to you | | --- | --- | --- | --- | | Next.js Learn dashboard course | Free | A financial dashboard with Postgres, Server Actions, search and pagination in the URL, NextAuth.js and Proxy; its invoices table turns into cards on phones | Settings, team and roles; it teaches the patterns and leaves the screens to you | | shadcn/ui dashboard-01 block | Free, MIT | Inset sidebar, summary cards, an interactive area chart and a TanStack data table (npx shadcn add dashboard-01) | Settings, team pages, auth; the table keeps its rows on a phone and scrolls sideways | | next-shadcn-dashboard-starter | Free, MIT | Next.js 16, shadcn/ui on Base UI, Clerk auth, product and user tables with URL state, team, billing and profile pages | Team, roles and billing come from Clerk's prebuilt components, so they need Clerk | | Wingo UI blocks (this guide) | Pro: $8 a month, $80 a year or $150 lifetime | App Shell, Admin Shell, the two dashboards, Data Table, Settings Page and Users Admin Page, all with phone layouts | Your data layer and auth; the blocks are UI only | Where the free options win: to learn, take the Next.js course, which teaches the data and auth side this guide skips. If you run shadcn/ui and need a dashboard tonight, dashboard-01 is one command and you own the code, which makes it the shortest path if you searched for "nextjs admin dashboard shadcn"; for desk-only internal tools a sideways-scrolling table is acceptable. If you already use Clerk, the Kiranism starter wires organizations, team management and billing through Clerk's prebuilt components. Wingo UI earns its price when phone users matter and you do not want to build the settings, team and table behaviors yourself. For libraries in general, our shadcn alternatives comparison covers fourteen with dated prices. What should you build first? Build the shell first, then one list page, then the home. The shell sets the navigation and phone layout every screen inherits, a list page exercises the server path (URL, query, Server Actions) that settings and team reuse, and the home is easiest once real data exists. The admin blocks in this guide are part of Wingo UI Pro. With it, start with npx wingo-ui@latest add app-shell (it brings the Sidebar, Topbar, Bottom Tab Bar and the hooks along) and drop it into app/(admin)/layout.tsx as above. The free basics they build on, such as the Button, Card, Badge, Dialog and Toast, install without an account. For back offices that bill Romanian customers, two tutorials go deeper: e-Factura VAT calculation computes VAT per rate the way ANAF validates it, with an editable invoice, and the Romanian address format guide builds a delivery address form with county, locality and postal code checks. More screen-by-screen tutorials live in Build it: screens and apps. Components in this post - App Shell (Pro): The whole admin layout in one component: a collapsible, resizable sidebar, a top bar and the page; a drawer and a tab bar on phones. Install: npx wingo-ui@latest add app-shell - Admin Dashboard Page (Pro): The home of a back office: greeting and period, four KPIs, revenue against the previous period, recent orders, alerts and team activity. Install: npx wingo-ui@latest add admin-dashboard-page - KPI Dashboard (Pro): The first screen of a back office: KPI tiles with deltas, revenue against the last period, sources, funnel, top sellers, invoices, activity. Install: npx wingo-ui@latest add kpi-dashboard - Data Table (Pro): The admin table on TanStack Table v9: search, filters, sorting, bulk actions, pinning, resizing, virtualization and cards on phones. Install: npx wingo-ui@latest add data-table - Sidebar (Pro): The admin sidebar: grouped links with icons, nested sections, badges, a sliding active pill, an icon rail with flyouts and a filter. Install: npx wingo-ui@latest add sidebar - Stat Card (Pro): The KPI tile of a dashboard: a label, a big number that counts up, a delta pill that knows good from bad, a sparkline and a goal bar. Install: npx wingo-ui@latest add stat-card - Settings Page (Pro): The account settings of a back office: profile, account, notifications, billing, security and a danger zone, with a save bar. Install: npx wingo-ui@latest add settings-page - Users Admin Page (Pro): The team page of a back office: members and invitations with seats, roles with a permissions matrix and a save bar, and the audit log. Install: npx wingo-ui@latest add users-admin-page FAQ How should I structure a Next.js App Router project for an admin dashboard? Put every admin route in a route group such as app/(admin) whose layout renders the shell, and keep sign-in in a separate group without it. Pages are server components that fetch through a server-only data access layer, and each route gets its own loading.tsx. Is there a free shadcn admin dashboard for Next.js? Yes. As of October 2026 shadcn/ui ships a free dashboard-01 block (npx shadcn add dashboard-01) with a sidebar, summary cards, a chart and a data table, but settings, roles and a phone layout for the table are left to you. The MIT next-shadcn-dashboard-starter adds Clerk auth, product and user tables, and team, billing and profile pages built on Clerk's prebuilt components. Should an admin data table filter on the server or in the browser? Filter in the browser when one request can load every row the user may see; it is instant and needs no query API. Filter on the server when the data grows past a few thousand rows or permissions decide which rows a user sees, and keep the page, sort, search and filters in the URL. Where do auth checks go in a Next.js admin dashboard? In a server-only data access layer that every page and every Server Action calls. A check in the layout or in proxy.ts alone is too weak: layouts do not re-render on client navigation, and Server Actions can be called with a direct POST request. How do I make an admin dashboard usable on a phone? Move the sidebar into a drawer with a bottom tab bar, show KPIs two per row, turn table rows into cards, open menus and detail views as bottom sheets, and keep a single bar on the bottom edge above the safe area. Test at 390px on a real phone, because a narrowed desktop window has no touch and no on-screen keyboard. Which Wingo UI admin components are free? None of the admin blocks in this guide: the App Shell, Sidebar, Data Table, Stat Card, KPI Dashboard, Admin Dashboard Page, Settings Page and Users Admin Page are Pro. The free basics, such as Button, Card, Badge, Dialog, Select, Tabs and Toast, install with the CLI without an account. --- This article was originally published on the Wingo UI blog: Nextjs Admin Dashboard Tutorial: 5 Screens, Phone-Ready. The original has the live, interactive demos and is kept up to date.
React Use Mobile Hook: SSR-Safe, No Hydration Mismatch TL;DR A React use mobile hook returns false on the server because the server has no screen: it renders the HTML before any browser reports a width, and hydration has to repeat that guess. Switch visible layout with CSS breakpoints, which apply at first paint, and keep the hook for behavior after an interaction, such as opening a bottom sheet instead of a dialog. Build the hook on useSyncExternalStore with a server snapshot, so hydration matches and every later mount reads the real value on its first render. Sooner or later, a Next.js app that has to work on phones grows a React use mobile hook: a useIsMobile() that returns true below 768px, copied from a starter or from shadcn/ui. It works in the browser, then surprises you three ways: it returns false on the server, a careless version throws a hydration error, and a phone can briefly see the desktop version of whatever the hook controls. This tutorial explains where each one comes from, which jobs belong to CSS breakpoints, and how to write an SSR-safe media query hook on useSyncExternalStore that hydrates cleanly. Why is useIsMobile false on the server? Because the server has no screen. React renders the HTML before any browser has reported a width, so a hook that reads matchMedia has nothing to read and must return a fixed guess. In the Next.js App Router the gap is wider still: a page that uses no request-time APIs is prerendered once at build time, with no request and no visitor at all. The guess itself is harmless. The trouble is hydration: the first client render has to produce the same markup as the server, so the browser repeats the guess, and the value may only change after hydration. On a phone, the server's answer stays on screen from the first paint until the JavaScript has downloaded, parsed and hydrated, and on a slow connection that takes a while. Anything visible that depends on the hook is wrong for that whole window. So the useful question is which jobs can live with a guess for that window. Why does a useIsMobile hook cause a hydration mismatch? Only when it reads window during render: the browser's first render then disagrees with the server's HTML. Search for a React is mobile hook and you find three shapes, and only the first one does that. We server-rendered each one with renderToString from React 19.3, hydrated it with hydrateRoot in Chromium at 390px through Playwright, and mounted a second copy 300ms later, the way a dialog, a lazy panel or a client-side route mounts after hydration: | Pattern | Hydration at 390px | First render of a later mount | | --- | --- | --- | | typeof window check in render | Text: an error, the tree is rebuilt on the client. A class: no error, the server's class stays | true | | useState + useEffect | No error: false, then true after the effect | false, then true | | useSyncExternalStore with a server snapshot | No error: false, then true right after hydration | true | The first shape is the obvious one: // wrong: the server says false, a phone says true during hydration const isMobile = typeof window !== "undefined" && matchMedia("(max-width: 767.98px)").matches; When the hook changes text, React 19 reports "Hydration failed because the server rendered text didn't match the client" and rebuilds that tree on the client, which throws away the work SSR did. The Next.js hydration error page lists this exact check as a common cause. When the hook only picks a className, nothing fails, and that is worse. In development React 19 logs "A tree hydrated but some attributes of the server rendered HTML didn't match the client properties. This won't be patched up." A production build logs nothing. In our test the phone kept class="desktop" after hydration in both builds, and nothing corrects it while the value stays the same. The second shape is the shadcn use-mobile hook that ships with the shadcn/ui Sidebar. As of October 2026 its source keeps undefined in state, measures in an effect and returns !!isMobile: const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined) React.useEffect(() => { const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`) // ...subscribes to mql, then: setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) }, []) return !!isMobile It never mismatches, because every first render returns false. The catch is the word every: a component that mounts long after hydration also renders as a desktop once, then corrects itself in the effect. An autoFocus={!isMobile} on a field in that component is true on the first commit, and React focuses the field during that commit, so a phone can still get the keyboard you meant to avoid. The shadcn Sidebar gets away with the false start for a good reason. The desktop markup it renders on the server carries hidden md:block, so a phone never sees it, and on a phone the correction swaps in a Sheet that stays closed until someone opens it. That is the pattern to copy, and the next section spells it out. When should you use CSS breakpoints instead of a hook? For everything visible before the first interaction. CSS media queries apply at first paint, before any JavaScript runs, so they are already right in the server's HTML. Render both versions and let a breakpoint hide one: // app/orders/page.tsx: a server component, no hook to correct import { getOrders } from "@/lib/orders"; import { OrderCard } from "./order-card"; import { OrderRow } from "./order-row"; export default async function OrdersPage() { const orders = await getOrders(); return ( <> <ul className="flex flex-col gap-2 md:hidden"> {orders.map((order) => ( <OrderCard key={order.id} order={order} /> ))} </ul> <table className="hidden w-full md:table"> <tbody> {orders.map((order) => ( <OrderRow key={order.id} order={order} /> ))} </tbody> </table> </> ); } Both trees are in the DOM, which costs little for a nav or a page of 20 rows. When the switch depends on the space a component gets rather than the window, Tailwind v4's @container and @md: variants do it in CSS with no hook at all. Keep the hook for decisions that start after the person does something, or that never show on the first paint: - which overlay opens: a bottom sheet below 768px, a popover or a dialog above it - the edge a panel slides in from - whether a field takes focus when a panel opens, since focusing it on a phone pops the keyboard over the content - where a click goes: a full page on a phone, a side panel next to the list on desktop A closed overlay renders only its trigger, and the trigger is usually the same button in both modes, so the hook can switch the overlay freely. The shadcn responsive dialog tutorial builds that switch for a dialog, and the mobile-first React components guide applies the same rule across a whole component library. How do you write an SSR-safe useMediaQuery hook? Read the query through useSyncExternalStore, React's API for values that live outside React, and give it a server snapshot. That is the whole fix for useMediaQuery SSR problems: // hooks/use-media-query.ts import { useCallback, useSyncExternalStore } from "react"; // the complement of Tailwind's md: variant, in the same unit export const MOBILE_QUERY = "(width < 48rem)"; const lists = new Map<string, MediaQueryList>(); function getList(query: string) { let list = lists.get(query); if (!list) { list = window.matchMedia(query); lists.set(query, list); } return list; } export function useMediaQuery(query: string, serverFallback = false) { // stable per query, or React unsubscribes and subscribes again on every render const subscribe = useCallback( (onChange: () => void) => { const list = getList(query); list.addEventListener("change", onChange); return () => list.removeEventListener("change", onChange); }, [query], ); return useSyncExternalStore( subscribe, () => getList(query).matches, // the server and the hydration render () => serverFallback, ); } export function useIsMobile() { return useMediaQuery(MOBILE_QUERY); } // the same check in event handlers and effects; false on the server export function matchesMedia(query: string) { return typeof window !== "undefined" && getList(query).matches; } Here is what React does with it: - Server and hydration. React calls the third function, so the server HTML and the hydration render agree. Leave it out and the React docs are blunt: "rendering the component on the server will throw an error." - Right after hydration. Once the effects run, React compares the server snapshot with the browser's answer and, when they differ, re-renders synchronously. That is the false, then true in the table above. - Every later mount. React calls the second function during the first render, so a sheet that opens on a phone renders as a phone from its first frame. - Updates. A MediaQueryList fires change only when the query flips, so dragging the window edge costs no renders until it crosses the breakpoint, unlike a resize listener. The lists are cached per query because matches is read on every render. If the shadcn Sidebar added hooks/use-mobile.ts to your project, point that file at the new hook. The Sidebar keeps its import, and its hidden md:block desktop markup now flips at the same width as the hook: // hooks/use-mobile.ts import { MOBILE_QUERY, useMediaQuery } from "@/hooks/use-media-query"; export const useIsMobile = () => useMediaQuery(MOBILE_QUERY); Event handlers do not need the hook at all. Read the query at the moment of the click: // app/orders/use-open-order.ts "use client"; import { useRouter } from "next/navigation"; import { useState } from "react"; import { MOBILE_QUERY, matchesMedia } from "@/hooks/use-media-query"; export function useOpenOrder() { const router = useRouter(); const [selected, setSelected] = useState<string | null>(null); function openOrder(id: string) { // phones get a full page, desktops a panel next to the list if (matchesMedia(MOBILE_QUERY)) router.push(`/orders/${id}`); else setSelected(id); } return { selected, openOrder }; } Which serverFallback should you pass? The answer whose mistake costs least during the hydration window. For useIsMobile, false is usually right, because the decisions it drives start after a tap and by then the real value is in. For a query that changes something visible, pick the answer that works on every device. With (hover: hover) that is false: the server renders the always-visible version of a control, and a mouse user gets the hover polish a moment later. Should the query use px or rem? Use the unit of your CSS breakpoints. Tailwind v4 defines --breakpoint-md: 48rem, and our build compiles md: to @media (min-width: 48rem). Media Queries Level 4 says relative units in media queries are "based on the initial value", so 48rem follows the default font size set in the visitor's browser and ignores your html { font-size }. A hook written in px does not move with it. We checked this in Chromium through Playwright, with a 900px window and the default font size set to 20px. (min-width: 48rem) stopped matching, so Tailwind showed the phone layout, while (max-width: 767.98px) did not match either, so a px hook still reported a desktop. (width < 48rem) matched, in step with the CSS. Range syntax also retires the .98px trick that Bootstrap's docs explain with "viewports with fractional widths". As of October 2026, MDN's compatibility data lists range syntax from Chrome 104, Firefox 102 and Safari 16.4. Breakpoints are tokens like colors and radii, so keep them in one place: the Tailwind v4 design tokens guide shows how to layer them. A caveat about our own code: as of October 2026 the MOBILE_QUERY in Wingo UI is still (max-width: 767.98px), so it has the gap described above. With a 20px default font size, md: classes switch at 960px while the Drawer and the Sheet, which call useIsMobile(), switch at 768px. In your own components, call useMediaQuery("(width < 48rem)") where the hook and your classes have to agree. Can Next.js detect mobile on the server? It can guess from request headers, and only on routes rendered per request. Most ways to make React detect mobile or desktop on the server read the User-Agent header. Next.js parses it with userAgent() from next/server, whose device.type is "mobile", "tablet" and a few others, or undefined for desktop browsers. Pass the guess down as the server snapshot and hydration still matches, because the prop travels with the page: // app/orders/page.tsx import { headers } from "next/headers"; import { userAgent } from "next/server"; import { OrdersScreen } from "./orders-screen"; export default async function OrdersPage() { const { device } = userAgent({ headers: await headers() }); return <OrdersScreen phoneGuess={device.type === "mobile"} />; } // app/orders/orders-screen.tsx "use client"; import { MOBILE_QUERY, useMediaQuery } from "@/hooks/use-media-query"; export function OrdersScreen({ phoneGuess }: { phoneGuess: boolean }) { // the server and the hydration render both use the guess, then the real value takes over const isMobile = useMediaQuery(MOBILE_QUERY, phoneGuess); return <p>{isMobile ? "Tap an order to open it" : "Select an order to see it on the right"}</p>; } Know the cost before you add it: - Dynamic rendering. headers() is a request-time API, so the page renders on every request instead of once at build time, and any shared cache has to vary by user agent. With Cache Components turned on, the read also has to sit inside a <Suspense> boundary, or next build fails. - It names a device. The header stays the same for a desktop browser narrowed to 600px, a tablet app in split screen and a phone turned to landscape, so it cannot tell you the window width. Since Safari 13, Safari on iPad sends the same user agent as Safari on macOS by default, so most iPads read as desktops. - Client hints cover Chromium only. Sec-CH-UA-Mobile arrives without opting in, but as of October 2026 MDN's compatibility data lists it in Chrome and Edge and not in Firefox or Safari. It pays off on app pages that are dynamic anyway, such as a signed-in dashboard, and rarely on marketing pages you want served from a CDN. How do the Wingo UI Sheet and Drawer use the hook? Only for behavior, and their closed state renders the same markup at every width. The free useMediaQuery hook is built the same way, with the same serverFallback parameter, plus useIsMobile, useIsTouch for (pointer: coarse), useBreakpoint and matchesMedia. Resize this window across 768px, or open the page on a phone: > Live demo: useMediaQuery. The original article embeds an interactive demo here (Resize the window across 768px: the useIsMobile() card and the (width < 48rem) card flip together at the default font size, and the ruler moves to the widest Tailwind breakpoint that matches). Try it live on wingo-ui.com or open the useMediaQuery docs. Install: npx wingo-ui@latest add use-media-query The Drawer calls useIsMobile() in its root. Phones always get the draggable sheet; from 768px the desktop prop picks a centered sheet, a side panel or a dialog. Its trigger is the same Radix Dialog trigger in every mode, so the server's guess changes nothing on screen, and the open state lives in the root, so a tablet rotated across 768px keeps the drawer open. > Live demo: Drawer. The original article embeds an interactive demo here (Tap Filters: below 768px the drawer slides up as a sheet you can drag down to close; on a wider screen the same component opens a centered dialog). Try it live on wingo-ui.com or open the Drawer docs. Install: npx wingo-ui@latest add drawer The Sheet does the same for side panels. mobileSide="bottom" turns a filter panel from the right into a bottom sheet on phones, and the side only matters once the sheet is open: import { SlidersHorizontal } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Sheet } from "@/components/ui/sheet"; export function OrderFilters() { return ( <Sheet side="right" mobileSide="bottom" title="Filters" description="Status, date and amount" trigger={ <Button variant="soft" leftIcon={<SlidersHorizontal />}> Filters </Button> } > <p className="text-sm text-muted-foreground">Pick the orders you want to see.</p> </Sheet> ); } What should you do next? Paste the hook above into hooks/use-media-query.ts, or install ours with npx wingo-ui@latest add use-media-query. Then search your codebase for useIsMobile and sort every call: visible layout moves to md: classes, behavior stays on the hook. More posts on the same stack live in Next.js and Tailwind CSS. Components in this post - useMediaQuery (Free): SSR-safe hooks for media queries, phone widths, touch screens and the current Tailwind breakpoint. Install: npx wingo-ui@latest add use-media-query - Sheet (Free): A panel that slides in from any edge on a spring: filters, a cart, an inspector or the phone navigation menu, swipeable to close on touch. Install: npx wingo-ui@latest add sheet - Drawer (Free): The library's bottom sheet on vaul: drag to dismiss, snap points, nesting, and a side panel or dialog on desktop. Install: npx wingo-ui@latest add drawer FAQ Why does useIsMobile return false on the server? The server renders the HTML before any browser reports a screen width, so the hook has nothing to measure and returns a fixed guess. Hydration has to repeat that guess, and the real value arrives right after it. Does the shadcn useIsMobile hook cause a hydration mismatch? No. The shadcn use-mobile hook keeps undefined in state and measures the screen in an effect, so its first render returns false on the server and in the browser alike. The cost is that a component mounted after hydration also renders as a desktop once, until the effect corrects it. How do I fix a hydration mismatch caused by useMediaQuery? Stop reading window during render and read the query through useSyncExternalStore, with a getServerSnapshot that returns the same value on the server and during hydration. React then corrects the value right after hydration, with no mismatch. Can Next.js detect a mobile device on the server? It can guess from the User-Agent header with userAgent() from next/server, which makes the route render per request. The header names a device and says nothing about the viewport width, so use the guess as the server snapshot only and keep layout in CSS. Why does my useIsMobile hook disagree with Tailwind's md breakpoint? Tailwind v4 breakpoints are in rem (md is 48rem), and media queries resolve rem from the browser's default font size, while many hooks, shadcn's included, hard-code 768px. When a visitor raises that font size the two disagree; write the hook's query as (width < 48rem) and both flip at the same width. Is the Wingo UI useMediaQuery hook free? Yes. useMediaQuery, useIsMobile and the rest of the hook file are free, and so are the Sheet and the Drawer that use it. Install the hook with npx wingo-ui@latest add use-media-query. --- This article was originally published on the Wingo UI blog: React Use Mobile Hook: SSR-Safe, No Hydration Mismatch. The original has the live, interactive demos and is kept up to date.
✨ shadcn/ui یه جعبهابزار حرفهای از کامپوننتهای خوشساخت برای اینکه سایتت شبیه پروژههای آماتوری نشه! 🔗 shadcn-ui/ui
Hi @shadcn this now supports presets as requested. PR: ui#12533 Demo link: shadcn.spaceman.sh P.S. Don't try to click any links, had to prune parts of the app to meet the cloudflare 10mb build size limit. t.co
React Bottom Navigation Bar in Next.js: Safe Areas, Badges TL;DR A React bottom navigation bar is a fixed nav of 3 to 5 links at the bottom of the screen, with aria-current="page" on the active link, padding for env(safe-area-inset-bottom) under viewport-fit=cover, and bottom padding on the page equal to the bar's height so the last row stays visible. Keep the labels visible, put badge counts in each link's accessible name, and only hide the bar on scroll if it follows the finger and comes back on scroll up. Use it below 768px with a sidebar above that, and with more than five destinations keep the four most used in the bar and the rest in a drawer. A React bottom navigation bar is the tab bar that native apps have trained everyone to expect: three to five destinations on the bottom edge, where the thumb already is. On the web it takes more than a fixed <div> of icons. The bar has to clear the iPhone home indicator, keep the last row of the page visible, tell a screen reader that Inbox has 4 unread messages, follow the router, and decide what a big center button and a long scroll should do. This guide covers each with the Wingo UI Bottom Tab Bar, then the cases where a Sidebar is the better choice. When does a web app need a bottom navigation bar? When people use it on phones and move between three to five top-level sections many times a session. Android's navigation bar guidance lists the conditions: "Three to five destinations of equal importance", "Compact window sizes" and "Consistent destinations across app screens". A CRM with Home, Clients, Inbox and Profile fits. A marketing site, a docs site or a checkout does not, because nobody hops between their sections. The bottom edge is the reason to bother. In Steven Hoober's 2013 field study (1,333 observations of people using mobile devices), 49% of those touching the screen held the phone in one hand. A hamburger menu in a top corner sits far from that thumb and hides every destination behind an extra tap. In a bottom tab bar web app, every destination stays visible and one tap away, and the active tab tells people where they are. What does a bottom tab bar need to work in a browser? Five jobs that a native tab bar gets from the platform and a web page has to do itself: | Concern | What to do | In the Bottom Tab Bar | | --- | --- | --- | | Semantics | A <nav> of links, aria-current="page" on the active one | aria-label="Primary"; tabs without href render as buttons | | Routing | The active tab follows the URL, nested routes included | currentPath picks the longest matching href; linkComponent takes next/link | | Home indicator | viewport-fit=cover, then pad the inside with env(safe-area-inset-bottom) | safeArea, on by default | | Covered content | Pad the page by the bar's real height | Writes --bottom-bar-offset on <html> | | Touch | Targets of 44px or more | Each tab fills its slot, 56 or 64px tall | Build the tabs from links. The ARIA tabs pattern describes "layered sections of content, known as tab panels, that display one panel of content at a time". A tab bar loads another page, so role="tablist" would announce a widget that is not there. Here is a React mobile bottom navigation for the Next.js App Router, in two files: // app/(app)/mobile-nav.tsx "use client"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { House, Inbox, Plus, UserRound, Users } from "lucide-react"; import { BottomTabBar, type BottomTabBarItem } from "@/components/ui/bottom-tab-bar"; const tabs: BottomTabBarItem[] = [ { label: "Home", href: "/", icon: <House />, activeIcon: <House className="fill-current/20" /> }, { label: "Clients", href: "/clients", icon: <Users />, activeIcon: <Users className="fill-current/20" /> }, { label: "Inbox", href: "/inbox", icon: <Inbox />, badge: 4 }, { label: "Profile", href: "/profile", icon: <UserRound />, dot: true }, ]; export function MobileNav() { return ( <BottomTabBar className="md:hidden" items={tabs} currentPath={usePathname()} linkComponent={Link} action={{ label: "New client", icon: <Plus />, href: "/clients/new" }} /> ); } // app/(app)/layout.tsx import type { ReactNode } from "react"; import type { Viewport } from "next"; import { MobileNav } from "./mobile-nav"; // without cover, iOS keeps the page inside the safe area and env() reports 0 export const viewport: Viewport = { width: "device-width", initialScale: 1, viewportFit: "cover" }; export default function AppLayout({ children }: { children: ReactNode }) { return ( <> <main className="pb-(--bottom-bar-offset)">{children}</main> <MobileNav /> </> ); } The bar is fixed to the viewport and measures itself, safe area included, so the padding on <main> is always exact; hidden by md:hidden on a desktop, it measures 0. A guessed pb-16 is wrong as soon as the inset or the size changes. The safe area guide explains the CSS underneath. Render the bar in the server HTML and hide it with CSS: deciding with a useIsMobile() hook renders nothing on the server, and the bar pops in after hydration, as the useMobile hook guide shows. > Live demo: Bottom Tab Bar. The original article embeds an interactive demo here (Tap Clients, open a client, then tap Clients again: the tab goes back to its list, and one more tap scrolls to the top. The raised Add button opens a sheet instead of switching tabs). Try it live on wingo-ui.com or open the Bottom Tab Bar docs. Install: npx wingo-ui@latest add bottom-tab-bar How should tab bar badges and labels behave? A count for things that need action, a dot for something new, both read as part of the tab's name. badge: 4 renders a count (99+ above 99) and dot: true an 8px dot, red by default (badgeTone="danger") because people read a red count as unread. The visible badge is aria-hidden, and the link is named "Inbox, 4 unread" (badgeLabel) or "Profile, new" (dotLabel), so a screen reader never reads a stray number. Apple's tab bar guidelines (updated June 2026) say "Reserve badges for critical information so you don't dilute their impact and meaning": unread messages and pending approvals, not a new feature. Keep showLabels="always". The same guidelines say "Include tab labels to help with navigation" and "Use single words whenever possible". "active" and "never" exist for icon-heavy consumer apps; with "never", labels stay in the DOM for screen readers and become title tooltips. activeIcon swaps in a second glyph while the tab is active, such as a filled version of the outline icon. Apple goes further and asks you to "Prefer filled symbols or icons for consistency with the platform" on every tab. Should the center button be a tab, an action or a FAB? An action, never a tab, and only when one creation action dominates the app. Apple is strict here: "Use a tab bar to support navigation, not to provide actions." We still ship the raised center button, because in an app built around one verb (Post, Sell, Scan) that verb has earned the most reachable spot. The action prop renders it in the middle (with four tabs: two, the action, two), filled with the tone and named by its label. It never takes the active pill, so nobody mistakes it for where they are. When the app has several things to create, or you follow Apple's rule, leave the bar to navigation and use a FAB. It reads --bottom-bar-offset, so it floats 16px above the tab bar and the home indicator without counting the inset twice: import { FileText, UserPlus } from "lucide-react"; import { Fab, type FabAction } from "@/components/ui/fab"; const actions: FabAction[] = [ { id: "client", label: "New client", icon: <UserPlus />, href: "/clients/new" }, { id: "invoice", label: "New invoice", icon: <FileText />, href: "/invoices/new" }, ]; export function CreateFab() { return <Fab className="md:hidden" label="Add" actions={actions} />; } > Live demo: FAB. The original article embeds an interactive demo here (Tap Add: the page dims and the create actions rise from the button. Scroll the list and the label folds down to the plus). Try it live on wingo-ui.com or open the FAB docs. Install: npx wingo-ui@latest add fab Should a bottom navigation bar hide on scroll? Only on long feeds and reading screens, and only if it comes back the moment the user might need it. Apple warns that "If you hide the tab bar, people can forget which area of the app they're in". Its iOS guidance describes minimizing a tab bar that carries an accessory, such as the MiniPlayer in Music, while people scroll down; tapping a tab or scrolling to the top brings it back. If you hide it, hideOnScroll shows the behavior to copy: - It follows the finger. The bar moves pixel for pixel with the scroll until it has traveled its own height plus the safe area, and snaps to shown or hidden once scrolling stops. A boolean that flips on direction makes the bar jump on a 2px scroll. - It comes back on scroll up, at the top, at the end of the content and whenever keyboard focus is inside it. - It moves a transform only. The progress lives in a motion value, so scrolling never re-renders the bar or the page. A version that calls setState in the scroll handler renders on every scroll event. - Reduced motion turns the slide into a flip after a full bar height of scrolling. > Live demo: Bottom Tab Bar. The original article embeds an interactive demo here (Scroll the page: the floating bar follows your finger out of view and comes back as soon as you scroll up). Try it live on wingo-ui.com or open the Bottom Tab Bar docs. Install: npx wingo-ui@latest add bottom-tab-bar When is a sidebar the better choice? When there are more than five top-level destinations, when they nest, or when most sessions happen on a laptop. Apple suggests "a sidebar or a tab bar that adapts to a sidebar as an alternative for an app with a complex information structure" and asks you to "Avoid overflow tabs"; Android recommends the bar for compact windows. | Your app | On a phone | From 768px | | --- | --- | --- | | 3 to 5 sections, used daily on phones | Bottom tab bar | Sidebar or top navigation | | 6 or more sections, or nested ones | The 4 most used in the bar, everything in a drawer | Sidebar | | Admin tool used mostly at a desk | Drawer, optional tab bar | Sidebar with an icon rail | | Content or docs site | Top bar and a menu | Top navigation | Most apps need both, so the App Shell renders the Sidebar beside the page from 768px and, below it, the same sidebar in a drawer plus a tab bar for the daily destinations: // app/(app)/layout.tsx with the App Shell import type { ReactNode } from "react"; import { AppShell } from "@/components/ui/app-shell"; import { Sidebar } from "@/components/ui/sidebar"; import { Topbar } from "@/components/ui/topbar"; import { nav, tabs } from "./nav"; export default function AppLayout({ children }: { children: ReactNode }) { return ( <AppShell sidebar={<Sidebar groups={nav} filter />} topbar={<Topbar title="Summit CRM" />} tabs={tabs}> {children} </AppShell> ); } nav holds every destination in groups and tabs the four people open every day. The shell renders the tab bar itself and reads the route from a LinkProvider (component={Link}, currentPath={usePathname()}) set once in a client provider; the Next.js admin dashboard tutorial builds that provider and the whole layout. The Admin Shell adds a More tab that opens the drawer. If you came looking for a shadcn mobile bottom navigation: as of October 2026, the shadcn/ui component list has none, and its Sidebar becomes an 18rem Sheet below 768px, two taps from any destination. Material UI's Bottom Navigation is a fine react bottom navigation component, and the better pick if your app already runs on Material UI. As of October 2026 its docs cover labels, fixed positioning and router links, and leave badges, safe areas and hide on scroll to you. What breaks a bottom navigation bar in production? - Home is active on every page. A hand-rolled pathname.startsWith(href) matches / everywhere. The bar matches / only exactly and picks the longest matching href, so /clients/northwind-tech keeps Clients active. - Tapping the active tab does nothing. People expect it to scroll to the top or go back to the tab's first screen. scrollToTopOnReselect does both, and onReselect can refresh a feed. - A sixth tab. The bar warns in development above five items. Move the rest into a More tab or the drawer. - Safari's floating toolbar. Since iOS 26, Safari's toolbar floats over the bottom of the page in the Compact tab layout, and a September 2025 forum report says Safari would not render fixed content below those controls. Test on a real iPhone in the Compact, Bottom and Top tab layouts. - Two bars, two insets. A buy bar above the tab bar must not pad the home indicator again; the mobile-first React components guide shows the stacking. What should you install? npx wingo-ui@latest add bottom-tab-bar copies the component into components/ui/ with the Badge, the navigation and motion helpers and the scroll hooks it imports. The Bottom Tab Bar, FAB, Sidebar and App Shell are part of Wingo UI Pro; the Badge is free. More guides like this one live in Mobile UI in React. Components in this post - Bottom Tab Bar (Pro): The phone's primary navigation: 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 - FAB (Pro): The floating action button for a phone screen's main action, with a labelled or radial speed dial and a label that folds while you scroll. Install: npx wingo-ui@latest add fab - Sidebar (Pro): The admin sidebar: grouped links with icons, nested sections, badges, a sliding active pill, an icon rail with flyouts and a filter. Install: npx wingo-ui@latest add sidebar - App Shell (Pro): The whole admin layout in one component: a collapsible, resizable sidebar, a top bar and the page; a drawer and a tab bar on phones. Install: npx wingo-ui@latest add app-shell FAQ How many items should a bottom navigation bar have? Three to five. Android's guidance lists three to five destinations of equal importance, and Apple's tab bar guidelines ask you to avoid overflow tabs. With more sections, keep the four most used in the bar and put everything in a drawer or a sidebar. Does shadcn/ui have a bottom navigation component? Not as of October 2026: its component list has no bottom navigation or tab bar, and its Sidebar turns into an 18rem Sheet below 768px. Wingo UI's Bottom Tab Bar, a Pro component, installs the same way, as source code in your project, with npx wingo-ui@latest add bottom-tab-bar. Should a bottom navigation bar use role="tablist"? No. The ARIA tabs pattern is for panels that switch inside one page. A bar that changes the page is navigation: a nav element with links, and aria-current="page" on the active one. Should a bottom navigation bar hide on scroll? Only on long feeds and reading screens, because a hidden bar also hides where people are in the app. If it hides, it should follow the finger, move with a transform only, and come back on scroll up, at the top and end of the page, and while keyboard focus is inside it. Is the Wingo UI Bottom Tab Bar free? No. The Wingo UI Bottom Tab Bar is a Pro component, like the FAB, the Sidebar and the App Shell; the Badge it uses for counts is free. --- This article was originally published on the Wingo UI blog: React Bottom Navigation Bar in Next.js: Safe Areas, Badges. The original has the live, interactive demos and is kept up to date.
Mobile-First React Components: 9 Rules With Code TL;DR Mobile-first React components are designed for a thumb on a 390px screen first and enhanced for a mouse after. The nine rules: open popovers, selects, menus and dialogs as bottom sheets below 768px; make targets 44px on coarse pointers; never hide a control behind hover; pad fixed bars with the safe-area insets; size full-height layouts in dvh, not vh; keep bottom bars out of the on-screen keyboard; turn table rows into cards; give every gesture a visible alternative; and switch layout with CSS breakpoints. Keep media query hooks for behavior only, because the server does not know the screen width. Open a typical React app on a phone and the cracks show in the same places: a dropdown menu hanging off the edge of the screen, a 32px icon button you have to aim for, a checkout bar hidden behind the home indicator, a data table you scroll sideways to read one row. Mobile-first React components fix those problems once, inside the component, so every screen that uses them gets them right without a media query in the page. This is the rulebook we follow in Wingo UI, with the reason and the code for each rule. We build Wingo UI, so we are not neutral; outside facts link to their sources and are dated. What makes a React component mobile-first? A mobile-first component is designed for a thumb on a 360 to 390px screen with an on-screen keyboard, and then enhanced for a mouse and a wide window. Beyond reflowing its layout, the component changes how it behaves: which overlay it opens, how big its hit area is, what replaces hover, and where it sits relative to the notch and the keyboard. The nine rules in one table, then a section for each: | Rule | On a phone | Why | | --- | --- | --- | | Overlays | Popovers, selects, menus and dialogs open as bottom sheets below 768px | An anchored panel has no room and sits under the thumb | | Touch targets | 44px on coarse pointers, 8px apart | A finger is less precise than a cursor | | Hover | Nothing is hover-only | Touch screens have no hover | | Safe areas | Fixed bars pad env(safe-area-inset-*) | The notch and the home indicator cover content | | Height | dvh or svh, never vh | 100vh is taller than the visible screen | | Keyboard | Bottom bars hide or ride above it, inputs use 16px text | Fixed bars end up behind the keyboard | | Tables | Rows become cards | Sideways scrolling hides most of every row | | Gestures | Every gesture has a visible alternative | Gestures are invisible, and not everyone can perform them | | Layout and behavior | CSS breakpoints for layout, a hook for behavior | The server does not know the screen width | If you are building a React mobile first UI from scratch, apply each rule in every component before adding the next one: people learn an interface from its most common pattern. Why should a popover become a bottom sheet on a phone? A popover anchored to a small trigger has nowhere good to go on a 390px screen, while a bottom sheet opens where the thumb already is. The finger that tapped the trigger covers it, the panel gets clipped or flipped above the trigger, and a tap that lands just outside it closes it. A sheet takes the full width, has room for 48px rows, can be dragged away, and keeps its actions in the bottom part of the screen, the part a thumb reaches without changing grip. We render a bottom sheet below 768px for the popover, select, dropdown menu, context menu and dialog, by default. The exceptions matter as much as the rule: - Tooltips never become sheets. Nothing hovers on a touch screen, so the text goes inline or behind a long press. - Alert dialogs stay centered at every width. A short question that must be answered reads like an iOS alert. - Suggestions under a text field stay attached to it, because a sheet would cover what you type, as in the Mention Input. The Combobox takes the other route: a full-height sheet with its own search box. In Wingo UI the switch is a responsive prop that defaults to true on the Popover, Select, Dropdown Menu, Dialog and the date pickers. You write the component once: "use client"; import { ChevronDown } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Popover, PopoverClose } from "@/components/ui/popover"; export function PriceFilter({ onApply }: { onApply: () => void }) { return ( <Popover trigger={ <Button variant="soft" rightIcon={<ChevronDown />}> Price </Button> } title="Price" description="Pick a range or type your own." footer={ <PopoverClose asChild> <Button size="sm" onClick={onApply}> Apply </Button> </PopoverClose> } > <p className="text-muted-foreground">Between $500 and $1,500</p> </Popover> ); } On a desktop that is a floating panel with a right-aligned Apply button. Below 768px the same JSX opens a bottom sheet with a handle and a full-width 48px Apply button. Pass responsive={false} when a panel has to stay anchored to a field. The sheet underneath is the free Drawer, built on vaul. It follows your finger, closes when you let go past a quarter of its height or flick it, stops at snap points, and lets a long list scroll first and drag the sheet only once the list is back at the top. Opening it moves focus to the sheet itself instead of the first field, so the keyboard does not jump up over half the content. From 768px it stays a centered sheet, or becomes a side panel or a dialog, depending on the desktop prop. > Live demo: Drawer. The original article embeds an interactive demo here (Tap Filters: on a phone the sheet slides up and you can drag it down to close; on a wide screen this one opens as a side panel). Try it live on wingo-ui.com or open the Drawer docs. Install: npx wingo-ui@latest add drawer The demo is a listings page: Filters opens a draggable bottom sheet below 768px and a panel from the right edge on wider screens, with the result count on a pinned button in both. Two posts go deeper on this rule. The React bottom sheet guide covers snap points, drag to dismiss, nested sheets and the keyboard, and the shadcn responsive dialog tutorial builds the same card-to-sheet switch on stock shadcn/ui. How do you switch between a sheet and a dialog without a hydration flash? Use CSS breakpoints for anything visible on the first paint, and a media query hook only for behavior that starts after an interaction, such as which overlay opens. The server has no screen, so any hook that reads matchMedia has to return a guess during server rendering and hydration. When that guess drives visible layout, phones see the desktop layout first and then a jump. The use-mobile hook that ships with shadcn/ui's sidebar starts as undefined, returns false, and flips in an effect (as of October 2026). Our free useMediaQuery uses the same 768px breakpoint, reads it through useSyncExternalStore and makes the server value a parameter. This is the core of it: import { useCallback, useSyncExternalStore } from "react"; export function useMediaQuery(query: string, serverFallback = false) { const subscribe = useCallback( (onChange: () => void) => { const list = window.matchMedia(query); list.addEventListener("change", onChange); return () => list.removeEventListener("change", onChange); }, [query], ); return useSyncExternalStore( subscribe, () => window.matchMedia(query).matches, // the server and the hydration render () => serverFallback, ); } export const useIsMobile = () => useMediaQuery("(max-width: 767.98px)"); Both hooks say false on the server. That is harmless for overlays, because an overlay is closed during server rendering: by the time someone taps the trigger, the hook holds the real value. It is harmful for content: // wrong: the server renders the table, phones swap to cards after hydration const isMobile = useIsMobile(); return isMobile ? <InvoiceCards rows={rows} /> : <InvoiceTable rows={rows} />; // right: both are in the HTML and CSS picks one before the first paint return ( <> <InvoiceTable rows={rows} className="hidden md:table" /> <InvoiceCards rows={rows} className="md:hidden" /> </> ); This is the part of mobile first design React apps get wrong without noticing, because the wrong version looks fine when you drag a desktop window narrow after the page has loaded. The jump only shows when a phone loads the server-rendered page. Our guide to an SSR-safe React use mobile hook tests three common versions of the hook through hydration and sorts which jobs belong to CSS. How big should touch targets be? Make every target 44 by 44 CSS pixels on touch screens and keep at least 8px between neighbors. The standards disagree on the exact number; 44px meets the strictest web criterion (WCAG level AAA) and Apple's default control size: | Source (as of October 2026) | Size | Status | | --- | --- | --- | | WCAG 2.2, 2.5.8 Target Size (Minimum) | 24 by 24 CSS px, or enough spacing | Level AA | | WCAG 2.2, 2.5.5 Target Size (Enhanced) | 44 by 44 CSS px | Level AAA | | Apple Human Interface Guidelines | 44 by 44 pt default on iOS, 28 by 28 pt minimum | Guideline | | Android accessibility guide | 48 by 48 dp | Recommendation | The catch is the desktop. A 44px button everywhere makes a toolbar or a settings page look bloated next to a mouse pointer. Tailwind CSS v4 has a pointer-coarse variant that compiles to @media (pointer: coarse), so the size can follow the input device instead of the screen width. A tablet and a phone both get the bigger targets; a laptop with a trackpad does not. For a text button the size is h-10 pointer-coarse:h-11: 40px with a mouse, 44px under a finger. An icon button that must stay small gets an invisible hit area instead: import { Ellipsis } from "lucide-react"; // looks 32px, takes 44px taps on touch export function MoreButton() { return ( <button type="button" aria-label="More actions" className="relative grid size-8 touch-manipulation place-items-center [-webkit-tap-highlight-color:transparent] pointer-coarse:after:absolute pointer-coarse:after:-inset-1.5" > <Ellipsis className="size-4" /> </button> ); } The ::after trick adds 6px on every side of the 32px button without moving anything. touch-manipulation turns off double-tap zoom on the control, so taps register without the browser waiting for a second one, and the transparent tap highlight removes the gray box mobile browsers paint on a tapped element. Both live on the component itself, because a component copied into another project does not bring your global stylesheet along. The Button in Wingo UI is built this way: md is 40px with a mouse and 44px on touch, sm and icon-sm stay 32px and get the invisible hit area, and lg is 48px everywhere. The minimum touch target size guide compares what WCAG, Apple and Android require and shows how to test the hit areas. Why do hover effects break on phones? Touch screens have no hover, so anything that only appears on hover does not exist on a phone. In Tailwind CSS v4 the hover: variant only applies when the primary input can hover (@media (hover: hover)). That fixes the old sticky hover state after a tap, and it also means a hover-only control never shows up on touch at all. The classic case is a row action that fades in when the pointer is over the row. The fix is a focus variant and a coarse-pointer variant: import { Ellipsis } from "lucide-react"; export function ClientRow({ name }: { name: string }) { return ( <li className="group/row flex h-12 items-center justify-between gap-3 px-4"> {name} <button type="button" aria-label={`Actions for ${name}`} className="grid size-8 place-items-center opacity-0 transition-opacity group-hover/row:opacity-100 group-focus-within/row:opacity-100 pointer-coarse:opacity-100" > <Ellipsis className="size-4" /> </button> </li> ); } Hover reveals it for a mouse, focus reveals it for a keyboard, and on a coarse pointer it is always there. The Data Table puts the same classes on the wrapper of its row menu trigger, plus has-[[data-state=open]]:opacity-100: without it, the trigger fades out while its menu is open, as soon as the pointer moves from the row into the menu. The other two replacements for hover are a tap and a long press: a hover card opens as a sheet on the first tap, a tooltip shows on a long press, and a context menu opens as an action sheet when you hold a row. How do you keep fixed bars clear of the notch and the home indicator? Set viewport-fit=cover, then pad every element pinned to a screen edge with env(safe-area-inset-*), with a minimum for devices that report zero. cover scales the viewport to fill the whole display, notch and home indicator included, and MDN highly recommends the safe area inset variables with it so important content stays visible. In the Next.js App Router the viewport meta tag comes from a viewport export in the root layout: // app/layout.tsx import type { Viewport } from "next"; export const viewport: Viewport = { width: "device-width", initialScale: 1, viewportFit: "cover", }; Then pad with an arbitrary value: pb-[max(env(safe-area-inset-bottom),12px)]. A plain pb-[env(safe-area-inset-bottom)] leaves buttons touching the screen edge on devices without a home indicator, where the inset is 0; max() keeps a little air. The harder problem is two bars at once. A product page on a phone often has a tab bar at the bottom and a buy bar above it, and both want the safe area. The Bottom Tab Bar pads the home indicator and writes its height to a --bottom-bar-offset CSS variable. The Sticky CTA Bar sits at bottom: var(--bottom-bar-offset) and pads only the part of the safe area the tab bar does not already cover, so there is no double gap: "use client"; import { House, Search, ShoppingBag, UserRound } from "lucide-react"; import { BottomTabBar, type BottomTabBarItem } from "@/components/ui/bottom-tab-bar"; import { StickyCtaBar } from "@/components/ui/sticky-cta-bar"; const tabs: BottomTabBarItem[] = [ { label: "Home", href: "/", icon: <House /> }, { label: "Search", href: "/search", icon: <Search /> }, { label: "Cart", href: "/cart", icon: <ShoppingBag />, badge: 2 }, { label: "Account", href: "/account", icon: <UserRound /> }, ]; export function ProductChrome({ onAddToCart }: { onAddToCart: () => Promise<void> }) { return ( <> <StickyCtaBar price={349} compareAtPrice={449} locale="en-US" actionLabel="Add to cart" tone="brand" hideFrom="md" onAction={onAddToCart} /> <BottomTabBar items={tabs} className="md:hidden" hideOnScroll /> </> ); } The tab bar takes 3 to 5 tabs, each far larger than 44px. With hideOnScroll it follows your finger out of view on the way down and comes back on the way up, moved by a motion value so nothing re-renders per frame. The buy bar shows a spinner while the onAction promise runs without changing its width. The Tailwind safe area insets tutorial covers the utilities, a FAB stacked above the tab bar and where toasts go, and the React bottom navigation bar guide covers badges, the center action and when a sidebar fits better. > Live demo: Bottom Tab Bar. The original article embeds an interactive demo here (Tap the tabs, then tap the active one again to jump back to the top; the raised Add button opens a sheet). Try it live on wingo-ui.com or open the Bottom Tab Bar docs. Install: npx wingo-ui@latest add bottom-tab-bar The demo is a small CRM with four tabs, an unread count on Inbox and a raised Add action. Open a client, then tap Clients again: the tab goes back to its first screen, the way iOS tab bars do. Should you use 100vh, dvh or svh on mobile? Use dvh for app shells and sheets, svh for blocks that must not change height while scrolling, and never vh. MDN defines vh as equal to lvh, the large viewport measured with the browser toolbars retracted. While the address bar is showing, 100vh (Tailwind's h-screen) is taller than the visible area, and the bottom of your layout, often the button that matters, sits under the toolbar. dvh follows the toolbars as they expand and retract, which is what a full-height shell wants. MDN also warns that dynamic units can resize content while the user scrolls, so a hero section sized in dvh may twitch; svh is the stable choice there. Tailwind CSS v4 ships h-dvh, min-h-dvh, h-svh and h-lvh. In Wingo UI the App Shell uses min-h-dvh and bottom sheets cap at max-h-[92dvh], so a tall sheet keeps a strip of the page visible above it whatever the toolbars do. The 100vh mobile fix compares the three units in detail and builds both kinds of full-height shell. What happens to bottom bars when the on-screen keyboard opens? They end up behind the keyboard unless you move them. Since Chrome 108 on Android, the keyboard resizes only the visual viewport, which the Chrome team describes as matching Safari on iOS. An element with position: fixed; bottom: 0 is placed against the layout viewport, which still reaches the bottom of the screen, behind the keyboard. There are two good answers. Hide the bar while a field has focus, which suits a checkout bar with a coupon field (the Sticky CTA Bar's default, keyboard="hide"). Or lift it by the height the keyboard covers, which suits a chat composer (keyboard="lift"). The lift comes from the visual viewport: the covered height is innerHeight - visualViewport.height - visualViewport.offsetTop while a text field has focus. The useKeyboardInset hook (Pro) measures it at most once per frame and exposes it as a motion value, so the bar moves without a re-render: "use client"; import { motion, useTransform } from "motion/react"; import { useKeyboardInset } from "@/hooks/use-keyboard-inset"; export function Composer() { const { inset } = useKeyboardInset(); const y = useTransform(inset, (value) => -value); return ( <motion.form style={{ y }} className="fixed inset-x-0 bottom-0 bg-background px-4 pt-2 pb-[max(env(safe-area-inset-bottom),12px)]"> <input aria-label="Message" inputMode="text" enterKeyHint="send" autoComplete="off" className="h-11 w-full rounded-full bg-foreground/5 px-4 text-base md:text-sm" /> </motion.form> ); } Two more keyboard rules. Give text inputs 16px text on phones (text-base md:text-sm): iOS Safari zooms the page into any focused field with smaller text and does not zoom back out. And set inputMode, enterKeyHint and autoComplete on every field, so the phone shows the right keyboard, the right return key label and the right autofill. How should a data table work on a phone? Below 768px, turn every row into a card: the primary field as the title, a status badge or an amount in the corner, two to four secondary fields as label and value pairs, and the row actions in a menu that opens as a sheet. Horizontal scrolling is acceptable only when the job is comparing columns, like a spreadsheet or a pricing matrix. For a list of invoices or clients it hides most of every row and makes people scroll in two directions to read one record. The Data Table does this by default (mobileLayout="cards"). Each column can say where it goes on the card with a mobile role, and the table guesses sensibly when you do not (the first column is the title, the first badge or else the last number is the meta): "use client"; import { Pencil } from "lucide-react"; import { DataTable, createColumns } from "@/components/ui/data-table"; type Invoice = { id: string; client: string; city: string; status: string; amount: number }; const col = createColumns<Invoice>(); const columns = col([ col.accessor("client", { header: "Client", mobile: "title" }), col.accessor("city", { header: "City", mobile: "subtitle" }), col.accessor("status", { header: "Status", type: "badge", badge: { Paid: { tone: "success" }, Overdue: { tone: "danger" }, Draft: { tone: "neutral" } }, mobile: "meta", }), col.accessor("amount", { header: "Amount", type: "currency" }), col.accessor("id", { header: "Invoice", mobile: "hidden" }), ]); export function Invoices({ invoices }: { invoices: Invoice[] }) { return ( <DataTable data={invoices} columns={columns} caption="Invoices" locale="en-US" selectionMode="multiple" rowActions={(row) => [{ label: "Edit", icon: <Pencil />, onSelect: () => console.log(row.id) }]} /> ); } On a phone you long press a card to start selecting, tap to add more, and the bulk actions float at the bottom. Sorting, filters and the row menu open as bottom sheets, and Load more appends rows instead of a pager with tiny page numbers. The table and the cards are both in the server HTML, hidden and shown with breakpoints, so a phone never sees the table first. The TanStack Table v9 data table tutorial builds filters, bulk actions and server pagination on TanStack directly, along with the phone layout. > Live demo: Data Table. The original article embeds an interactive demo here (On a phone every row is a card you can long press to select; on a wide screen you get the full table with sorting and column menus). Try it live on wingo-ui.com or open the Data Table docs. Install: npx wingo-ui@latest add data-table The demo is a client list with search, quick filters, status badges and balances. Below 768px each row becomes a card with the client name as the title and the status in the corner. Which columns make it onto a card, and how sorting and bulk actions work without headers, are in the responsive table mobile guide. Which gestures belong in a mobile web app? Drag to dismiss, pull to refresh, swipe actions on rows and hold to confirm all belong, as long as each has a visible alternative and leaves native scrolling alone. Gestures are invisible until someone shows you, and people using a keyboard, a switch or a screen reader cannot perform them at all. - Always a button path. Sheets close with Escape, a tap outside or a close button. Pull to refresh pairs with a refresh button. Swipe actions on a row also live in its menu. - touch-action on draggable surfaces. touch-pan-y on something you swipe sideways keeps vertical scrolling native; touch-none is for surfaces you drag freely, like a signature pad or a slider thumb. - overscroll-behavior: contain on scroll containers. It stops a scroll inside a sheet from chaining into the page, and keeps the browser's own pull to refresh from fighting yours. - Motion values, not state. A drag that calls setState on every pointer move re-renders on every frame. Write the offset to a motion value and only commit state when the gesture ends. Sortable lists and boards are where these rules collide with native scrolling; the dnd kit mobile tutorial sets up touch sensors that let a swipe still scroll the page. The Pull To Refresh shows where the rules bend. It sets overscroll-behavior-y: contain on its scroller and moves the content with a motion value under rubberband resistance. It cannot use touch-action, because the list it wraps must keep scrolling natively, so it listens to touchmove with passive: false and cancels the scroll only for a downward pull at the very top. It ticks when the pull arms, spins until your promise settles (500ms at least, so it never flashes), and exposes the same refresh as an API for a button (the React pull to refresh guide covers the indicator styles, accessibility and long virtualized lists): "use client"; import { useState, type ReactNode } from "react"; import { RotateCw } from "lucide-react"; import { Button } from "@/components/ui/button"; import { PullToRefresh, type PullToRefreshApi } from "@/components/ui/pull-to-refresh"; export function Feed({ reload, children }: { reload: () => Promise<void>; children: ReactNode }) { const [api, setApi] = useState<PullToRefreshApi>(); return ( <div className="flex h-dvh flex-col"> <header className="flex h-14 items-center justify-end px-4"> <Button variant="ghost" size="icon" radius="full" aria-label="Reload" onClick={() => api?.refresh()}> <RotateCw /> </Button> </header> <PullToRefresh scroll="self" className="min-h-0 flex-1" setApi={setApi} onRefresh={reload}> {children} </PullToRefresh> </div> ); } Hold to confirm is the gesture for actions that deserve more than a tap and less than a dialog, like deleting a client. The Haptic Button in mode="hold" fills while you hold a finger, the mouse button, Space or Enter, and runs onConfirm when the fill completes. Screen readers and voice control, which activate with a plain click and cannot hold, get a two-step path instead: the first activation announces "Activate again to confirm" and a second one within 4 seconds confirms. "use client"; import { Trash2 } from "lucide-react"; import { HapticButton } from "@/components/ui/haptic-button"; export function DeleteClient({ onDelete }: { onDelete: () => Promise<void> }) { return ( <HapticButton tone="danger" variant="soft" mode="hold" fullWidth leftIcon={<Trash2 />} onConfirm={onDelete}> Hold to delete </HapticButton> ); } On Android the tick uses navigator.vibrate. Safari on iPhone has no Vibration API, so the haptics helper falls back to clicking a hidden native switch (<input type="checkbox" switch>). That played the system tick up to iOS 26.4; from iOS 26.5 WebKit ticks only for the user's own tap on the switch, so on a current iPhone the fill carries the feedback on its own. Desktops and mouse clicks stay silent. The Web Vibration API guide covers what still ticks on an iPhone and how to let people turn haptics off. Does shadcn/ui handle mobile? Partly. As of October 2026 the shadcn mobile story is one component and one pattern. The Drawer docs come in a Base UI version, which now uses Base UI's drawer instead of Vaul, and a Radix version, which is still built on Vaul. Both include a Responsive section that renders a Dialog on desktop and a Drawer on mobile; the example decides with useMediaQuery("(min-width: 768px)") and shares one form between both branches. The Popover docs describe no mobile behavior, and Select and Dropdown Menu stay floating panels at every width unless you write a wrapper for each. That suits a library meant as a minimal, unopinionated starting point. shadcn/ui is free and MIT licensed, its GitHub repository shows about 125k stars (as of October 2026), and its docs let you choose between Base UI, Radix and React Aria primitives. If your app is used mostly on desktops, or you want to write and own every responsive wrapper yourself, it is the better choice. Our comparison of the best React component libraries for mobile checks shadcn/ui and eight others at 390px. If most of your users arrive on phones, the wrappers add up: every overlay needs the swap, every control needs the coarse-pointer size, every fixed bar needs the safe area, and every one of them has to avoid the hydration flash. Wingo UI makes those decisions inside each component, so the defaults are already mobile-first and you opt out per instance. Which components does a React mobile web app need? A React mobile web app usually needs overlays that become sheets, a tab bar for the primary navigation, a bottom action bar on product and checkout pages, lists with swipe actions, and a table that becomes cards. Here is the set in Wingo UI (registry 1.7.0, 326 items as of October 2026), with what each one does on a phone: | Component | Job on a phone | Access | | --- | --- | --- | | Drawer | The bottom sheet: drag to dismiss, snap points, nesting | Free | | Popover, Select, Dropdown Menu, Dialog | Open as bottom sheets below 768px | Free | | Segmented Control | A few options on a thumb you can drag | Free | | Bottom Tab Bar | 3 to 5 primary destinations at the thumb | Pro | | Sticky CTA Bar | Price and the main action, above the tab bar | Pro | | Pull To Refresh | Reload a list with a pull, or a button | Pro | | List | Grouped rows with swipe actions and drag to reorder | Pro | | Data Table | Rows as cards, long press to select | Pro | | Haptic Button | Press feedback, haptics and hold to confirm | Pro | | Large Title Header | An iOS large title that collapses into a bar | Pro | | Action Sheet | A short list of actions about one thing | Pro | | Picker Wheel | iOS wheels for times and dates | Pro | The whole Mobile category has 15 components, all built to these rules, with reduced motion support and designed light and dark themes. How do you test mobile-first React components? Test at 390 by 844 and 360 by 780 with touch enabled, then on a real iPhone for the parts emulators get wrong. Chrome's device mode switches the pointer to coarse, which is enough to see pointer-coarse styles and the sheets. It does not reproduce the iOS keyboard, the focus zoom, the home indicator or the toolbar resizing, so those need a phone. For automated checks, Playwright can emulate a touch phone per test file: import { expect, test } from "@playwright/test"; test.use({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true }); test("filters open as a bottom sheet on a phone", async ({ page }) => { await page.goto("/listings"); await page.getByRole("button", { name: "Filters" }).tap(); await expect(page.getByRole("dialog", { name: "Filters" })).toBeVisible(); }); Run the same pass in light and dark and with reduced motion turned on. A sheet handle or a hairline drawn at a low opacity can be clear in one theme and vanish in the other, and under reduced motion every overlay still has to open and close, only with a shorter slide or a fade. Which guides go deeper on each rule? Each of these posts in Mobile UI in React takes one rule or one component further: - Shadcn responsive dialog: a centered card on desktop and a bottom sheet on phones, with the content written once. - React bottom sheet for the web: snap points, drag to dismiss, nested sheets and the keyboard on vaul. - Minimum touch target size: the WCAG, iOS and Android numbers, and 44px on touch in Tailwind without a bloated desktop. - Tailwind safe area insets: bottom bars, FABs and toasts clear of the notch and the home indicator. - 100vh on mobile: when to use dvh, svh or lvh, and full-height shells that do not jump. - React bottom navigation bar: a tab bar with badges, a center action and hide on scroll. - React pull to refresh: a custom refresh in a PWA that does not fight the browser's own. - Web Vibration API: the haptic feedback a page can trigger on Android and iPhone. - React PWA install prompt: an install prompt for Chrome, Edge and iPhone, and when not to ask. - Responsive tables on mobile: when rows become cards, which columns survive, and sorting and bulk actions without headers. - React segmented control: the iOS thumb you can drag, radio semantics, and when tabs or a toggle group fit better. - React wheel picker: iOS wheels for times and dates on a native snapping scroller, with a typed field on desktop. Where should you start? Start with the overlays, because they are the most visible mobile failure and the cheapest to fix. Install the free Popover with npx wingo-ui@latest add popover (it brings the Drawer, the Button and the media query hook along), swap it in for one panel that people open on phones, and try it on your own device. The tab bar, sticky CTA bar, pull to refresh, haptic button and data table are part of Wingo UI Pro. Components in this post - Drawer (Free): The library's bottom sheet on vaul: drag to dismiss, snap points, nesting, and a side panel or dialog on desktop. Install: npx wingo-ui@latest add drawer - Popover (Free): Rich floating content anchored to a trigger, like a quick filter, form or share panel, that becomes a bottom sheet on phones. Install: npx wingo-ui@latest add popover - Bottom Tab Bar (Pro): The phone's primary navigation: 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 - Pull To Refresh (Pro): Pull a list down past its top to reload it, with rubberband resistance, an arrow, iOS spokes or a Material ring, and a haptic tick. Install: npx wingo-ui@latest add pull-to-refresh - Sticky CTA Bar (Pro): The bottom action bar of product, listing and checkout pages on phones: the price on one side, the main action on the other. Install: npx wingo-ui@latest add sticky-cta-bar - Data Table (Pro): The admin table on TanStack Table v9: search, filters, sorting, bulk actions, pinning, resizing, virtualization and cards on phones. Install: npx wingo-ui@latest add data-table - Haptic Button (Pro): A button that answers like a native control: spring press, touch ripple, a haptic tick, a long-press action and a hold-to-confirm mode. Install: npx wingo-ui@latest add haptic-button FAQ What is a mobile-first React component? A component designed for touch, a narrow screen and an on-screen keyboard first, then enhanced for a mouse and a wide window. On a phone it changes its behavior as well as its layout: it opens a bottom sheet instead of a popover, keeps 44px hit areas and has no hover-only controls. How big should buttons be in a mobile web app? WCAG 2.2 asks for 24 by 24 CSS pixels at level AA and 44 by 44 at level AAA. Apple's guidelines use 44 by 44 points as the default iOS control size and Android recommends 48 by 48 dp. A practical rule for the web is 44px on coarse pointers with 8px between targets. Should I use a useIsMobile hook or CSS media queries? Use CSS breakpoints for anything visible on the first paint, because the server cannot know the screen width and a hook returns a guess there. Use the hook for behavior that starts after an interaction, such as opening a bottom sheet instead of a popover. Does shadcn/ui have a bottom sheet for mobile? Yes, the Drawer. As of October 2026 its Base UI version is built on Base UI's drawer and its Radix version on Vaul, and the docs show a responsive example that renders a Dialog on desktop and a Drawer on mobile. Popover, Select and Dropdown Menu do not switch to a sheet on their own. Why do hover effects not work on phones? Touch screens have no hover, and in Tailwind CSS v4 the hover variant only applies when the primary input can hover, so a control that only appears on hover never shows up on a phone. Reveal it on focus and on coarse pointers as well, or replace the hover with a tap or a long press. Which Wingo UI mobile components are free? The Drawer, Sheet, Popover, Dialog, Select, Dropdown Menu, Segmented Control, Button and the useMediaQuery hook are free, and the Popover, Dialog, Select and Dropdown Menu open as bottom sheets below 768px by default. The 15 components of the Mobile category (tab bar, sticky CTA bar, pull to refresh, haptic button and more) and the Data Table are Pro. --- This article was originally published on the Wingo UI blog: Mobile-First React Components: 9 Rules With Code. The original has the live, interactive demos and is kept up to date.
做 React 页面,按钮和表单又要从头搭?🎨 shadcn/ui 提供可定制的组件代码。 想改样式,就直接改源码。🧩 shadcn-ui/ui #React #WebDev #浇朋友 #蓝朋友 #互关 #fo
Want a polished UI without being trapped in a component black box? ui (~126k★): Customizable UI components you can own. shadcn-ui/ui Which component should every starter kit include?
shadcn's select component has no reset method. the top answer in the thread: give it a key prop tied to a counter, bump the counter to force a remount, which clears the value. a plain form reset won't touch it. ui#638
why does shadcn's resizable component suddenly throw after installing the latest react-resizable-panels? the new version renamed the panel-group and resize-handle exports; shadcn still imports the old names. ui#9450