Mobile-First React Components: 9 Rules With Code
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:
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:
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.
$ npx wingo-ui@latest add drawerThe 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 (opens in a new tab) (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:
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:
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:
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 (opens in a new tab) 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:
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 (opens in a new tab) (@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:
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 (opens in a new tab) 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:
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:
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.
$ npx wingo-ui@latest add bottom-tab-barThe 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 (opens in a new tab) 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 (opens in a new tab), 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:
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):
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.
$ npx wingo-ui@latest add data-tableThe 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 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-actionon draggable surfaces.touch-pan-yon something you swipe sideways keeps vertical scrolling native;touch-noneis for surfaces you drag freely, like a signature pad or a slider thumb.overscroll-behavior: containon 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
setStateon 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):
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.
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 (opens in a new tab) 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 (opens in a new tab). 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 (opens in a new tab) 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:
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:
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.
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.
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.
- Mobile UI
- React
- Tailwind CSS
- Bottom sheet
- Touch targets
- Accessibility