WingoUI
ComponentsTemplatesDocsChangelogBlogAI agents
ThemePricing
  1. Home
  2. Blog
  3. Mobile UI in React
  4. Mobile-First React Components: 9 Rules With Code
  1. Blog
  2. Mobile UI in React
  3. Mobile-First React Components: 9 Rules With Code
Mobile UI in React
Mobile UI in React

Mobile-First React Components: 9 Rules With Code

SR
Serban Rusu · Founder of Wingo UI
Oct 9, 2026 · 20 min read

On this page

0%
  1. What makes a React component mobile-first?
  2. Why should a popover become a bottom sheet on a phone?
  3. How do you switch between a sheet and a dialog without a hydration flash?
  4. How big should touch targets be?
  5. Why do hover effects break on phones?
  6. How do you keep fixed bars clear of the notch and the home indicator?
  7. Should you use 100vh, dvh or svh on mobile?
  8. What happens to bottom bars when the on-screen keyboard opens?
  9. How should a data table work on a phone?
  10. Which gestures belong in a mobile web app?
  11. Does shadcn/ui handle mobile?
  12. Which components does a React mobile web app need?
  13. How do you test mobile-first React components?
  14. Which guides go deeper on each rule?
  15. Where should you start?
  16. FAQ
    1. What is a mobile-first React component?
    2. How big should buttons be in a mobile web app?
    3. Should I use a useIsMobile hook or CSS media queries?
    4. Does shadcn/ui have a bottom sheet for mobile?
    5. Why do hover effects not work on phones?
    6. Which Wingo UI mobile components are free?

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.

Published Oct 9, 2026

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:

RuleOn a phoneWhy
OverlaysPopovers, selects, menus and dialogs open as bottom sheets below 768pxAn anchored panel has no room and sits under the thumb
Touch targets44px on coarse pointers, 8px apartA finger is less precise than a cursor
HoverNothing is hover-onlyTouch screens have no hover
Safe areasFixed bars pad env(safe-area-inset-*)The notch and the home indicator cover content
Heightdvh or svh, never vh100vh is taller than the visible screen
KeyboardBottom bars hide or ride above it, inputs use 16px textFixed bars end up behind the keyboard
TablesRows become cardsSideways scrolling hides most of every row
GesturesEvery gesture has a visible alternativeGestures are invisible, and not everyone can perform them
Layout and behaviorCSS breakpoints for layout, a hook for behaviorThe 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:

tsx
"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.

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
$ npx wingo-ui@latest add drawer
FreeDrawer docs

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 (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:

ts
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:

tsx
// 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)SizeStatus
WCAG 2.2, 2.5.8 Target Size (Minimum) (opens in a new tab)24 by 24 CSS px, or enough spacingLevel AA
WCAG 2.2, 2.5.5 Target Size (Enhanced) (opens in a new tab)44 by 44 CSS pxLevel AAA
Apple Human Interface Guidelines (opens in a new tab)44 by 44 pt default on iOS, 28 by 28 pt minimumGuideline
Android accessibility guide (opens in a new tab)48 by 48 dpRecommendation

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:

tsx
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 (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:

tsx
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 (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:

tsx
// 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:

tsx
"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.

Tap the tabs, then tap the active one again to jump back to the top; the raised Add button opens a sheet
$ npx wingo-ui@latest add bottom-tab-bar
ProBottom Tab Bar docs

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 (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:

tsx
"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):

tsx
"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.

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
$ npx wingo-ui@latest add data-table
ProData Table docs

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 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):

tsx
"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.

tsx
"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 (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:

ComponentJob on a phoneAccess
DrawerThe bottom sheet: drag to dismiss, snap points, nestingFree
Popover, Select, Dropdown Menu, DialogOpen as bottom sheets below 768pxFree
Segmented ControlA few options on a thumb you can dragFree
Bottom Tab Bar3 to 5 primary destinations at the thumbPro
Sticky CTA BarPrice and the main action, above the tab barPro
Pull To RefreshReload a list with a pull, or a buttonPro
ListGrouped rows with swipe actions and drag to reorderPro
Data TableRows as cards, long press to selectPro
Haptic ButtonPress feedback, haptics and hold to confirmPro
Large Title HeaderAn iOS large title that collapses into a barPro
Action SheetA short list of actions about one thingPro
Picker WheeliOS wheels for times and datesPro

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:

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

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

    The library's bottom sheet on vaul: drag to dismiss, snap points, nesting, and a side panel or dialog on desktop.

    Free
  • Popover

    Rich floating content anchored to a trigger, like a quick filter, form or share panel, that becomes a bottom sheet on phones.

    Free
  • Bottom Tab Bar

    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.

    Pro
  • Pull To Refresh

    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.

    Pro
  • Sticky CTA Bar

    The bottom action bar of product, listing and checkout pages on phones: the price on one side, the main action on the other.

    Pro
  • Data Table

    The admin table on TanStack Table v9: search, filters, sorting, bulk actions, pinning, resizing, virtualization and cards on phones.

    Pro
  • Haptic Button

    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.

    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

Share

SR

About the author

Serban Rusu

Founder of Wingo UI

Serban Rusu is the founder of Wingo UI. He builds the component library, its CLI and its MCP server, and writes about React interfaces that work well on phones and with AI coding agents.

More from Serban
Keep reading

Related posts

All posts
Comparisons and alternatives

Best React Component Library for Mobile in 2026: 9 Compared

The best React component library for mobile web apps in 2026: 9 libraries checked at 390px for bottom sheets, touch targets, safe areas and gestures.

SRSerban Rusu·Oct 9, 2026·12 min read
Mobile UI in React

Tailwind Safe Area Insets: Bottom Bars, FABs and Toasts

A Tailwind safe area guide for v4: opt in with viewport-fit=cover, pad bars with env(safe-area-inset-bottom) and keep FABs and toasts off the home indicator.

SRSerban Rusu·Oct 9, 2026·12 min read
Comparisons and alternatives

14 shadcn Alternatives in 2026: Price, Mobile and MCP

14 shadcn alternatives checked in October 2026: free and paid UI libraries by license, price, mobile behavior and MCP support, with a pick per use case.

SRSerban Rusu·Oct 9, 2026·19 min read
Newsletter

Get new posts by email

New guides, tutorials and comparisons from the Wingo UI blog, sent when they are published.

No spam. Unsubscribe at any time.

WingoUI

Animated, configurable, mobile-first React components. Copy the source, make it yours, and let your coding agent build with it.

ComponentsTemplatesPricingBlogTheme

Component categories

  • Buttons & Actions
  • Inputs
  • Forms
  • Navigation
  • Overlays
  • Feedback
  • Data Display
  • Tables & Lists
  • Charts & Stats
  • Layout
  • Media
  • AI Kit
  • Text & Effects
  • Mobile
  • Commerce
  • Marketing Sections
  • Blocks
  • Hooks & Utilities
Wingo UI