Native Feel
Everyone who opens your app has used dozens of other apps on the same phone. They already know where back is, how a switch flips, and which dialog button cancels. When an app redraws those parts its own way, each one becomes a small thing to figure out.
Notifications
Alerts
Lock Screen
Switch platforms, then press a row and flip a switch.
It's the same screen, with the same rows and the same brand color. Switch platforms and watch what changes: the back button, the title, the list, the switches, and what a press looks like. On iPhone a row highlights under your finger. On Android a ripple grows from the point you touched.
Draw the parts people learned from the system the way their platform draws them, and put your brand in color, content and voice.
What changes
| | iPhone | Android | | --- | --- | --- | | Back | Chevron, top leading. Swipe right from the edge, or from anywhere in the content since iOS 26 | Arrow in the top app bar. The system back gesture from either edge, or the back button | | Title | Large title that shrinks into the bar as you scroll | 64dp top app bar, title after the arrow | | Lists | Grouped, inset rows on a gray background, Title Case labels | Full-width rows, section labels in the primary color, sentence case | | Switches | iOS switch; only the switch toggles | M3 switch, the thumb grows when on; the whole row toggles | | Press | Highlight or dim on touch-down | Ripple from the touch point, a 10% state layer | | Type | SF Pro, Dynamic Type, 17pt body | Roboto, sizes in sp, scaling up to 200% | | Icons | SF Symbols | Material Symbols | | Sheets | Detents and a grabber | Bottom sheet with a 32×4dp drag handle |
Each row has its own concept: Back Navigation, Touch Feedback, Text Size, Bottom Sheets and Haptics.
Dialogs
Dialogs are where a home-made design does the most damage, because a wrong button costs data. Switch between one custom dialog and the native ones.
Delete 3 photos?
They'll be removed from all your devices.
iPhone: Cancel leading, Delete trailing.
3 selected
Delete 3 photos?
They'll be removed from all your devices.
Android: text buttons at the trailing edge, Cancel left of Delete.
Press a button to close the dialog, and the trash button to open it again.
In a row of buttons, both platforms put the safe choice on the left and the action on the right.
- On iPhone, Apple puts the default button on the trailing side of a row, or at the top of a stack, and Cancel on the leading side or at the bottom. Always call it Cancel, and name the action with a verb, never "OK".
- On Android, Material 3 puts text buttons at the trailing edge, with the
dismiss button left of the confirm button, and allows at most two.
Compose's
AlertDialog(confirmButton, dismissButton)does the layout.
The custom dialog puts Delete where both platforms put Cancel, so a reflexive tap deletes the photos. The system dialog gets this right for free. Before you ask at all, check Destructive Actions: an undo is often better than a question.
Where the brand goes
Your brand belongs in what's yours: color, illustration, content, copy and
the motion of your own content. Tint the system switch with your color. If
you use your own font, make it scale like the system one: SwiftUI's
.custom(_:size:relativeTo:) follows Dynamic Type, and sizes in sp follow
Android's font scale. Don't redraw the back button.
The 2026 look
iOS 26 put bars and tab bars on Liquid Glass, floating over
content, and iOS 27 adds a setting that lets people choose between clear
and tinted. Android has Material 3 Expressive, with springier motion and
new shapes, on Pixel since September 2025. In Compose, MaterialExpressiveTheme
and MotionScheme are internal in material3 1.4.0, the stable version. They
only become public in 1.5.0, which is in beta.
System bars pick up these changes on their own. iOS 27 also makes iPhone apps resizable, in iPhone Mirroring and on iPad. And on iPhone Duo, the tab bar, toolbar and back button move into a strip on one side of the screen. Standard bars move by themselves. A hand-built bottom bar stays where you drew it.
Cross-platform
- In React Native and Expo, use the native stack (Expo Router's
Stack), not the JavaScript one. Use native tabs:expo-router/unstable-native-tabsup to SDK 57,expo-router/native-tabsfrom SDK 58. Pick values per platform withPlatform.select, and system colors withPlatformColor. Expo UI (@expo/ui, stable since SDK 56) renders real SwiftUI and Compose controls when the stock components don't cover it. - In Flutter,
Switch.adaptive,Slider.adaptive,AlertDialog.adaptiveandshowAdaptiveDialogdraw Cupertino on iOS and Material elsewhere.
Usage
// Push this inside a NavigationStack: the back button and swipe come free.
struct NotificationSettings: View {
@State private var allow = true
@State private var confirmClear = false
var body: some View {
List {
Section("Alerts") {
Toggle("Allow Notifications", isOn: $allow)
}
Section {
Button("Clear History", role: .destructive) { confirmClear = true }
}
}
.listStyle(.insetGrouped)
.navigationTitle("Notifications") // large title by default
.tint(Color("Brand")) // your color, system shapes
.alert("Clear history?", isPresented: $confirmClear) {
Button("Cancel", role: .cancel) {} // leading
Button("Clear", role: .destructive) { clear() } // trailing
} message: {
Text("This can't be undone.")
}
}
}@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NotificationSettings(onBack: () -> Unit, onClear: () -> Unit) {
var allow by rememberSaveable { mutableStateOf(true) }
var confirmClear by rememberSaveable { mutableStateOf(false) }
Scaffold(topBar = {
TopAppBar(
title = { Text("Notifications") },
navigationIcon = {
IconButton(onClick = onBack) {
// Material Symbols "arrow_back", imported as a vector drawable
Icon(painterResource(R.drawable.arrow_back), contentDescription = "Back")
}
},
)
}) { inner ->
LazyColumn(contentPadding = inner) {
item {
ListItem( // the whole row toggles
headlineContent = { Text("Allow notifications") },
trailingContent = { Switch(checked = allow, onCheckedChange = null) },
modifier = Modifier.toggleable(allow, role = Role.Switch) { allow = it },
)
}
item { ListItem({ Text("Clear history") }, Modifier.clickable { confirmClear = true }) }
}
}
if (confirmClear) AlertDialog(
onDismissRequest = { confirmClear = false },
title = { Text("Clear history?") },
text = { Text("This can't be undone.") },
dismissButton = { TextButton({ confirmClear = false }) { Text("Cancel") } },
confirmButton = { TextButton({ onClear(); confirmClear = false }) { Text("Clear") } },
)
}import { Stack } from "expo-router";
import { useState } from "react";
import { Alert, Platform, PlatformColor, Pressable, ScrollView, Switch, Text } from "react-native";
const label = Platform.select({
ios: PlatformColor("label"),
android: PlatformColor("?android:attr/textColorPrimary"),
});
export default function NotificationSettings() {
const [allow, setAllow] = useState(true);
// The system alert on iOS, the system dialog on Android
const confirmClear = () =>
Alert.alert("Clear history?", "This can't be undone.", [
{ text: "Cancel", style: "cancel" },
{ text: "Clear", style: "destructive", onPress: clear },
]);
return (
<ScrollView contentInsetAdjustmentBehavior="automatic">
<Stack.Screen options={{ title: "Notifications", headerLargeTitleEnabled: true }} />
<Switch value={allow} onValueChange={setAllow} />
<Pressable
onPress={confirmClear}
android_ripple={{ color: "rgba(0,0,0,0.1)" }}
style={({ pressed }) => Platform.OS === "ios" && pressed && { opacity: 0.6 }}
>
<Text style={{ color: label }}>Clear history</Text>
</Pressable>
</ScrollView>
);
}SwitchListTile.adaptive(
title: const Text('Allow notifications'),
value: allow,
onChanged: (v) => setState(() => allow = v),
);
// Cupertino alert on iOS, Material dialog on Android
showAdaptiveDialog<void>(
context: context,
builder: (context) => AlertDialog.adaptive(
title: const Text('Clear history?'),
content: const Text("This can't be undone."),
actions: [
action(context, 'Cancel', () => Navigator.pop(context)),
action(context, 'Clear', clear, destructive: true),
],
),
);
Widget action(BuildContext context, String label, VoidCallback onPressed,
{bool destructive = false}) =>
switch (Theme.of(context).platform) {
TargetPlatform.iOS || TargetPlatform.macOS => CupertinoDialogAction(
isDestructiveAction: destructive, onPressed: onPressed, child: Text(label)),
_ => TextButton(onPressed: onPressed, child: Text(label)),
};System controls also come with the accessibility work done: VoiceOver and TalkBack roles, Dynamic Type, Bold Text, Increase Contrast and the right focus order. A custom switch has to rebuild every one of those, and most don't. Break the convention only where your app is the convention, like the canvas of a drawing app or the controls of a game.
Resources
Alerts in the Human Interface Guidelines - Apple's rules for alert buttons, including where Cancel and the default button go in a row and in a stack.
Dialogs in Jetpack Compose - The Material 3 AlertDialog with its confirm and dismiss buttons, laid out for you.
Automatic platform adaptations - Everything Flutter changes per platform, from page transitions and scroll physics to the .adaptive widgets.
Native Stack Navigator - React Navigation's stack on UINavigationController and Fragment, with large titles and native sheets.
Native tabs in Expo Router - The system tab bar from React Native, with the import path for each Expo SDK.