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 actio...

