From ehmo-platform-design-skills
Apple Human Interface Guidelines for iPhone. Use when building, reviewing, or refactoring SwiftUI/UIKit interfaces for iOS. Triggers on tasks involving iPhone UI, iOS components, accessibility, Dynamic Type, Dark Mode, or HIG compliance.
npx claudepluginhub joshuarweaver/cascade-content-creation-misc-1 --plugin ehmo-platform-design-skillsThis skill uses the workspace's default tool permissions.
Comprehensive rules derived from Apple's Human Interface Guidelines. Apply these when building, reviewing, or refactoring any iPhone app interface.
Guides Next.js Cache Components and Partial Prerendering (PPR) with cacheComponents enabled. Implements 'use cache', cacheLife(), cacheTag(), revalidateTag(), static/dynamic optimization, and cache debugging.
Guides building MCP servers enabling LLMs to interact with external services via tools. Covers best practices, TypeScript/Node (MCP SDK), Python (FastMCP).
Generates original PNG/PDF visual art via design philosophy manifestos for posters, graphics, and static designs on user request.
Comprehensive rules derived from Apple's Human Interface Guidelines. Apply these when building, reviewing, or refactoring any iPhone app interface.
Impact: CRITICAL
All interactive elements must have a minimum tap target of 44x44 points. This includes buttons, links, toggles, and custom controls.
Correct:
Button("Save") { save() }
.frame(minWidth: 44, minHeight: 44)
Incorrect:
// 20pt icon with no padding — too small to tap reliably
Button(action: save) {
Image(systemName: "checkmark")
.font(.system(size: 20))
}
// Missing .frame(minWidth: 44, minHeight: 44)
Never place interactive or essential content under the status bar, Dynamic Island, or home indicator. Use SwiftUI's automatic safe area handling or UIKit's safeAreaLayoutGuide.
Correct:
struct ContentView: View {
var body: some View {
VStack {
Text("Content")
}
// SwiftUI respects safe areas by default
}
}
Incorrect:
struct ContentView: View {
var body: some View {
VStack {
Text("Content")
}
.ignoresSafeArea() // Content will be clipped under notch/Dynamic Island
}
}
Use .ignoresSafeArea() only for background fills, images, or decorative elements — never for text or interactive controls.
Place primary actions at the bottom of the screen where the user's thumb naturally rests. Secondary actions and navigation belong at the top.
Correct:
VStack {
ScrollView { /* content */ }
Button("Continue") { next() }
.buttonStyle(.borderedProminent)
.padding()
}
Incorrect:
VStack {
Button("Continue") { next() } // Top of screen — hard to reach one-handed
.buttonStyle(.borderedProminent)
.padding()
ScrollView { /* content */ }
}
Design for iPhone SE (375pt wide) through iPhone Pro Max (430pt wide). Use flexible layouts, avoid hardcoded widths.
Correct:
HStack(spacing: 12) {
ForEach(items) { item in
CardView(item: item)
.frame(maxWidth: .infinity) // Adapts to screen width
}
}
Incorrect:
HStack(spacing: 12) {
ForEach(items) { item in
CardView(item: item)
.frame(width: 180) // Breaks on SE, wastes space on Pro Max
}
}
Align spacing, padding, and element sizes to multiples of 8 points (8, 16, 24, 32, 40, 48). Use 4pt for fine adjustments.
Support landscape orientation unless the app is task-specific (e.g., camera). Use ViewThatFits or GeometryReader for adaptive layouts.
Impact: CRITICAL
Use a tab bar at the bottom of the screen for 3 to 5 top-level sections. Each tab should represent a distinct category of content or functionality.
Correct:
TabView {
HomeView()
.tabItem {
Label("Home", systemImage: "house")
}
SearchView()
.tabItem {
Label("Search", systemImage: "magnifyingglass")
}
ProfileView()
.tabItem {
Label("Profile", systemImage: "person")
}
}
Incorrect:
// Hamburger menu hidden behind three lines — discoverability is near zero
NavigationView {
Button(action: { showMenu.toggle() }) {
Image(systemName: "line.horizontal.3")
}
}
Hamburger (drawer) menus hide navigation, reduce discoverability, and violate iOS conventions. Use a tab bar instead. If you have more than 5 sections, consolidate or use a "More" tab.
Use .navigationBarTitleDisplayMode(.large) for top-level views. Titles transition to inline (.inline) when the user scrolls.
Correct:
NavigationStack {
List(items) { item in
ItemRow(item: item)
}
.navigationTitle("Messages")
.navigationBarTitleDisplayMode(.large)
}
The swipe-from-left-edge gesture for back navigation is a system-level expectation. Never attach custom gesture recognizers that interfere with it.
Incorrect:
.gesture(
DragGesture()
.onChanged { /* custom drawer */ } // Conflicts with system back swipe
)
Use NavigationStack (not the deprecated NavigationView) for drill-down content. Use NavigationPath for programmatic navigation.
Correct:
NavigationStack(path: $path) {
List(items) { item in
NavigationLink(value: item) {
ItemRow(item: item)
}
}
.navigationDestination(for: Item.self) { item in
ItemDetail(item: item)
}
}
When users navigate back and then forward, or switch tabs, restore the previous scroll position and input state. Use @SceneStorage or @State to persist view state.
Keep current location, recent choices, and available destinations visible. Restore tab, scroll, filter, and selection state so users continue from recognition instead of reconstructing context from memory.
Impact: HIGH
Always use semantic text styles rather than hardcoded sizes. These scale automatically with Dynamic Type.
Correct:
VStack(alignment: .leading, spacing: 4) {
Text("Section Title")
.font(.headline)
Text("Body content that explains the section.")
.font(.body)
Text("Last updated 2 hours ago")
.font(.caption)
.foregroundStyle(.secondary)
}
Incorrect:
VStack(alignment: .leading, spacing: 4) {
Text("Section Title")
.font(.system(size: 17, weight: .semibold)) // Won't scale with Dynamic Type
Text("Body content")
.font(.system(size: 15)) // Won't scale with Dynamic Type
}
Dynamic Type can scale text up to approximately 200% at the largest accessibility sizes. Layouts must reflow — never truncate or clip essential text.
Correct:
HStack {
Image(systemName: "star")
Text("Favorites")
.font(.body)
}
// At accessibility sizes, consider using ViewThatFits or
// AnyLayout to switch from HStack to VStack
Use @Environment(\.dynamicTypeSize) to detect size category and adapt layouts:
@Environment(\.dynamicTypeSize) var dynamicTypeSize
var body: some View {
if dynamicTypeSize.isAccessibilitySize {
VStack { content }
} else {
HStack { content }
}
}
If you use a custom typeface, scale it so it responds to Dynamic Type. The API differs by framework.
Correct (SwiftUI):
extension Font {
static func scaledCustom(size: CGFloat, relativeTo textStyle: Font.TextStyle) -> Font {
.custom("CustomFont-Regular", size: size, relativeTo: textStyle)
}
}
// Usage
Text("Hello")
.font(.scaledCustom(size: 17, relativeTo: .body))
Correct (UIKit):
let metrics = UIFontMetrics(forTextStyle: .body)
let customFont = UIFont(name: "CustomFont-Regular", size: 17)!
label.font = metrics.scaledFont(for: customFont)
label.adjustsFontForContentSizeCategory = true
Use the system font (SF Pro) unless brand requirements dictate otherwise. SF Pro is optimized for legibility on Apple displays.
Never display text smaller than 11pt. Prefer 17pt for body text. Use the caption2 style (11pt) as the absolute minimum.
Establish visual hierarchy through font weight and size. Do not rely solely on color to differentiate text levels.
Impact: HIGH
Use system-provided semantic colors that automatically adapt to light and dark modes.
Correct:
Text("Primary text")
.foregroundStyle(.primary) // Adapts to light/dark
Text("Secondary info")
.foregroundStyle(.secondary)
VStack { }
.background(Color(.systemBackground)) // White in light, black in dark
Incorrect:
Text("Primary text")
.foregroundColor(.black) // Invisible on dark backgrounds
VStack { }
.background(.white) // Blinding in Dark Mode
Define custom colors in the asset catalog with both Any Appearance and Dark Appearance variants.
// In Assets.xcassets, define "BrandBlue" with:
// Any Appearance: #0066CC
// Dark Appearance: #4DA3FF
Text("Brand text")
.foregroundStyle(Color("BrandBlue")) // Automatically switches
Always pair color with text, icons, or shapes to convey meaning. Approximately 8% of men have some form of color vision deficiency.
Correct:
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.red)
Text("Error: Invalid email address")
.foregroundStyle(.red)
}
Incorrect:
// Only color indicates the error — invisible to colorblind users
TextField("Email", text: $email)
.border(isValid ? .green : .red)
All text must meet WCAG AA contrast ratios: 4.5:1 for normal text, 3:1 for large text (18pt+ or 14pt+ bold).
Use Display P3 color space for vibrant, accurate colors on modern iPhones. Define colors in the asset catalog with the Display P3 gamut.
Use the three-level background hierarchy for depth:
systemBackground — primary surfacesecondarySystemBackground — grouped content, cardstertiarySystemBackground — elements within grouped contentChoose a single tint/accent color for all interactive elements (buttons, links, toggles). This creates a consistent, learnable visual language.
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.tint(.indigo) // All interactive elements use indigo
}
}
}
Impact: CRITICAL
Every button, control, and interactive element must have a meaningful accessibility label.
Correct:
Button(action: addToCart) {
Image(systemName: "cart.badge.plus")
}
.accessibilityLabel("Add to cart")
Incorrect:
Button(action: addToCart) {
Image(systemName: "cart.badge.plus")
}
// VoiceOver reads "cart.badge.plus" — meaningless to users
Ensure VoiceOver reads elements in a logical order. Use .accessibilitySortPriority() to adjust when the visual layout doesn't match the reading order.
VStack {
Text("Price: $29.99")
.accessibilitySortPriority(1) // Read second (lower number = lower priority)
Text("Product Name")
.accessibilitySortPriority(2) // Read first (higher number = higher priority)
}
When the user enables Bold Text in Settings, custom-rendered text must adapt. SwiftUI text styles handle this automatically. For SwiftUI custom rendering, use @Environment(\.legibilityWeight) to apply heavier weights. UIKit code must check UIAccessibility.isBoldTextEnabled and re-query on UIAccessibility.boldTextStatusDidChangeNotification.
Correct:
// SwiftUI — standard text styles adapt automatically
Text("Section Header")
.font(.headline)
// SwiftUI — custom rendering respects legibilityWeight
@Environment(\.legibilityWeight) var legibilityWeight
var body: some View {
Text("Custom Label")
.fontWeight(legibilityWeight == .bold ? .bold : .regular)
}
Incorrect:
// Hardcoded weight ignores Bold Text preference
label.font = UIFont.systemFont(ofSize: 17, weight: .regular)
// Missing: re-query font when UIAccessibility.boldTextStatusDidChangeNotification fires
Disable decorative animations and parallax when Reduce Motion is enabled. Use @Environment(\.accessibilityReduceMotion).
Correct:
@Environment(\.accessibilityReduceMotion) var reduceMotion
var body: some View {
CardView()
.animation(reduceMotion ? nil : .spring(), value: isExpanded)
}
When the user enables Increase Contrast, ensure custom colors have higher-contrast variants. Use @Environment(\.colorSchemeContrast) to detect.
Information must be available through multiple channels. Pair visual indicators with text or accessibility descriptions.
Every custom gesture must have an equivalent tap-based or menu-based alternative for users who cannot perform complex gestures.
Ensure all interactions work with Switch Control (external switches) and Full Keyboard Access (Bluetooth keyboards). Test navigation order and focus behavior.
Impact: HIGH
Use the standard iOS gesture vocabulary: tap, long press, swipe, pinch, rotate. Users already understand these.
| Gesture | Standard Use |
|---|---|
| Tap | Primary action, selection |
| Long press | Context menu, preview |
| Swipe horizontal | Delete, archive, navigate back |
| Swipe vertical | Scroll, dismiss sheet |
| Pinch | Zoom in/out |
| Two-finger rotate | Rotate content |
These gestures are reserved by the system and must not be intercepted:
If you add a custom gesture, provide visual hints (e.g., a grabber handle) and ensure the action is also available through a visible button or menu item.
Design for touch first, but also support:
Impact: HIGH
Use the built-in button styles appropriately:
.borderedProminent — primary call-to-action.bordered — secondary actions.borderless — tertiary or inline actions.destructive role — red tint for delete/removeCorrect:
VStack(spacing: 16) {
Button("Purchase") { buy() }
.buttonStyle(.borderedProminent)
Button("Add to Wishlist") { wishlist() }
.buttonStyle(.bordered)
Button("Delete", role: .destructive) { delete() }
}
Use alerts sparingly for critical information that requires a decision. Prefer 2 buttons; maximum 3. The destructive option should use .destructive role.
Correct:
.alert("Delete Photo?", isPresented: $showAlert) {
Button("Delete", role: .destructive) { deletePhoto() }
Button("Cancel", role: .cancel) { }
} message: {
Text("This photo will be permanently removed.")
}
Incorrect:
// Alert for non-critical info — should be a banner or toast
.alert("Tip", isPresented: $showTip) {
Button("OK") { }
} message: {
Text("Swipe left to delete items.")
}
Present sheets for self-contained tasks. Always provide a way to dismiss (close button or swipe down). Use .presentationDetents() for half-height sheets.
.sheet(isPresented: $showCompose) {
NavigationStack {
ComposeView()
.navigationTitle("New Message")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { showCompose = false }
}
ToolbarItem(placement: .confirmationAction) {
Button("Send") { send() }
}
}
}
.presentationDetents([.medium, .large])
}
Use the .insetGrouped list style as the default. Support swipe actions for common operations. Minimum row height is 44pt.
Correct:
List {
Section("Recent") {
ForEach(recentItems) { item in
ItemRow(item: item)
.swipeActions(edge: .trailing) {
Button(role: .destructive) { delete(item) } label: {
Label("Delete", systemImage: "trash")
}
Button { archive(item) } label: {
Label("Archive", systemImage: "archivebox")
}
.tint(.blue)
}
}
}
}
.listStyle(.insetGrouped)
.badge()TabView {
MessagesView()
.tabItem {
Label("Messages", systemImage: "message")
}
.badge(unreadCount)
}
Place search using .searchable(). Provide search suggestions and support recent searches.
NavigationStack {
List(filteredItems) { item in
ItemRow(item: item)
}
.searchable(text: $searchText, prompt: "Search items")
.searchSuggestions {
ForEach(suggestions) { suggestion in
Text(suggestion.title)
.searchCompletion(suggestion.title)
}
}
}
Use context menus (long press) for secondary actions. Never use a context menu as the only way to access an action.
PhotoView(photo: photo)
.contextMenu {
Button { share(photo) } label: {
Label("Share", systemImage: "square.and.arrow.up")
}
Button { favorite(photo) } label: {
Label("Favorite", systemImage: "heart")
}
Button(role: .destructive) { delete(photo) } label: {
Label("Delete", systemImage: "trash")
}
}
ProgressView(value:total:)) for operations with known durationProgressView()) for unknown durationUse the appropriate rendering mode for each symbol. Monochrome is the default; hierarchical, palette, and multicolor provide richer expression where appropriate. Always prefer the symbol rendering mode that best communicates meaning — do not default to monochrome when multicolor conveys critical state.
Correct:
// Hierarchical: single color with automatic opacity layers
Image(systemName: "person.crop.circle.fill")
.symbolRenderingMode(.hierarchical)
.foregroundStyle(.blue)
// Multicolor: system-defined color per layer (e.g., battery, weather)
Image(systemName: "battery.100percent.bolt")
.symbolRenderingMode(.multicolor)
// Palette: explicit per-layer colors
Image(systemName: "folder.badge.plus")
.symbolRenderingMode(.palette)
.foregroundStyle(.white, .blue)
Incorrect:
// Monochrome on a symbol that has meaningful multicolor layers
Image(systemName: "battery.100percent.bolt")
.foregroundColor(.gray) // loses the contextual color meaning
Match the symbol weight to adjacent text weight. Use scale variants (.small, .medium, .large) rather than resizing. The symbol weight should never appear heavier than adjacent text.
Correct:
Label("Download", systemImage: "arrow.down.circle.fill")
.font(.body.weight(.semibold))
// Symbol inherits .semibold weight automatically via Label
Incorrect:
HStack {
Image(systemName: "arrow.down.circle.fill")
.font(.system(size: 32)) // explicit size ignores type scale
Text("Download")
.font(.body)
}
Use symbolEffect for symbol state transitions. Prefer discrete effects (.bounce, .pulse) for actions and indefinite effects (.variableColor) for ongoing state. Do not use manual cross-fade between symbol names when contentTransition(.symbolEffect) is available.
Correct:
Image(systemName: isLoading ? "arrow.2.circlepath" : "checkmark.circle")
.contentTransition(.symbolEffect(.replace))
.symbolEffect(.pulse, isActive: isLoading)
Incorrect:
// Manual opacity cross-fade between symbol names
if isLoading {
Image(systemName: "arrow.2.circlepath")
} else {
Image(systemName: "checkmark.circle")
}
Impact: MEDIUM
Keep onboarding to 3 or fewer pages. Always provide a skip option. Defer sign-in until the user needs authenticated features.
TabView {
OnboardingPage(
image: "wand.and.stars",
title: "Smart Suggestions",
subtitle: "Get personalized recommendations based on your preferences."
)
OnboardingPage(
image: "bell.badge",
title: "Stay Updated",
subtitle: "Receive notifications for things that matter to you."
)
OnboardingPage(
image: "checkmark.shield",
title: "Private & Secure",
subtitle: "Your data stays on your device."
)
}
.tabViewStyle(.page)
.overlay(alignment: .topTrailing) {
Button("Skip") { completeOnboarding() }
.padding()
}
Use skeleton/placeholder views that match the layout of the content being loaded. Never show a full-screen blocking spinner.
Correct:
if isLoading {
ForEach(0..<5) { _ in
SkeletonRow() // Placeholder matching final row layout
.redacted(reason: .placeholder)
}
} else {
ForEach(items) { item in
ItemRow(item: item)
}
}
Incorrect:
if isLoading {
ProgressView("Loading...") // Blocks the entire view
} else {
List(items) { item in ItemRow(item: item) }
}
The launch storyboard must visually match the initial screen of the app. No splash logos, no branding screens. This creates the perception of instant launch.
Present modal views only when the user must complete or abandon a focused task. Always provide a clear dismiss action. Never stack modals on top of modals.
Only send notifications for content the user genuinely cares about. Support actionable notifications. Categorize notifications so users can control them granularly.
Provide immediate feedback for every user action:
UIImpactFeedbackGenerator, UINotificationFeedbackGenerator, or UISelectionFeedbackGeneratorButton("Complete") {
let generator = UINotificationFeedbackGenerator()
generator.notificationOccurred(.success)
completeTask()
}
If an action cannot complete immediately, acknowledge the tap at once, then show inline progress, skeletons, or partial results. Never leave the interface visually unchanged while work continues.
Impact: HIGH
Request a permission at the moment the user takes an action that needs it — never at app launch.
Correct:
Button("Take Photo") {
// Request camera permission only when the user taps this button
AVCaptureDevice.requestAccess(for: .video) { granted in
if granted { showCamera = true }
}
}
Incorrect:
// In AppDelegate.didFinishLaunching — too early, no context
func application(_ application: UIApplication, didFinishLaunchingWithOptions ...) {
AVCaptureDevice.requestAccess(for: .video) { _ in }
CLLocationManager().requestWhenInUseAuthorization()
UNUserNotificationCenter.current().requestAuthorization(options: [.alert]) { _, _ in }
}
Show a custom explanation screen before triggering the system permission dialog. The system dialog only appears once — if the user denies, the app must direct them to Settings.
struct LocationExplanation: View {
var body: some View {
VStack(spacing: 16) {
Image(systemName: "location.fill")
.font(.largeTitle)
Text("Find Nearby Stores")
.font(.headline)
Text("We use your location to show stores within walking distance. Your location is never shared or stored.")
.font(.body)
.multilineTextAlignment(.center)
Button("Enable Location") {
locationManager.requestWhenInUseAuthorization()
}
.buttonStyle(.borderedProminent)
Button("Not Now") { dismiss() }
.foregroundStyle(.secondary)
}
.padding()
}
}
If the app offers any third-party sign-in (Google, Facebook), it must also offer Sign in with Apple. Present it as the first option.
Let users explore the app before requiring sign-in. Gate only features that genuinely need authentication (purchases, sync, social features).
If you track users across apps or websites, display the ATT prompt. Respect denial — do not degrade the experience for users who opt out.
Use LocationButton for actions that need location once without requesting ongoing permission.
import CoreLocationUI
LocationButton(.currentLocation) {
fetchNearbyStores()
}
.labelStyle(.titleAndIcon)
Impact: MEDIUM
Provide widgets using WidgetKit for information users check frequently. Show the most useful snapshot. Since iOS 17, widgets support interactive controls: use Button and Toggle backed by App Intents for actions users perform directly from the widget without opening the app.
// iOS 17+ interactive widget with a Button
struct TimerWidgetView: View {
let entry: TimerEntry
var body: some View {
VStack {
Text(entry.remaining, style: .timer)
.font(.title2.bold())
Button(intent: ToggleTimerIntent()) {
Label(entry.isRunning ? "Pause" : "Start",
systemImage: entry.isRunning ? "pause.fill" : "play.fill")
}
.buttonStyle(.borderedProminent)
}
}
}
Define App Shortcuts so users can trigger key actions from Siri, Spotlight, and the Shortcuts app.
struct MyAppShortcuts: AppShortcutsProvider {
static var appShortcuts: [AppShortcut] {
AppShortcut(
intent: StartWorkoutIntent(),
phrases: ["Start a workout in \(.applicationName)"],
shortTitle: "Start Workout",
systemImageName: "figure.run"
)
}
}
Index app content with CSSearchableItem so users can find it from Spotlight search.
Support the system share sheet for content that users might want to send elsewhere. Implement UIActivityItemSource or use ShareLink in SwiftUI.
ShareLink(item: article.url) {
Label("Share", systemImage: "square.and.arrow.up")
}
Use Live Activities and the Dynamic Island for real-time, time-bound events (delivery tracking, sports scores, workouts).
Save state and pause gracefully when interrupted by:
Use scenePhase to detect transitions:
@Environment(\.scenePhase) var scenePhase
.onChange(of: scenePhase) { _, newPhase in
switch newPhase {
case .active: resumeActivity()
case .inactive: pauseActivity()
case .background: saveState()
@unknown default: break
}
}
| Need | Component | Notes |
|---|---|---|
| Top-level sections (3-5) | TabView with .tabItem | Bottom tab bar, SF Symbols |
| Hierarchical drill-down | NavigationStack | Large title on root, inline on children |
| Self-contained task | .sheet | Swipe to dismiss, cancel/done buttons |
| Critical decision | .alert | 2 buttons preferred, max 3 |
| Secondary actions | .contextMenu | Long press; must also be accessible elsewhere |
| Scrolling content | List with .insetGrouped | 44pt min row, swipe actions |
| Text input | TextField / TextEditor | Label above, validation below |
| Selection (few options) | Picker | Segmented for 2-5, wheel for many |
| Selection (on/off) | Toggle | Aligned right in a list row |
| Search | .searchable | Suggestions, recent searches |
| Progress (known) | ProgressView(value:total:) | Show percentage or time remaining |
| Progress (unknown) | ProgressView() | Inline, never full-screen blocking |
| One-time location | LocationButton | No persistent permission needed |
| Sharing content | ShareLink | System share sheet |
| Haptic feedback | UIImpactFeedbackGenerator | .light, .medium, .heavy |
| Destructive action | Button(role: .destructive) | Red tint, confirm via alert |
Use this checklist to audit an iPhone app for HIG compliance:
Font.custom(_:size:relativeTo:) in SwiftUI or UIFontMetrics in UIKit).destructive roleThese are common mistakes that violate the iOS Human Interface Guidelines. Never do these:
Hamburger menus — Use a tab bar. Hamburger menus hide navigation and reduce feature discoverability by up to 50%.
Custom back buttons that break swipe-back — If you replace the back button, ensure the swipe-from-left-edge gesture still works via NavigationStack.
Full-screen blocking spinners — Use skeleton views or inline progress indicators. Blocking spinners make the app feel frozen.
Splash screens with logos — The launch screen must mirror the first screen of the app. Branding delays feel artificial.
Requesting all permissions at launch — Asking for camera, location, notifications, and contacts on first launch guarantees most will be denied.
Hardcoded font sizes — Use text styles. Hardcoded sizes ignore Dynamic Type and accessibility preferences, breaking the app for millions of users.
Using only color to indicate state — Red/green for valid/invalid excludes colorblind users. Always pair with icons or text.
Alerts for non-critical information — Alerts interrupt flow and require dismissal. Use banners, toasts, or inline messages for tips and non-critical information.
Hiding the tab bar on push — Tab bars should remain visible throughout navigation within a tab. Hiding them disorients users.
Ignoring safe areas — Using .ignoresSafeArea() on content views causes text and buttons to disappear under the notch, Dynamic Island, or home indicator.
Non-dismissable modals — Every modal must have a clear dismiss path (close button, cancel, swipe down). Trapping users in a modal is hostile.
Custom gestures without alternatives — A three-finger swipe for undo is unusable for many people. Provide a visible button or menu item as well.
Tiny touch targets — Buttons and links smaller than 44pt cause mis-taps, especially in lists and toolbars.
Stacked modals — Presenting a sheet on top of a sheet on top of a sheet creates navigation confusion. Use navigation within a single modal instead.
Dark Mode as an afterthought — Using hardcoded colors means the app is either broken in Dark Mode or light mode. Always use semantic colors.