# Web App That Feels Native on a Phone: 12 React Fixes

> 12 details behind a web app that feels native on a phone: instant taps, action sheets, swipeable tabs, large titles, haptics and a keyboard-aware composer.

- Author: [Serban Rusu](https://wingo-ui.com/blog/authors/serban), Founder of Wingo UI
- Published: Oct 13, 2026
- Category: [Mobile UI in React](https://wingo-ui.com/blog/category/mobile-ui)
- Reading time: 11 min
- Canonical: https://wingo-ui.com/blog/web-app-that-feels-native

## TL;DR

A web app that feels native on a phone gets the behavior right before the looks. It launches standalone and edge to edge, answers a tap at once without a gray flash or a double-tap zoom, opens menus and pickers as sheets from the bottom, lets you swipe between tabs and pull to refresh, and keeps the composer above the keyboard. Large titles, in-app banners and haptics come last, and each needs a visible fallback because browser support for them is uneven.

You built the app, added it to your home screen, and it still feels like a website: links flash gray when you tap them, a double tap on a button zooms the page, the menu opens as a tiny popover under your thumb, and the keyboard hides the message box. A web app that feels native gets a dozen small behaviors right, and most of them are free CSS or a few props. This list walks through twelve of them, from the launch to the keyboard, each with the component we built for it in Wingo UI, the code, and what a browser still cannot do.

The list is the practical companion to [Mobile-first React components](https://wingo-ui.com/blog/mobile-first-react-components), which covers the layout rules behind it. More guides live in [Mobile UI in React](https://wingo-ui.com/blog/category/mobile-ui).

## What makes a web app feel native on a phone?

Behavior first, looks second. Most advice on how to make a PWA feel native stops at a manifest and a few meta tags, which is step one of twelve. People forgive a web app that does not look exactly like Settings on an iPhone. They notice at once when a tap waits, a gesture does nothing, or the keyboard covers what they type. Web apps that feel native fix those first, so the twelve details below start with the ones every app needs and end with the polish.

| # | Detail | Tool |
|---|---|---|
| 1 | Launch standalone, edge to edge | manifest, `viewport-fit=cover`, safe area insets |
| 2 | Taps that answer at once | `touch-manipulation`, tap highlight, press state |
| 3 | Inputs that never zoom the page | 16px text on phones |
| 4 | Chrome that does not act like a document | `select-none`, `overscroll-behavior` |
| 5 | Navigation at the thumb | [Bottom Tab Bar](https://wingo-ui.com/components/bottom-tab-bar) |
| 6 | A large title that hands over to the bar | [Large Title Header](https://wingo-ui.com/components/large-title-header) |
| 7 | Tabs you can swipe | [Swipeable Tabs](https://wingo-ui.com/components/swipeable-tabs) |
| 8 | Action sheets instead of dropdowns | [Action Sheet](https://wingo-ui.com/components/action-sheet) |
| 9 | Bottom sheets for everything else | [Drawer](https://wingo-ui.com/components/drawer) |
| 10 | Pull to refresh | [Pull To Refresh](https://wingo-ui.com/components/pull-to-refresh) |
| 11 | In-app banners for live events | [Notification Banner](https://wingo-ui.com/components/notification-banner) |
| 12 | Haptics, a keyboard-aware composer, and reduced motion | [Haptic Button](https://wingo-ui.com/components/haptic-button), `useKeyboardInset` |

## 1. Does it launch like an app?

An installed app opens without the address bar and fills the screen. That takes a manifest with `display: "standalone"` and a viewport that reaches the edges. In the Next.js App Router both are files and exports:

```tsx
// app/manifest.ts
import type { MetadataRoute } from "next";

export default function manifest(): MetadataRoute.Manifest {
  return {
    name: "Northwind CRM",
    short_name: "Northwind",
    start_url: "/",
    display: "standalone",
    background_color: "#ffffff",
    theme_color: "#ffffff",
    icons: [
      { src: "/icon-192.png", sizes: "192x192", type: "image/png" },
      { src: "/icon-512.png", sizes: "512x512", type: "image/png" },
    ],
  };
}
```

```tsx
// app/layout.tsx
import type { Viewport } from "next";

export const viewport: Viewport = {
  width: "device-width",
  initialScale: 1,
  viewportFit: "cover",
};
```

With `viewportFit: "cover"` the page draws under the notch and the home indicator, so every bar pinned to an edge must pad itself with `env(safe-area-inset-*)`. Our [Tailwind safe area guide](https://wingo-ui.com/blog/tailwind-safe-area-insets) has the utilities and the arithmetic for stacked bars. Some details only make sense once installed, like hiding your own install banner. A custom variant keeps that in CSS:

```css
/* app/globals.css */
@custom-variant standalone (@media (display-mode: standalone));
```

Then `standalone:hidden` hides an element inside the installed app. The [React PWA install prompt guide](https://wingo-ui.com/blog/react-pwa-install-prompt) covers how people get there on Chrome, Edge and iPhone.

## 2. Does a tap answer at once?

On a native control the press shows under your finger, with no gray box and no wait. Two CSS details get a web button there. `touch-action: manipulation` turns off double-tap zoom on the element, so a quick second tap on a stepper never zooms the page. WebKit's [post on fast tapping](https://webkit.org/blog/5610/more-responsive-tapping-on-ios/) (December 2015) explains that it also keeps single taps immediate at every zoom level. And `-webkit-tap-highlight-color: transparent` removes the translucent box Safari and Chrome draw over a tapped link. Tailwind CSS v4's preflight already sets it to transparent on `html`, so in a Tailwind app the job is the opposite one: give every pressable its own pressed state.

```tsx
<button
  type="button"
  className="h-11 touch-manipulation rounded-xl bg-foreground px-4 text-background transition-transform [-webkit-tap-highlight-color:transparent] active:scale-[0.97]"
>
  Save
</button>
```

The free Wingo UI [Button](https://wingo-ui.com/components/button) carries both classes itself, so a copied button keeps them without a global stylesheet, and presses with a spring instead of a CSS transition. Our [Tailwind touch-manipulation tutorial](https://wingo-ui.com/blog/tailwind-touch-manipulation) goes through every `touch-*` value and the bug each one fixes.

## 3. Does focusing a field zoom the page?

Safari on iOS zooms into a focused field whose text is smaller than 16px, and it does not zoom back out when the field loses focus. Adding `maximum-scale=1` to the viewport stops that zoom, but Android browsers then block pinch zoom for the whole page, and Lighthouse fails the page on its viewport accessibility audit. Use 16px text on phones and your smaller size from `md` up:

```tsx
<input aria-label="Search clients" className="h-11 w-full rounded-xl bg-foreground/5 px-4 text-base md:h-10 md:text-sm" />
```

The Wingo UI design system makes this one rule for every text field, `text-base md:text-sm`, and the shared field box and the Prompt Input follow it. Watch for the same bug in places that are easy to miss: a search field inside a header, a `select`, and a `contenteditable` editor all zoom the page when their computed font size is under 16px.

## 4. Does the interface act like a document?

Only where it should. A web page lets you select any text and chains a scroll from an inner list into the page behind it. An app does neither on its controls. Add `select-none` to buttons, tabs and bars, so a long press on a tab does not select its label, and leave real content selectable: people copy addresses, order numbers and error messages. Put `overscroll-behavior: contain` (`overscroll-contain`) on inner scrollers, so the end of a list inside a sheet does not scroll the page behind it; Safari supports it from version 16. Long press callouts on images in your chrome go away with `[-webkit-touch-callout:none]`, which Safari supports and other browsers ignore.

## 5. Is the navigation at the thumb?

Native apps put their 3 to 5 main sections in a bar at the bottom, where the thumb already is. On the web that bar needs the home indicator inset, a hide-on-scroll behavior and a way to tell the rest of the page how tall it is. The [Bottom Tab Bar](https://wingo-ui.com/components/bottom-tab-bar) does those three, and our [React bottom navigation bar guide](https://wingo-ui.com/blog/react-bottom-navigation-bar) builds one step by step. Keep a sidebar from `md` up: a tab bar on a 1440px screen looks like a phone app stretched to fit.

## 6. Does the title behave like iOS?

The iOS large title is the most recognizable detail of an iPhone app: a 34px heading that scrolls up with the page and hands over to a small title in a frosted bar. The [Large Title Header](https://wingo-ui.com/components/large-title-header) follows your finger through that handover, settles to fully expanded or fully collapsed when you stop halfway, and stretches the title by up to 8% when you pull past the top in Safari. The title stays the page's only `h1`, and under reduced motion the handover becomes an instant switch. The [React collapsing header guide](https://wingo-ui.com/blog/react-collapsing-header) builds this handover, and a photo hero, on motion values.

```tsx
"use client";

import { useState } from "react";
import { SquarePen } from "lucide-react";
import { Button } from "@/components/ui/button";
import { LargeTitleHeader } from "@/components/ui/large-title-header";

export function MessagesHeader() {
  const [query, setQuery] = useState("");
  return (
    <LargeTitleHeader
      title="Messages"
      subtitle="3 unread"
      backLabel="Settings"
      backHref="/settings"
      search
      searchValue={query}
      onSearchChange={setQuery}
      actions={
        <Button variant="ghost" size="icon" radius="full" aria-label="New message">
          <SquarePen />
        </Button>
      }
    />
  );
}
```

> Live demo (Large Title Header): Scroll the conversations: the large title slides up and the compact title rises into the glass bar. Tap Search and the field moves right under the bar with a Cancel button. Try it at [Large Title Header](https://wingo-ui.com/components/large-title-header) and install it with `npx wingo-ui@latest add large-title-header`.

Use it on top-level lists and settings pages. Detail screens read better with `mode="compact"`, which shows only the bar, the way iOS does on a pushed page.

## 7. Can you swipe between tabs?

On a phone, tabs are pages side by side, and a sideways swipe moves between them. Plain tabs that only react to taps feel like a website. The [Swipeable Tabs](https://wingo-ui.com/components/swipeable-tabs) put the pages in a native scroll snap row, so the swipe has the browser's own momentum, and the underline stretches between tabs as the pages move. A vertical scroll inside a page never changes the tab, and tapping the active tab scrolls its page back to the top. The [React swipeable tabs guide](https://wingo-ui.com/blog/react-swipeable-tabs) covers the scroll snap track and the indicator in detail.

```tsx
import { SwipeableTabs } from "@/components/ui/swipeable-tabs";
import { ClientList } from "./client-list"; // your own list

export function ClientTabs() {
  return (
    <SwipeableTabs
      aria-label="Clients"
      options={[
        { value: "all", label: "All", badge: 4, content: <ClientList /> },
        { value: "active", label: "Active", badge: 2, content: <ClientList status="active" /> },
        { value: "overdue", label: "Overdue", badge: 1, content: <ClientList status="overdue" /> },
      ]}
    />
  );
}
```

> Live demo (Swipeable Tabs): Swipe the client list sideways, or swipe with two fingers on a trackpad: the indicator follows the pages, and a tap on a tab slides to it. Try it at [Swipeable Tabs](https://wingo-ui.com/components/swipeable-tabs) and install it with `npx wingo-ui@latest add swipeable-tabs`.

Swiping is never the only way: every tab stays a button, and the arrow keys move between them. Pages mount lazily and stay mounted by default (`lazy` and `keepMounted`), so a page you swipe back to is not mounted again from scratch.

## 8. Do menus open as action sheets?

On a phone they should. Tap the "more" button in a native app and a sheet of actions rises from the bottom, with a red destructive row and a separate Cancel. A dropdown anchored to a 44px button covers the thumb that opened it. The [Action Sheet](https://wingo-ui.com/components/action-sheet) always opens as a sheet on phones; from 768px it becomes a compact dialog by default, or a menu anchored to the trigger with `desktop="popover"`. The [React action sheet guide](https://wingo-ui.com/blog/react-action-sheet) covers async rows and what happens when an action fails. It can also be awaited, which keeps the call site short:

```tsx
"use client";

import { MoreHorizontal } from "lucide-react";
import { useActionSheet } from "@/components/ui/action-sheet";
import { Button } from "@/components/ui/button";

type PhotoMenuProps = { onSave: () => void; onDelete: () => Promise<void> };

export function PhotoMenu({ onSave, onDelete }: PhotoMenuProps) {
  const actionSheet = useActionSheet();
  async function open() {
    const id = await actionSheet.show({
      actions: [
        { id: "save", label: "Save to photos" },
        { id: "delete", label: "Delete for me", tone: "danger", onSelect: onDelete },
      ],
    });
    if (id === "save") onSave();
  }
  return (
    <Button variant="ghost" size="icon" radius="full" aria-label="Photo options" onClick={open}>
      <MoreHorizontal />
    </Button>
  );
}
```

`show()` needs an `ActionSheetProvider` near the root (it throws without one) and resolves with the chosen id, or `null` on cancel. An action that returns a promise shows a spinner in its row until it settles; if the promise rejects, the row shakes and stays open for a retry.

> Live demo (Action Sheet): Tap the listing's menu button: the sheet rises with the actions and a separate Cancel. Try Delete listing, which spins until its promise resolves. Try it at [Action Sheet](https://wingo-ui.com/components/action-sheet) and install it with `npx wingo-ui@latest add action-sheet`.

Set `variant="material"` for one attached sheet with a handle and icon rows, closer to Android.

## 9. Do pickers and forms open from the bottom?

Everything that would float over the page on a desktop, such as filters, a date picker, a share menu or a short form, should rise from the bottom on a phone, where the thumb can reach it and drag it away. The free [Drawer](https://wingo-ui.com/components/drawer) is the sheet the rest of the library uses: drag to dismiss, snap points, a scroll body that drags the sheet only once its list is back at the top, and from 768px a centered sheet, a side panel or a dialog, set with `desktop`. The [React bottom sheet guide](https://wingo-ui.com/blog/react-bottom-sheet-for-the-web) covers snap points and the keyboard in detail.

## 10. Can you pull to refresh?

In an installed PWA, only if you add the gesture yourself. People pull a list down to reload it without thinking, and in a standalone PWA the browser's own pull to refresh is gone or reloads the whole page. The [Pull To Refresh](https://wingo-ui.com/components/pull-to-refresh) adds the gesture with rubberband resistance and an arrow, iOS spokes or a Material ring:

```tsx
import { PullToRefresh } from "@/components/ui/pull-to-refresh";
import { InboxList } from "./inbox-list"; // your own list

export function Inbox({ reload }: { reload: () => Promise<void> }) {
  return (
    <PullToRefresh scroll="self" className="min-h-0 flex-1" onRefresh={reload}>
      <InboxList />
    </PullToRefresh>
  );
}
```

It sets `overscroll-behavior-y: contain` on the scroller so Chrome does not reload the page, ignores sideways swipes, never starts a pull inside an input or a nested list that is not at its top, and spins for at least 500ms so the indicator never flashes. There is no keyboard gesture, so keep a reload button, or a `shortcut`, for mouse and keyboard users. The [React pull to refresh guide](https://wingo-ui.com/blog/react-pull-to-refresh) explains why Safari needs a non-passive `touchmove` listener for this.

## 11. Do live events arrive like notifications?

A toast at the bottom suits "Saved". A new message, an order or a reminder that arrives while someone uses the app reads better as a banner from the top, like the system's own. The [Notification Banner](https://wingo-ui.com/components/notification-banner) queues them one at a time, lets you swipe a banner up to dismiss it or pull it down to expand its actions, and pauses its timer while you hold it:

```tsx
"use client";

import { useRouter } from "next/navigation";
import { MessageCircle } from "lucide-react";
import { notify } from "@/components/ui/notification-banner";

export function useMessageBanner() {
  const router = useRouter();
  return (message: { id: string; from: string; text: string }) =>
    notify({
      id: `message-${message.id}`,
      title: message.from,
      message: message.text,
      icon: <MessageCircle />,
      onPress: () => router.push(`/messages/${message.id}`),
    });
}
```

Mount `<NotificationBanner appName="Northwind CRM" />` once near the root. Sending the same `id` again updates the banner in place instead of stacking a duplicate, which matters when a socket delivers the same event twice. This is an in-app banner; it does not replace Web Push for people who have closed the app.

## 12. What about haptics, the keyboard and motion?

Three last details, each with a limit worth knowing.

**Haptics.** The [Haptic Button](https://wingo-ui.com/components/haptic-button) presses on a spring, spreads a ripple from the touch point and plays a light tick on touch, and its `mode="hold"` fills while you hold to confirm a delete. The tick uses `navigator.vibrate`, which works in Chromium on Android after the first tap. Safari on iPhone has no Vibration API, and since iOS 26.5 a web page can no longer trigger the system tick of a hidden switch, so on a current iPhone the press and the fill carry the feedback alone. Our [Web Vibration API guide](https://wingo-ui.com/blog/web-vibration-api-haptic-feedback) covers what still ticks.

**The keyboard.** On iOS, and on Android since Chrome 108, the keyboard covers fixed bars instead of pushing them up. A chat composer pinned to the bottom needs to follow it. The `useKeyboardInset` hook reports the covered height as a motion value, and the [iOS Safari keyboard guide](https://wingo-ui.com/blog/ios-safari-keyboard-position-fixed) builds the composer step by step.

**Motion.** Native transitions use springs and respect the system's reduced motion setting. The components in this post take their springs from one shared set in `lib/motion` and read the reduced motion preference, so the title handover becomes an instant switch, the action sheet fades instead of sliding, and the haptic button drops its dip, ripple and shake.

## Which of these should you add first?

Start with the ones that cost nothing, because they fix the tells everyone notices: the launch (1), the taps (2), the zoom (3) and the document behavior (4) are CSS and a manifest. Then the sheets: `npx wingo-ui@latest add drawer` installs the free Drawer. The bottom tab bar, large title, swipeable tabs, action sheet, pull to refresh, banners, haptic button and keyboard hook are part of [Wingo UI Pro](https://wingo-ui.com/pricing), with live demos on each component page, so you can try the native app feel on your phone before you install them.

## Components in this post

- [Large Title Header](https://wingo-ui.com/components/large-title-header) (Pro): A React iOS large title header that shrinks into a compact title in a glass bar as you scroll. Install: `npx wingo-ui@latest add large-title-header`
- [Action Sheet](https://wingo-ui.com/components/action-sheet) (Pro): A React iOS-style action sheet: a short list of actions with a title, red destructive rows, a separate Cancel and an awaitable show(). Install: `npx wingo-ui@latest add action-sheet`
- [Notification Banner](https://wingo-ui.com/components/notification-banner) (Pro): iOS-style React notification banners for messages, orders and reminders that drop from the top one at a time, with a queue. Install: `npx wingo-ui@latest add notification-banner`
- [Swipeable Tabs](https://wingo-ui.com/components/swipeable-tabs) (Pro): React swipeable tabs whose pages sit side by side and follow your thumb: swipe and the indicator tracks the finger, tap and the pages slide. Install: `npx wingo-ui@latest add swipeable-tabs`
- [Haptic Button](https://wingo-ui.com/components/haptic-button) (Pro): A React haptic button that feels native: 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`
- [Pull To Refresh](https://wingo-ui.com/components/pull-to-refresh) (Pro): A React pull to refresh wrapper: pull a list past its top to reload it, with rubberband resistance, iOS spokes or a Material ring, haptics. Install: `npx wingo-ui@latest add pull-to-refresh`

## FAQ

### How do I make a PWA feel native?

Launch it with display standalone and viewport-fit=cover, pad every pinned bar with the safe area insets, and fix the behaviors that give a web page away: slow or flashing taps, dropdown menus where a phone expects a sheet, fixed bars hidden by the keyboard, and pages that scroll like documents. Visual details like large titles come after that.

### Can a web app use haptic feedback on iPhone?

Barely. Safari has no Vibration API, and since iOS 26.5 the system tick of a native switch plays only when the user's own tap toggles it, so a web app cannot tick on demand. Android Chrome supports navigator.vibrate after the first tap, so treat haptics as an extra and keep a visual response for every one.

### Should a web app copy iOS design on Android?

Copy the behaviors both platforms share, such as sheets from the bottom, tabs you can swipe and instant press feedback. Platform-specific looks are a choice: several Wingo UI components offer an iOS and a Material variant, for example the action sheet and the pull to refresh indicator.

### What gives away that an app is a website?

The usual tells are a gray box over tapped links, a page that zooms when you double tap a button or focus a small input, menus that open as tiny popovers, the keyboard covering a bottom bar, text that gets selected when you hold a button, and the whole page bouncing behind a scrolling list.

### Are these components free?

The Button and the Drawer, which carry the tap and sheet basics, are free in Wingo UI. The Bottom Tab Bar, Large Title Header, Swipeable Tabs, Action Sheet, Pull To Refresh, Notification Banner, Haptic Button and the useKeyboardInset hook are part of Wingo UI Pro.

---

Source: https://wingo-ui.com/blog/web-app-that-feels-native
