# Mobile-First React Components: 9 Rules With Code

> Mobile-first React components, rule by rule: bottom sheets instead of popovers, 44px touch targets, safe areas, dvh, keyboard-aware bars and tables as cards.

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

## 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](https://wingo-ui.com/components/mention-input). The [Combobox](https://wingo-ui.com/components/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](https://wingo-ui.com/components/popover), [Select](https://wingo-ui.com/components/select), [Dropdown Menu](https://wingo-ui.com/components/dropdown-menu), [Dialog](https://wingo-ui.com/components/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](https://wingo-ui.com/components/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): 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 at [Drawer](https://wingo-ui.com/components/drawer) and install it with `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](https://wingo-ui.com/blog/react-bottom-sheet-for-the-web) covers snap points, drag to dismiss, nested sheets and the keyboard, and the [shadcn responsive dialog tutorial](https://wingo-ui.com/blog/shadcn-responsive-dialog-on-mobile) 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](https://github.com/shadcn-ui/ui/blob/main/apps/v4/registry/new-york-v4/hooks/use-mobile.ts) (as of October 2026). Our free [useMediaQuery](https://wingo-ui.com/components/use-media-query) 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](https://wingo-ui.com/blog/react-use-mobile-hook-ssr) 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)](https://www.w3.org/WAI/WCAG22/Understanding/target-size-minimum.html) | 24 by 24 CSS px, or enough spacing | Level AA |
| [WCAG 2.2, 2.5.5 Target Size (Enhanced)](https://www.w3.org/WAI/WCAG22/Understanding/target-size-enhanced.html) | 44 by 44 CSS px | Level AAA |
| [Apple Human Interface Guidelines](https://developer.apple.com/design/human-interface-guidelines/accessibility) | 44 by 44 pt default on iOS, 28 by 28 pt minimum | Guideline |
| [Android accessibility guide](https://developer.android.com/guide/topics/ui/accessibility/apps) | 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](https://tailwindcss.com/docs/hover-focus-and-other-states) 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](https://wingo-ui.com/components/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](https://wingo-ui.com/blog/minimum-touch-target-size-react-tailwind) 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](https://tailwindcss.com/docs/upgrade-guide) (`@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](https://wingo-ui.com/components/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](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/meta/name/viewport) 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](https://wingo-ui.com/components/bottom-tab-bar) pads the home indicator and writes its height to a `--bottom-bar-offset` CSS variable. The [Sticky CTA Bar](https://wingo-ui.com/components/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](https://wingo-ui.com/blog/tailwind-safe-area-insets) covers the utilities, a FAB stacked above the tab bar and where toasts go, and the [React bottom navigation bar guide](https://wingo-ui.com/blog/react-bottom-navigation-bar) covers badges, the center action and when a sidebar fits better.

> Live demo (Bottom Tab Bar): Tap the tabs, then tap the active one again to jump back to the top; the raised Add button opens a sheet. Try it at [Bottom Tab Bar](https://wingo-ui.com/components/bottom-tab-bar) and install it with `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](https://developer.mozilla.org/en-US/docs/Web/CSS/length) `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](https://wingo-ui.com/components/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](https://wingo-ui.com/blog/100vh-mobile-dvh-tailwind) 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](https://developer.chrome.com/blog/viewport-resize-behavior), 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](https://wingo-ui.com/components/use-keyboard-inset) 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](https://wingo-ui.com/components/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](https://wingo-ui.com/blog/tanstack-table-v9-data-table) builds filters, bulk actions and server pagination on TanStack directly, along with the phone layout.

> Live demo (Data Table): 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 at [Data Table](https://wingo-ui.com/components/data-table) and install it with `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](https://wingo-ui.com/blog/responsive-table-mobile-cards).

## 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](https://wingo-ui.com/blog/dnd-kit-mobile-touch-drag) sets up touch sensors that let a swipe still scroll the page.

The [Pull To Refresh](https://wingo-ui.com/components/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](https://wingo-ui.com/blog/react-pull-to-refresh) 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](https://wingo-ui.com/components/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](https://wingo-ui.com/blog/web-vibration-api-haptic-feedback) 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](https://ui.shadcn.com/docs/components/drawer) 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](https://ui.shadcn.com/docs/components/radix/drawer). 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](https://ui.shadcn.com/docs/components/popover) 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](https://wingo-ui.com/blog/best-react-component-library-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](https://wingo-ui.com/components/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](https://wingo-ui.com/components/segmented-control) | A few options on a thumb you can drag | Free |
| [Bottom Tab Bar](https://wingo-ui.com/components/bottom-tab-bar) | 3 to 5 primary destinations at the thumb | Pro |
| [Sticky CTA Bar](https://wingo-ui.com/components/sticky-cta-bar) | Price and the main action, above the tab bar | Pro |
| [Pull To Refresh](https://wingo-ui.com/components/pull-to-refresh) | Reload a list with a pull, or a button | Pro |
| [List](https://wingo-ui.com/components/list) | Grouped rows with swipe actions and drag to reorder | Pro |
| [Data Table](https://wingo-ui.com/components/data-table) | Rows as cards, long press to select | Pro |
| [Haptic Button](https://wingo-ui.com/components/haptic-button) | Press feedback, haptics and hold to confirm | Pro |
| [Large Title Header](https://wingo-ui.com/components/large-title-header) | An iOS large title that collapses into a bar | Pro |
| [Action Sheet](https://wingo-ui.com/components/action-sheet) | A short list of actions about one thing | Pro |
| [Picker Wheel](https://wingo-ui.com/components/picker-wheel) | iOS wheels for times and dates | Pro |

The whole [Mobile category](https://wingo-ui.com/components/category/mobile) 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](https://wingo-ui.com/blog/category/mobile-ui) takes one rule or one component further:

- [Shadcn responsive dialog](https://wingo-ui.com/blog/shadcn-responsive-dialog-on-mobile): a centered card on desktop and a bottom sheet on phones, with the content written once.
- [React bottom sheet for the web](https://wingo-ui.com/blog/react-bottom-sheet-for-the-web): snap points, drag to dismiss, nested sheets and the keyboard on vaul.
- [Minimum touch target size](https://wingo-ui.com/blog/minimum-touch-target-size-react-tailwind): the WCAG, iOS and Android numbers, and 44px on touch in Tailwind without a bloated desktop.
- [Tailwind safe area insets](https://wingo-ui.com/blog/tailwind-safe-area-insets): bottom bars, FABs and toasts clear of the notch and the home indicator.
- [100vh on mobile](https://wingo-ui.com/blog/100vh-mobile-dvh-tailwind): when to use dvh, svh or lvh, and full-height shells that do not jump.
- [React bottom navigation bar](https://wingo-ui.com/blog/react-bottom-navigation-bar): a tab bar with badges, a center action and hide on scroll.
- [React pull to refresh](https://wingo-ui.com/blog/react-pull-to-refresh): a custom refresh in a PWA that does not fight the browser's own.
- [Web Vibration API](https://wingo-ui.com/blog/web-vibration-api-haptic-feedback): the haptic feedback a page can trigger on Android and iPhone.
- [React PWA install prompt](https://wingo-ui.com/blog/react-pwa-install-prompt): an install prompt for Chrome, Edge and iPhone, and when not to ask.
- [Responsive tables on mobile](https://wingo-ui.com/blog/responsive-table-mobile-cards): when rows become cards, which columns survive, and sorting and bulk actions without headers.
- [React segmented control](https://wingo-ui.com/blog/react-segmented-control): the iOS thumb you can drag, radio semantics, and when tabs or a toggle group fit better.
- [React wheel picker](https://wingo-ui.com/blog/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](https://wingo-ui.com/pricing).

## Components in this post

- [Drawer](https://wingo-ui.com/components/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](https://wingo-ui.com/components/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](https://wingo-ui.com/components/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](https://wingo-ui.com/components/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](https://wingo-ui.com/components/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](https://wingo-ui.com/components/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](https://wingo-ui.com/components/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.

---

Source: https://wingo-ui.com/blog/mobile-first-react-components
