Phase 3

Make it feel native

Icons, splash, insets, back button, plugins

2–4 hours0/7 done

This phase is what separates an approved app from a rejected one. Reviewers open your app and ask: does this look and behave like an Android or iOS app, or like a website in a frame? Every item here is cheap and moves that answer.

01

Icons and splash screens

You do not hand-cut dozens of sizes. Provide three source images and let the generator produce every density for both platforms.

Source files (create a resources/ folder)
resources/
  icon.png              1024 x 1024, no transparency, no rounded corners
  splash.png            2732 x 2732, artwork centred in the middle 1200px
  splash-dark.png       2732 x 2732, dark-mode variant
Generate every size
npm install -D @capacitor/assets
npx capacitor-assets generate --android --ios
npx cap sync
AssetSizeRules
Android adaptive icon432×432 safe zone in 1024²Keep the logo inside the centre 66% — the system masks the edges
Play Store icon512×512 PNG, 32-bitUploaded in Play Console, not bundled
iOS app icon1024×1024 PNGNo alpha channel, no rounded corners — iOS masks it
Splash2732×2732Simple centred mark; it is shown for under a second
02

Splash behaviour and status bar

Install
npm install @capacitor/splash-screen @capacitor/status-bar
npx cap sync
capacitor.config.ts
plugins: {
  SplashScreen: {
    launchAutoHide: false,        // you hide it when the app is ready
    backgroundColor: "#ffffff",
    androidScaleType: "CENTER_CROP",
    showSpinner: false,
  },
},
Hide it once your first screen has data
import { SplashScreen } from "@capacitor/splash-screen";
import { StatusBar, Style } from "@capacitor/status-bar";
import { Capacitor } from "@capacitor/core";

export async function onAppReady() {
  if (!Capacitor.isNativePlatform()) return;
  await StatusBar.setStyle({ style: Style.Dark });   // dark icons on light bg
  await SplashScreen.hide();
}
03

Safe areas and full-screen layout

Android 15+ draws edge-to-edge by default, so your header will sit under the status bar unless you pad for it. Apply the CSS environment variables you added in Phase 1 to every fixed header, bottom bar and modal.

tsx
<header className="fixed inset-x-0 top-0 pt-[env(safe-area-inset-top)]">…</header>
<nav className="fixed inset-x-0 bottom-0 pb-[env(safe-area-inset-bottom)]">…</nav>
04

Hardware back button

On Android, ignoring the back button is an instant 'this is a website' signal — and a policy problem, because the default behaviour closes the app from any screen. Route it through your router and confirm before exiting from the root screen.

src/lib/native-back.ts
import { App } from "@capacitor/app";
import { Capacitor } from "@capacitor/core";
import { useEffect } from "react";
import { useRouter } from "@tanstack/react-router";

export function useNativeBackButton() {
  const router = useRouter();

  useEffect(() => {
    if (!Capacitor.isNativePlatform()) return;

    const handle = App.addListener("backButton", ({ canGoBack }) => {
      if (canGoBack || window.history.length > 1) {
        router.history.back();
      } else {
        App.exitApp();
      }
    });

    return () => {
      void handle.then((h) => h.remove());
    };
  }, [router]);
}
05

Keyboard, scrolling and touch polish

Install
npm install @capacitor/keyboard
npx cap sync
capacitor.config.ts
plugins: {
  Keyboard: {
    resize: "body",              // layout shrinks instead of being covered
    resizeOnFullScreen: true,
  },
},
Kill the web-view tells
* {
  -webkit-tap-highlight-color: transparent;   /* no blue flash on tap */
  -webkit-touch-callout: none;                /* no long-press menu */
}

body {
  overscroll-behavior-y: none;                /* no rubber-band on the shell */
  user-select: none;
}

input, textarea, [contenteditable] {
  user-select: text;
  font-size: 16px;                            /* stops iOS zoom on focus */
}
06

Offline and network state

An app that shows a blank page in airplane mode gets rejected by Apple and reviewed badly on Play. Detect connectivity and show a real offline state.

ts
import { Network } from "@capacitor/network";

const status = await Network.getStatus();
Network.addListener("networkStatusChange", (s) => {
  setOnline(s.connected);
});
07

Pick the native capabilities you actually need

PluginUse it forNotes
@capacitor/preferencesDurable key/value storageReplaces localStorage for tokens and settings
@capacitor/cameraPhotos from camera or libraryNeeds camera + photo permission strings
@capacitor/push-notificationsRemote pushRequires Firebase on Android and APNs key on iOS
@capacitor/shareSystem share sheetCheap, very native-feeling
@capacitor/hapticsTap feedbackOne line, big perceived quality win
@capacitor/browserOpen external linksRequired — never use window.open
@capacitor/filesystemSave/read filesPair with Share for downloads
@capacitor/geolocationLocationJustify it in the store data forms
Guard native calls so the web build still works
import { Capacitor } from "@capacitor/core";

if (Capacitor.isNativePlatform()) {
  await Haptics.impact({ style: ImpactStyle.Light });
}
08

Deep links (App Links / Universal Links)

Optional, but required if you use OAuth or want shared URLs to open in the app. Android verifies ownership with a file at https://yourdomain.com/.well-known/assetlinks.json; iOS uses apple-app-site-association. Add the intent filter in AndroidManifest.xml and handle the incoming URL:

ts
App.addListener("appUrlOpen", ({ url }) => {
  const path = new URL(url).pathname;
  router.navigate({ to: path });
});