Phase 3
Make it feel native
Icons, splash, insets, back button, plugins
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.
You do not hand-cut dozens of sizes. Provide three source images and let the generator produce every density for both platforms.
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 variantnpm install -D @capacitor/assets
npx capacitor-assets generate --android --ios
npx cap sync| Asset | Size | Rules |
|---|---|---|
| Android adaptive icon | 432×432 safe zone in 1024² | Keep the logo inside the centre 66% — the system masks the edges |
| Play Store icon | 512×512 PNG, 32-bit | Uploaded in Play Console, not bundled |
| iOS app icon | 1024×1024 PNG | No alpha channel, no rounded corners — iOS masks it |
| Splash | 2732×2732 | Simple centred mark; it is shown for under a second |
npm install @capacitor/splash-screen @capacitor/status-bar
npx cap syncplugins: {
SplashScreen: {
launchAutoHide: false, // you hide it when the app is ready
backgroundColor: "#ffffff",
androidScaleType: "CENTER_CROP",
showSpinner: false,
},
},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();
}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.
<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>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.
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]);
}npm install @capacitor/keyboard
npx cap syncplugins: {
Keyboard: {
resize: "body", // layout shrinks instead of being covered
resizeOnFullScreen: true,
},
},* {
-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 */
}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.
import { Network } from "@capacitor/network";
const status = await Network.getStatus();
Network.addListener("networkStatusChange", (s) => {
setOnline(s.connected);
});| Plugin | Use it for | Notes |
|---|---|---|
@capacitor/preferences | Durable key/value storage | Replaces localStorage for tokens and settings |
@capacitor/camera | Photos from camera or library | Needs camera + photo permission strings |
@capacitor/push-notifications | Remote push | Requires Firebase on Android and APNs key on iOS |
@capacitor/share | System share sheet | Cheap, very native-feeling |
@capacitor/haptics | Tap feedback | One line, big perceived quality win |
@capacitor/browser | Open external links | Required — never use window.open |
@capacitor/filesystem | Save/read files | Pair with Share for downloads |
@capacitor/geolocation | Location | Justify it in the store data forms |
import { Capacitor } from "@capacitor/core";
if (Capacitor.isNativePlatform()) {
await Haptics.impact({ style: ImpactStyle.Light });
}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:
App.addListener("appUrlOpen", ({ url }) => {
const path = new URL(url).pathname;
router.navigate({ to: path });
});