
Expo Ui
- 73 installs
- 191 repo stars
- Updated July 24, 2026
- pproenca/dot-skills
expo-ui is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
Key points
- expo-ui
- AI & Agent Building
- AI-coding skill
Expo Ui by the numbers
- 73 all-time installs (skills.sh)
- +7 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #5,587 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/pproenca/dot-skills --skill expo-uiAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 73 |
|---|---|
| repo stars | ★ 191 |
| Last updated | July 24, 2026 |
| Repository | pproenca/dot-skills ↗ |
How do I helps with ai & agent building tasks during ai-assisted development?
Helps with ai & agent building tasks during AI-assisted development.
Who is it for?
Best when you're working on ai & agent building and need structured help with expo-ui.
Skip if: Teams with no ai & agent building needs, or anyone wanting a generic chat assistant without this specific workflow.
When should I use this skill?
When you need to helps with ai & agent building tasks during ai-assisted development, or when expo-ui is a claude code skill for ai & agent building. it helps solo builders move faster with ai-assisted coding.
What you get
Structured output aligned to expo-ui: expo-ui; AI & Agent Building; AI-coding skill.
Files
Expo @expo/ui SwiftUI Best Practices
Library reference for @expo/ui/swift-ui and @expo/ui/swift-ui/modifiers — the iOS surface of Expo's native UI bridge. Contains 53 rules across 8 categories, prioritised by cascade impact for agents building Expo apps that render to native SwiftUI views on iOS 26 and earlier.
When to Apply
Reference these guidelines when:
- Building a new screen with
@expo/ui/swift-ui— pick the right container (Form vs List vs ScrollView), wrap in Host correctly, apply modifiers - Migrating from React Native primitives (View, Text, TouchableOpacity) to native SwiftUI components
- Targeting iOS 26 features — Liquid Glass material, GlassEffectContainer, new sheet detent behaviours
- Reviewing code that imports from
@expo/ui/swift-uior@expo/ui/swift-ui/modifiers - Debugging "the SwiftUI view doesn't render / is the wrong size / ignores styles" — usually a Host or modifier issue
- Composing presentation surfaces — Alert, ConfirmationDialog, BottomSheet, Popover — under HIG modality guidance
- Writing controlled inputs (TextField, Toggle, Picker, Slider) with
useNativeStateand worklet writes
When NOT to Use This Skill
- Android Jetpack Compose — this skill covers iOS SwiftUI only. The
@expo/ui/jetpack-composesurface has its own conventions - Universal (cross-platform) components —
@expo/uiexposes a small set; this skill scopes to the platform-specific iOS surface - Navigation routing — for stack/tab routing, use
expo-routerandexpo-router/unstable-native-tabs; this skill covers UI composition only - Pre-iOS-17 fallbacks — most rules assume iOS 17 minimum; Liquid Glass rules require iOS 26
Rule Categories by Priority
| Priority | Category | Impact | Prefix |
|---|---|---|---|
| 1 | Setup & Host Boundaries | CRITICAL | host- |
| 2 | iOS 26 HIG Composition Rules | CRITICAL | hig- |
| 3 | Modifiers System | CRITICAL | mod- |
| 4 | Layout Components | HIGH | layout- |
| 5 | Input & Controls | HIGH | input- |
| 6 | Navigation & Overlays | HIGH | nav- |
| 7 | Display & Feedback | MEDIUM-HIGH | display- |
| 8 | State & Cross-Cutting Patterns | MEDIUM | state- |
Quick Reference
1. Setup & Host Boundaries (CRITICAL)
- `host-wrap-all-swiftui-roots` — Wrap every SwiftUI subtree in a Host
- `host-match-contents` — Size Host to its SwiftUI content with matchContents
- `host-viewport-size-for-form` — Use useViewportSizeMeasurement for Form and List
- `host-color-scheme-explicit` — Pass explicit colorScheme when overriding the system
- `host-ignore-safe-area` — Use ignoreSafeArea only for full-bleed surfaces
2. iOS 26 HIG Composition Rules (CRITICAL)
- `hig-glass-effect-container` — Group glass siblings inside GlassEffectContainer
- `hig-no-glass-on-glass` — Avoid nesting glassEffect on glass surfaces
- `hig-no-stacked-modals` — Resolve a sheet before presenting another
- `hig-popover-iphone-fallback` — Don't use Popover on iPhone — use BottomSheet
- `hig-sheet-detents-partial` — Include a partial detent for Liquid Glass appearance
- `hig-confirmation-dialog-destructive` — ConfirmationDialog + destructive role
- `hig-tint-only-for-brand` — Reserve tint for brand surfaces, not destructive
3. Modifiers System (CRITICAL)
- `mod-prop-not-style` — Modifiers go through the
modifiersprop, not RN style - `mod-composition-order` — Modifier order is meaningful — each wraps the previous
- `mod-import-from-modifiers-subpath` — Import from
@expo/ui/swift-ui/modifiers - `mod-frame-vs-fixedsize` — frame proposes a size, fixedSize opts out of flex
- `mod-padding-vs-frame` — padding for inner space, frame for outer bounds
- `mod-presentation-on-sheet-content` — Presentation modifiers attach to sheet content
- `mod-disabled-prop` — Use disabled modifier, don't conditionally render
- `mod-animation-wraps-trigger` — withAnimation wraps state-driven prop changes
4. Layout Components (HIGH)
- `layout-hstack-vs-vstack` — Pick stack direction by content flow
- `layout-lazy-stack-for-long-lists` — LazyVStack inside ScrollView for long lists
- `layout-form-for-settings` — Form adopts iOS grouped chrome automatically
- `layout-section-with-header-footer` — Use Section header/footer slots
- `layout-scrollview-axes` — Set axes explicitly for horizontal/2D scroll
- `layout-grid-vs-stack` — Grid for column-aligned content
5. Input & Controls (HIGH)
- `input-button-role-for-destructive` — Set role='destructive' for delete buttons
- `input-button-systemimage` — Use systemImage SF Symbol for button icons
- `input-textfield-observable-state` — useNativeState for TextField, not React state
- `input-securefield-for-passwords` — SecureField for passwords, not TextField
- `input-toggle-on-async` — SyncToggle for instant flicks, Toggle for async
- `input-picker-style-via-modifier` — pickerStyle modifier picks appearance
- `input-date-picker-range` — Constrain selectable dates with range
- `input-stepper-bounded` — Provide min and max on Stepper
6. Navigation & Overlays (HIGH)
- `nav-alert-for-critical-only` — Alert for blocking notifications only
- `nav-context-menu-vs-swipe` — ContextMenu or SwipeActions per row, not both
- `nav-bottom-sheet-via-group` — Wrap BottomSheet content in Group
- `nav-share-link-system` — ShareLink for the system share sheet
- `nav-tabview-style-modifier` — tabViewStyle modifier picks appearance
- `nav-disclosure-group-collapsible` — DisclosureGroup for collapsible sections
- `nav-link-not-button-for-urls` — Link for URLs, Button for in-app actions
- `nav-menu-primary-action` — onPrimaryAction disambiguates tap from long-press
7. Display & Feedback (MEDIUM-HIGH)
- `display-text-markdown` — Enable markdownEnabled for inline rich text
- `display-image-system-name` — Prefer systemName SF Symbols over uiImage
- `display-chart-data-points` — ChartDataPoint arrays drive native axes
- `display-gauge-current-value-label` — Provide currentValueLabel for accessibility
- `display-progress-indeterminate` — Undefined value → spinner, 0 → frozen bar
- `display-label-icon-vs-title` — systemImage for SF Symbols, icon slot for custom
8. State & Cross-Cutting Patterns (MEDIUM)
- `state-use-native-state-for-fields` — useNativeState for every bridged input
- `state-worklet-writes` — Update ObservableState from worklets
- `state-controlled-via-selection-prop` — selection or defaultSelection, not both
- `state-platform-check-pre-26` — Guard iOS 26-only features with version check
- `state-textfield-ref-imperative` — TextFieldRef for focus and selection
How to Use
Read individual reference files for detailed explanations and code examples:
- Section definitions — Category structure and impact levels
- Rule template — Template for adding new rules
- Reference files:
references/{prefix}-{slug}.md
Each rule file contains:
- Brief explanation of why it matters in the SwiftUI bridge
- Incorrect code example anchored to a realistic domain
- Correct code example with a minimal diff from the incorrect one
- Where relevant: Alternative approach, When NOT to use, Warning callouts, authoritative reference URL
Gotchas
See gotchas.md — append entries as failure points surface during real use.
Related Skills
- For Android Jetpack Compose components, the parallel skill would target
@expo/ui/jetpack-compose - For navigation routing (stack, tabs), use
expo-routerdirectly - For form validation libraries, see the
react-hook-formskill
@expo/ui (SwiftUI for iOS)
Version 0.1.0 Expo May 2026
Note:
This document targets @expo/ui (SwiftUI for iOS) codebases. It is mainly for agents
and LLMs to follow when maintaining, generating, or refactoring code that imports from
@expo/ui/swift-uiand@expo/ui/swift-ui/modifiers. Humans may also find it useful,
but guidance here is optimized for automation and consistency by AI-assisted workflows.
---
Abstract
Library reference for @expo/ui SwiftUI components — covers Host boundaries, modifier composition, iOS 26 Liquid Glass and Human Interface Guidelines composition rules, layout/input/navigation/display catalogues, and ObservableState patterns. Rules are derived from the expo-ui source (v56.0.8) and Apple's iOS 26 HIG, prioritised by cascade impact for agents building Expo apps that bridge to native SwiftUI views.
---
Table of Contents
1. Setup & Host Boundaries — CRITICAL
- 1.1 Pass an Explicit colorScheme to Host When Overriding System Appearance — CRITICAL (prevents SwiftUI tree from reading stale Appearance and forcing the wrong theme)
- 1.2 Use ignoreSafeArea Only for Full-Bleed Surfaces — CRITICAL (prevents controls from sliding under the home indicator or notch)
- 1.3 Use matchContents to Size Host to Its SwiftUI Content — CRITICAL (prevents zero-height hosts and layout glitches when SwiftUI content is intrinsically sized)
- 1.4 Use useViewportSizeMeasurement for Form and Fill-Available Content — CRITICAL (enables Form and List to expand to the available viewport instead of collapsing)
- 1.5 Wrap All SwiftUI Trees in a Host Component — CRITICAL (prevents native view registration failures and missing layout for every SwiftUI descendant)
2. iOS 26 HIG Composition Rules — CRITICAL
- 2.1 Avoid Nesting glassEffect Inside Already-Glass Surfaces — CRITICAL (prevents broken material rendering — stacked glass produces opaque or visually muddy artefacts)
- 2.2 Avoid Presenting a Sheet from Inside Another Sheet — CRITICAL (prevents users from facing multiple dismissal layers and disorientation)
- 2.3 Include a Partial Detent to Enable the Liquid Glass Sheet Appearance — CRITICAL (enables the floating Liquid Glass sheet —
.large-only sheets fall back to edge-anchored opaque chrome on iOS 26) - 2.4 Reserve tint Modifier for Brand Surfaces — Keep Semantic System Colors — CRITICAL (preserves the system's destructive-red and accessibility colour contracts)
- 2.5 Use ConfirmationDialog or BottomSheet on iPhone Instead of Popover — CRITICAL (prevents Popover from rendering as an unanchored sheet on compact widths — breaking HIG popover guidance)
- 2.6 Use ConfirmationDialog with role='destructive' for Destructive Confirmations — CRITICAL (enables system-red destructive styling and proper VoiceOver semantics — prevents accidental data loss)
- 2.7 Wrap Multiple Glass Siblings in a GlassEffectContainer — CRITICAL (enables Liquid Glass siblings to morph and blend as one shape, prevents per-element pipeline cost)
3. Modifiers System — CRITICAL
- 3.1 Apply Presentation Modifiers to Sheet Content, Not the Trigger — CRITICAL (prevents the modifier from attaching to the wrong view — modifiers on the trigger do nothing)
- 3.2 Apply Visual Modifications via the modifiers Prop, Not React Native style — CRITICAL (prevents silent no-ops — RN style is ignored by every native SwiftUI view in @expo/ui)
- 3.3 Import Modifiers from the @expo/ui/swift-ui/modifiers Subpath — HIGH (prevents bundling the modifier graph through the main entry — direct subpath import keeps tree-shaking accurate)
- 3.4 Order Modifiers from Inside-Out — Each Wraps the Previous — CRITICAL (prevents visually wrong layering — padding-then-background paints inside the padding, background-then-padding paints the padding)
- 3.5 Use frame for Explicit Sizing — fixedSize to Opt Out of Flex — HIGH (prevents the wrong sizing strategy — frame proposes a size, fixedSize tells SwiftUI to use the view's intrinsic size)
- 3.6 Use padding for Inner Space — frame for Outer Constraints — HIGH (prevents double-counting space — padding adds inside the view's bounds, frame fixes total bounds)
- 3.7 Use the disabled Modifier — Don't Conditionally Render — HIGH (preserves accessibility focus order and prevents layout shift when toggling availability)
- 3.8 Wrap State-Driven Prop Changes in withAnimation for Smooth Transitions — HIGH (enables SwiftUI's implicit transitions for value changes — without it, transitions snap instantly)
4. Layout Components — HIGH
- 4.1 Pick Stack Direction by Content Flow — HStack for Rows, VStack for Columns — HIGH (prevents content from wrapping or overflowing the wrong axis)
- 4.2 Set axes Explicitly on ScrollView for Horizontal or 2D Scrolling — MEDIUM-HIGH (prevents accidental vertical-only scroll when horizontal or both axes are needed)
- 4.3 Use Form for Settings-Style Screens — Not VStack Inside ScrollView — HIGH (enables inset-grouped chrome, automatic separators, and HIG-correct settings styling)
- 4.4 Use Grid for Column-Aligned Content — Stacks Don't Align Across Rows — MEDIUM-HIGH (enables cells in successive rows to line up — VStack of HStacks lets each row size its columns independently)
- 4.5 Use LazyVStack or LazyHStack for Long Lists Inside ScrollView — HIGH (defers off-screen row layout — eager VStack frames every child at mount, 10-100× initial-layout cost on 500-row lists)
- 4.6 Use Section.header and Section.footer Slots for Grouped Context — MEDIUM-HIGH (enables rich contextual headers and explanatory footers without breaking out of the Form/List chrome)
5. Input & Controls — HIGH
- 5.1 Constrain Selectable Dates with the range Prop — HIGH (prevents invalid date submission — the picker rejects out-of-range taps natively)
- 5.2 Provide min and max on Stepper to Bound the Increment Range — MEDIUM-HIGH (prevents out-of-range values — the + and - buttons disable at the boundaries)
- 5.3 Set Picker Appearance via pickerStyle Modifier, Not a Prop — HIGH (enables the four Picker appearances (wheel, segmented, menu, inline) — Picker takes no style prop)
- 5.4 Use Button role='destructive' for Delete-Style Actions — HIGH (enables system-red styling, VoiceOver announcement, and HIG-correct semantic — prevents accidental confirms)
- 5.5 Use SecureField for Password Inputs — Not TextField — HIGH (enables password autofill, biometric autofill, and prevents screenshot capture of the text)
- 5.6 Use systemImage for Button Icons — SF Symbols Scale and Adapt Automatically — HIGH (enables Dynamic Type scaling, dark mode adaptation, and symbol effect support — prevents pixelation on Retina displays)
- 5.7 Use Toggle for Async State, SyncToggle for Instant Native Updates — HIGH (prevents toggle-flicker — Toggle round-trips state through React, SyncToggle commits to the native state directly)
- 5.8 Use useNativeState for TextField text — Not React useState — HIGH (enables zero-latency native text updates and worklet-thread writes — React state round-trips through the JS bridge)
6. Navigation & Overlays — HIGH
- 6.1 Choose ContextMenu OR SwipeActions per Row — Not Both — HIGH (prevents discoverability ambiguity — long-press and edge-swipe gestures compete for the same affordance space)
- 6.2 Set onPrimaryAction on Menu to Disambiguate Tap from Long-Press — MEDIUM-HIGH (enables instant tap for the primary action — without it, every tap opens the menu chooser)
- 6.3 Set TabView Appearance via tabViewStyle Modifier — HIGH (enables the three TabView appearances (automatic bottom-bar, swipeable pager, sidebar-adaptable) — no style prop exists)
- 6.4 Use Alert Only for App-Blocking Critical Information — HIGH (prevents alert fatigue — reserves the highest-modality affordance for critical events)
- 6.5 Use DisclosureGroup for Collapsible Detail Inside Forms — MEDIUM-HIGH (enables HIG-correct expand/collapse chevron — Section's isExpanded only works inside sidebar lists)
- 6.6 Use Link for URL Navigation — Button for In-App Actions — MEDIUM-HIGH (enables system URL handling including SafariViewController fallbacks and universal-link routing)
- 6.7 Use ShareLink for System Share — Not a Custom Sheet — HIGH (enables the full iOS share sheet (AirDrop, system apps, extensions) — custom sheets only show what you wire up)
- 6.8 Wrap BottomSheet Content in Group to Attach Presentation Modifiers — HIGH (enables detents, drag indicator, and background interaction modifiers — bare content can't attach them)
7. Display & Feedback — MEDIUM-HIGH
- 7.1 Build Chart Data from ChartDataPoint Arrays, Not Raw Numbers — MEDIUM-HIGH (enables per-point colour, native axis labels, and chart-type switching without restructuring data)
- 7.2 Enable markdownEnabled for Inline Bold, Italic, Links — MEDIUM-HIGH (enables inline markdown formatting — avoids fragile multi-Text concatenation that breaks Dynamic Type wrapping)
- 7.3 Pass value=undefined to ProgressView for Indeterminate Spinner — MEDIUM-HIGH (enables the system indeterminate spinner — passing 0 shows a frozen-at-zero progress bar)
- 7.4 Prefer systemName SF Symbols Over Raster uiImage for Icons — MEDIUM-HIGH (enables variable color, dynamic-type scaling, and symbol effects — uiImage is a synchronous main-thread file read)
- 7.5 Provide currentValueLabel on Gauge for Accessibility and Context — MEDIUM-HIGH (enables VoiceOver to read the current value and preserves the numeric context for sighted users)
- 7.6 Use Label.systemImage for SF Symbols, Label.icon for Custom Glyphs — MEDIUM (enables correct icon layering — systemImage gets symbol effects, icon slot takes a full SwiftUI subview)
8. State & Cross-Cutting Patterns — MEDIUM
- 8.1 Choose selection (Controlled) or defaultSelection (Uncontrolled) — Not Both — MEDIUM (prevents prop conflicts — components ignore defaultSelection when selection is also provided)
- 8.2 Guard iOS 26-Only Features With a Platform Version Check — MEDIUM (prevents runtime crashes on iOS 17/18/19 — features like glassEffect, tabBarMinimizeBehavior are 26-only)
- 8.3 Update ObservableState from Worklets — Not From the JS Thread — MEDIUM (prevents the development-mode warning and ensures atomic same-frame updates on the native side)
- 8.4 Use TextFieldRef for Imperative Focus and Selection — MEDIUM (enables focus management, text replacement, and selection control — declarative props can't model these)
- 8.5 Use useNativeState for Every Bridged Input Value — MEDIUM (enables zero-bridge text updates and worklet-friendly writes — eliminates per-keystroke JS renders)
---
References
1. https://github.com/expo/expo/tree/main/packages/expo-ui 2. https://docs.expo.dev/versions/latest/sdk/ui/ 3. https://developer.apple.com/design/human-interface-guidelines 4. https://developer.apple.com/documentation/TechnologyOverviews/adopting-liquid-glass 5. https://developer.apple.com/documentation/swiftui/glasseffectcontainer 6. https://developer.apple.com/design/human-interface-guidelines/modality 7. https://developer.apple.com/design/human-interface-guidelines/materials 8. https://developer.apple.com/documentation/swiftui/view/presentationdetents(_:))
---
Source Files
This document was compiled from individual reference files. For detailed editing or extension:
| File | Description |
|---|---|
| references/_sections.md | Category definitions and impact ordering |
| assets/templates/_template.md | Template for creating new rules |
| SKILL.md | Quick reference entry point |
| metadata.json | Version and reference URLs |
{Title — matches frontmatter title verbatim}
{1-3 sentences explaining WHY this matters in @expo/ui's SwiftUI bridge — what goes wrong without this pattern, what the cascade effect is. Always anchor the reasoning to the native bridge or HIG. Don't dictate "use X" — explain what breaks when you don't.}
Incorrect ({specific problem}):
{Production-realistic code. Use realistic domain names — never foo, bar, MyComponent.}
{Imports from '@expo/ui/swift-ui' and '@expo/ui/swift-ui/modifiers'.}Correct ({specific benefit}):
{Minimal diff from Incorrect — same variable names, same structure, only the key change.}Alternative ({context}):
{Optional — a second valid approach with a different tradeoff.}When NOT to use this pattern:
- {Edge case where the rule doesn't apply}
- {Conditions that override the guidance}
Reference: [{authoritative title}]({URL — prefer developer.apple.com or @expo/ui source})
Gotchas
No known gotchas yet. Append entries here as they're discovered while using @expo/ui/swift-ui in real projects.
Format:
### {Specific failure point}
{1-2 sentences: what goes wrong and how it manifests.}
Fix: {concrete remediation}.
Added: {YYYY-MM-DD}{
"version": "0.1.0",
"organization": "Expo",
"technology": "@expo/ui (SwiftUI for iOS)",
"discipline": "distillation",
"type": "library-reference",
"date": "May 2026",
"abstract": "Library reference for @expo/ui SwiftUI components — covers Host boundaries, modifier composition, iOS 26 Liquid Glass and Human Interface Guidelines composition rules, layout/input/navigation/display catalogues, and ObservableState patterns. Rules are derived from the expo-ui source (v56.0.8) and Apple's iOS 26 HIG, prioritised by cascade impact for agents building Expo apps that bridge to native SwiftUI views.",
"references": [
"https://github.com/expo/expo/tree/main/packages/expo-ui",
"https://docs.expo.dev/versions/latest/sdk/ui/",
"https://developer.apple.com/design/human-interface-guidelines",
"https://developer.apple.com/documentation/TechnologyOverviews/adopting-liquid-glass",
"https://developer.apple.com/documentation/swiftui/glasseffectcontainer",
"https://developer.apple.com/design/human-interface-guidelines/modality",
"https://developer.apple.com/design/human-interface-guidelines/materials",
"https://developer.apple.com/documentation/swiftui/view/presentationdetents(_:)"
]
}
@expo/ui SwiftUI Best Practices Skill
iOS SwiftUI usage guidelines for @expo/ui/swift-ui — covering Host boundaries, modifier composition, iOS 26 HIG composition rules, and ObservableState patterns.
Overview
This skill provides 53 rules across 8 categories, designed to help AI agents and developers build native iOS UIs in Expo apps using SwiftUI through @expo/ui/swift-ui.
Directory Structure
expo-ui/
├── SKILL.md # Entry point with quick reference
├── AGENTS.md # Compiled comprehensive guide (auto-built)
├── metadata.json # Version, org, references
├── gotchas.md # Failure points discovered during use
├── README.md # This file
├── references/
│ ├── _sections.md # Category definitions
│ ├── host-*.md # Setup & Host boundaries (5)
│ ├── hig-*.md # iOS 26 HIG composition rules (7)
│ ├── mod-*.md # Modifiers system (8)
│ ├── layout-*.md # Layout components (6)
│ ├── input-*.md # Input & controls (8)
│ ├── nav-*.md # Navigation & overlays (8)
│ ├── display-*.md # Display & feedback (6)
│ └── state-*.md # State & cross-cutting (5)
└── assets/
└── templates/
└── _template.md # Rule templateGetting Started
Installation
pnpm installBuild AGENTS.md
pnpm buildValidate Skill
pnpm validateCreating a New Rule
1. Choose the appropriate category prefix:
| Category | Prefix | Impact |
|---|---|---|
| Setup & Host Boundaries | host- | CRITICAL |
| iOS 26 HIG Composition Rules | hig- | CRITICAL |
| Modifiers System | mod- | CRITICAL |
| Layout Components | layout- | HIGH |
| Input & Controls | input- | HIGH |
| Navigation & Overlays | nav- | HIGH |
| Display & Feedback | display- | MEDIUM-HIGH |
| State & Cross-Cutting Patterns | state- | MEDIUM |
2. Create a new file: references/{prefix}-{description}.md
3. Copy the template from assets/templates/_template.md
Rule File Structure
---
title: Rule Title Here
impact: CRITICAL|HIGH|MEDIUM-HIGH|MEDIUM|LOW-MEDIUM|LOW
impactDescription: Quantified outcome (e.g., "prevents X", "enables Y")
tags: prefix, technique, tool
---
## Rule Title Here
Brief explanation of WHY this matters in the SwiftUI bridge (1-3 sentences).
**Incorrect (description of problem):**
\`\`\`tsx
// Bad code anchored to a realistic domain
\`\`\`
**Correct (description of solution):**
\`\`\`tsx
// Good code — minimal diff from incorrect
\`\`\`
Reference: [Authoritative source](https://developer.apple.com/...)File Naming Convention
Rules follow the pattern: {prefix}-{slug}.md
- prefix: Category identifier (3-7 chars) from
_sections.md - slug: Kebab-case description of the rule
Examples:
host-wrap-all-swiftui-roots.mdhig-glass-effect-container.mdmod-prop-not-style.md
Impact Levels
| Level | Description |
|---|---|
| CRITICAL | Cascade effect — breaks every downstream component (Host, modifiers, HIG composition) |
| HIGH | Major impact on category — layout containers, primary inputs, presentation surfaces |
| MEDIUM-HIGH | Notable impact in common patterns — secondary inputs, display components |
| MEDIUM | Measurable improvement — state patterns, platform guards |
| LOW-MEDIUM | Minor optimisation for edge cases |
| LOW | Best practice with minimal practical impact |
Scripts
| Command | Description |
|---|---|
pnpm build | Compile rules into AGENTS.md |
pnpm validate | Check skill against quality guidelines |
Contributing
1. Read existing rules in the same category for style consistency 2. Anchor "Incorrect" examples to realistic Expo app domains — not foo/bar 3. Keep the "Correct" diff minimal — same variable names, same structure 4. Cite an authoritative source: developer.apple.com or expo/expo source code 5. Quantify impact ("prevents X", "enables Y", "reduces Z×") rather than vague phrasing 6. Run validation before committing
Acknowledgments
Built from the @expo/ui source (v56.0.8) and Apple's iOS 26 Human Interface Guidelines.
Sections
This file defines all sections, their ordering, impact levels, and descriptions. The section ID (in parentheses) is the filename prefix used to group rules.
---
1. Setup & Host Boundaries (host)
Impact: CRITICAL Description: The Host component is the bridge between React Native and SwiftUI. Misconfigured Host boundaries (missing wrapper, wrong sizing mode, nested React Native views) cause every downstream component to fail layout or rendering.
2. iOS 26 HIG Composition Rules (hig)
Impact: CRITICAL Description: Apple's Human Interface Guidelines define which components compose, which conflict, and how Liquid Glass material must be applied. Violations look broken to users and break the Liquid Glass appearance entirely.
3. Modifiers System (mod)
Impact: CRITICAL Description: Every component in @expo/ui/swift-ui accepts a modifiers array — applying styles through React Native's style prop silently does nothing. Modifier composition order also affects visual outcome.
4. Layout Components (layout)
Impact: HIGH Description: Stack/Grid/Form/ScrollView/Section choices wrap every component below them. Wrong layout container forces children into the wrong native rendering pipeline (e.g., Form chrome vs raw ScrollView).
5. Input & Controls (input)
Impact: HIGH Description: Button, TextField, Toggle, Picker, Slider, Stepper, DatePicker, ColorPicker. Controlled-vs-uncontrolled mistakes and missing role/style modifiers produce inputs that look wrong, lose focus, or skip native validation.
6. Navigation & Overlays (nav)
Impact: HIGH Description: Alert, ConfirmationDialog, BottomSheet, Popover, Menu, ContextMenu, SwipeActions, TabView, DisclosureGroup, ShareLink, Link. HIG-conflicting combinations (popover on iPhone, stacked modals, swipe + context menu on the same row) confuse users.
7. Display & Feedback (display)
Impact: MEDIUM-HIGH Description: Text, Image, Label, Chart, Gauge, ProgressView, Divider. Surface area is large but errors are local — wrong systemName, missing markdown flag, indeterminate progress misconfigured.
8. State & Cross-Cutting Patterns (state)
Impact: MEDIUM Description: useNativeState, ObservableState worklet writes, controlled vs uncontrolled props, platform availability guards, imperative refs (TextFieldRef). These patterns recur across every interactive component.
Build Chart Data from ChartDataPoint Arrays, Not Raw Numbers
Chart consumes a ChartDataPoint[] shaped as { x, y, color? }. Building this structured representation up front lets the native Swift Charts pipeline produce native axes, legends, and gridlines — and gives you a per-point colour hook for highlighting outliers or category coding. Inlining raw numbers and recomputing labels yourself misses the native renderer's affordances entirely.
Incorrect (parallel x/y arrays — no per-point colour, awkward label mapping):
import { Host, Chart } from '@expo/ui/swift-ui';
const labels = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri'];
const values = [82, 91, 73, 88, 95];
<Host matchContents>
<Chart
type="bar"
data={values.map((y, i) => ({ x: labels[i], y }))}
/>
</Host>Correct (ChartDataPoint with colour highlighting the lowest day):
import { Host, Chart, type ChartDataPoint } from '@expo/ui/swift-ui';
const dailyEngagement: ChartDataPoint[] = [
{ x: 'Mon', y: 82 },
{ x: 'Tue', y: 91 },
{ x: 'Wed', y: 73, color: '#FF9500' },
{ x: 'Thu', y: 88 },
{ x: 'Fri', y: 95 },
];
<Host matchContents>
<Chart type="bar" data={dailyEngagement} />
</Host>Reference: @expo/ui Chart source
Provide currentValueLabel on Gauge for Accessibility and Context
Gauge shows a graphical progress arc but the raw value (e.g., 0.73) is meaningless without a label that interprets it ("73%", "73 of 100 calories", "$730"). Without currentValueLabel, VoiceOver announces only the raw fraction and sighted users have to mentally translate the arc position. The label slot is the only way to attach interpretive text that travels with the gauge.
Incorrect (no currentValueLabel — VoiceOver reads 0.73, sighted users guess):
import { Host, Gauge, Text } from '@expo/ui/swift-ui';
<Host matchContents>
<Gauge value={0.73}>
<Text>Calories</Text>
</Gauge>
</Host>Correct (currentValueLabel — both VoiceOver and sighted users get context):
import { Host, Gauge, Text } from '@expo/ui/swift-ui';
<Host matchContents>
<Gauge
value={0.73}
currentValueLabel={<Text>1,460 / 2,000 kcal</Text>}>
<Text>Calories</Text>
</Gauge>
</Host>Alternative (closed range gauge with min/max value labels):
<Gauge
value={185}
min={120}
max={220}
currentValueLabel={<Text>185 bpm</Text>}
minimumValueLabel={<Text>120</Text>}
maximumValueLabel={<Text>220</Text>}>
<Text>Heart rate</Text>
</Gauge>Reference: @expo/ui Gauge source
Prefer systemName SF Symbols Over Raster uiImage for Icons
Image.systemName references the system SF Symbols catalogue: vector glyphs that scale with Dynamic Type, support variable colour for fill states, animate via symbol effects, and stay sharp on every display. Image.uiImage loads a file from disk synchronously on the main thread — it blocks layout and can't scale or animate. Use SF Symbols for every icon-style image; reserve uiImage for content imagery (photos, screenshots, branded illustrations).
Incorrect (uiImage for an icon — main-thread read, no Dynamic Type, no symbol effects):
import { Host, Image } from '@expo/ui/swift-ui';
<Host matchContents>
<Image uiImage="file:///path/to/heart-icon.png" size={24} />
</Host>Correct (systemName SF Symbol — vector, accessible, animatable):
import { Host, Image } from '@expo/ui/swift-ui';
<Host matchContents>
<Image systemName="heart.fill" size={24} color="#FF3B30" />
</Host>Alternative (variable colour for partial fill — battery/signal indicators):
<Image systemName="battery.100" variableValue={0.4} size={20} />Reference: SF Symbols catalogue
Use Label.systemImage for SF Symbols, Label.icon for Custom Glyphs
Label accepts two ways to provide an icon: systemImage (a string SF Symbol name) or icon (a full React child). The string form gets all the SF Symbol affordances — symbol effects, Dynamic Type, variable colour. The icon slot is for cases where the icon is a custom image, a stylised glyph, or a composed view (e.g., an Image with a Badge overlay). Mixing the two by passing an <Image> child when systemImage would suffice loses the symbol affordances.
Incorrect (passing Image where systemImage would do — loses symbol effects):
import { Host, Label, Image } from '@expo/ui/swift-ui';
<Host matchContents>
<Label title="Inbox" icon={<Image systemName="tray.fill" />} />
</Host>Correct (systemImage — string SF Symbol gets all the affordances):
import { Host, Label } from '@expo/ui/swift-ui';
<Host matchContents>
<Label title="Inbox" systemImage="tray.fill" />
</Host>Alternative (icon slot for a custom image not in the SF Symbol set):
import { Label, Image } from '@expo/ui/swift-ui';
<Label
title="Slack channel"
icon={<Image uiImage="file:///bundle/slack-logo.png" size={20} />}
/>Reference: @expo/ui Label source
Pass value=undefined to ProgressView for Indeterminate Spinner
ProgressView has two distinct modes: determinate (when value is a number 0–1, renders as a progress bar) and indeterminate (when value is undefined, renders as a system spinner). Passing 0 puts it in determinate mode at 0% — a frozen progress bar that misrepresents "I don't know how long this takes". Use undefined for unknown-duration work like network calls or initial data loads.
Incorrect (value=0 — renders a frozen progress bar, not a spinner):
import { Host, ProgressView, Text } from '@expo/ui/swift-ui';
const [loading, setLoading] = useState(true);
<Host matchContents>
{loading && (
<ProgressView value={0}>
<Text>Loading reservations…</Text>
</ProgressView>
)}
</Host>Correct (no value — system spinner):
import { Host, ProgressView, Text } from '@expo/ui/swift-ui';
const [loading, setLoading] = useState(true);
<Host matchContents>
{loading && (
<ProgressView>
<Text>Loading reservations…</Text>
</ProgressView>
)}
</Host>Alternative (determinate progress when totalBytes is known):
<ProgressView value={uploadedBytes / totalBytes}>
<Text>Uploading photos — {Math.round((uploadedBytes / totalBytes) * 100)}%</Text>
</ProgressView>Reference: @expo/ui ProgressView source
Enable markdownEnabled for Inline Bold, Italic, Links
Text.markdownEnabled opts the SwiftUI Text view into Markdown parsing — **bold**, *italic*, and [link](https://...) render with the right styles inline. The alternative — composing nested Text views with individual modifiers — produces a brittle layout that breaks Dynamic Type wrapping, can't render inline links, and forces manual reflow logic. Use Markdown for any sentence-level rich text.
Incorrect (concatenated Text — bold word breaks wrapping, no inline link):
import { Host, HStack, Text } from '@expo/ui/swift-ui';
import { bold } from '@expo/ui/swift-ui/modifiers';
<Host matchContents>
<HStack>
<Text>You have </Text>
<Text modifiers={[bold()]}>3 unread messages</Text>
<Text> waiting in your inbox.</Text>
</HStack>
</Host>Correct (markdownEnabled — single Text reflows correctly):
import { Host, Text } from '@expo/ui/swift-ui';
<Host matchContents>
<Text markdownEnabled>
You have **3 unread messages** waiting in your inbox.
</Text>
</Host>Alternative (live timer for "ends in 3:24"):
import { Text } from '@expo/ui/swift-ui';
const endsAt = new Date(Date.now() + 5 * 60 * 1000);
<Text date={endsAt} dateStyle="timer" />Reference: @expo/ui Text source
Use ConfirmationDialog with role='destructive' for Destructive Confirmations
HIG's action-sheet guidance: confirm destructive, irreversible actions with a confirmation dialog whose primary action carries the .destructive role. The role drives both the system red colour and VoiceOver semantics that announce the action as destructive — a plain Button styled red gives the colour but not the semantics, and a plain Alert doesn't adapt to a popover on iPad. ConfirmationDialog handles all three concerns.
Incorrect (Alert with red tint — wrong semantics, no iPad popover adaptation):
import { Host, Alert, Button } from '@expo/ui/swift-ui';
import { tint } from '@expo/ui/swift-ui/modifiers';
<Host matchContents>
<Alert title="Delete account?" isPresented={confirmOpen} onIsPresentedChange={setConfirmOpen}>
<Alert.Trigger>
<Button label="Delete" onPress={() => setConfirmOpen(true)} />
</Alert.Trigger>
<Alert.Actions>
<Button label="Delete forever" onPress={deleteAccount} modifiers={[tint('#FF3B30')]} />
<Button label="Cancel" onPress={() => setConfirmOpen(false)} />
</Alert.Actions>
</Alert>
</Host>Correct (ConfirmationDialog + destructive role):
import { Host, ConfirmationDialog, Button } from '@expo/ui/swift-ui';
<Host matchContents>
<ConfirmationDialog title="Delete account?" isPresented={confirmOpen} onIsPresentedChange={setConfirmOpen}>
<ConfirmationDialog.Trigger>
<Button label="Delete" onPress={() => setConfirmOpen(true)} />
</ConfirmationDialog.Trigger>
<ConfirmationDialog.Message>
This permanently removes your data. It cannot be undone.
</ConfirmationDialog.Message>
<ConfirmationDialog.Actions>
<Button role="destructive" label="Delete forever" onPress={deleteAccount} />
<Button role="cancel" label="Cancel" onPress={() => setConfirmOpen(false)} />
</ConfirmationDialog.Actions>
</ConfirmationDialog>
</Host>When NOT to use this pattern:
- For non-destructive confirmations (e.g., "Save changes?"). A plain
Alertis appropriate.
Reference: Action sheets | HIG
Wrap Multiple Glass Siblings in a GlassEffectContainer
iOS 26 Liquid Glass material is designed to blend across adjacent shapes — two glass buttons close together should fuse into one shape as the user interacts. SwiftUI achieves this with GlassEffectContainer, which groups its glass children so they share a morph pass. Applying glassEffect() to siblings without a container forces each to run an independent shader pass and skips the morphing animation entirely.
Incorrect (siblings render as isolated glass shapes — no morphing, higher GPU cost):
import { Host, HStack, Image } from '@expo/ui/swift-ui';
import { glassEffect } from '@expo/ui/swift-ui/modifiers';
<Host matchContents>
<HStack spacing={8}>
<Image systemName="play.fill" modifiers={[glassEffect()]} />
<Image systemName="pause.fill" modifiers={[glassEffect()]} />
<Image systemName="forward.fill" modifiers={[glassEffect()]} />
</HStack>
</Host>Correct (container groups the glass shapes for morphing):
import { Host, GlassEffectContainer, HStack, Image } from '@expo/ui/swift-ui';
import { glassEffect } from '@expo/ui/swift-ui/modifiers';
<Host matchContents>
<GlassEffectContainer spacing={8}>
<HStack spacing={8}>
<Image systemName="play.fill" modifiers={[glassEffect()]} />
<Image systemName="pause.fill" modifiers={[glassEffect()]} />
<Image systemName="forward.fill" modifiers={[glassEffect()]} />
</HStack>
</GlassEffectContainer>
</Host>When NOT to use this pattern:
- For a single isolated glass element. The container only matters when glass shapes need to blend.
Reference: GlassEffectContainer | SwiftUI
Avoid Nesting glassEffect Inside Already-Glass Surfaces
iOS 26 toolbars, tab bars, and sheet chrome already use Liquid Glass material. Applying glassEffect() to content placed inside a glass surface (a toolbar button, a sheet's contents) compounds the material — the resulting render is opaque, loses transparency, and breaks the visual depth Apple's design system encodes. Apple's HIG explicitly limits Liquid Glass to the floating control layer; content beneath should use fills, vibrancy, or plain backgrounds.
Incorrect (sheet content with glass-on-glass — opaque, broken):
import { Host, BottomSheet, Group, Button } from '@expo/ui/swift-ui';
import { glassEffect } from '@expo/ui/swift-ui/modifiers';
<Host matchContents>
<BottomSheet isPresented onIsPresentedChange={setOpen}>
<Group modifiers={[glassEffect()]}>
<Button label="Confirm" onPress={confirm} />
</Group>
</BottomSheet>
</Host>Correct (sheet chrome owns the glass — content uses a plain Group):
import { Host, BottomSheet, Group, Button } from '@expo/ui/swift-ui';
<Host matchContents>
<BottomSheet isPresented onIsPresentedChange={setOpen}>
<Group>
<Button label="Confirm" onPress={confirm} />
</Group>
</BottomSheet>
</Host>When NOT to use this pattern:
- When deliberately layering a separate floating glass control over a glass toolbar (e.g., a floating action button above a tab bar). The two glass surfaces are distinct layers, not nested.
Reference: Adopting Liquid Glass | Apple
Avoid Presenting a Sheet from Inside Another Sheet
Apple's HIG on modality says: minimise modal layers. Stacking a BottomSheet over a BottomSheet, or an Alert over a ConfirmationDialog, forces the user to dismiss multiple layers to return to the underlying screen — and on iOS 26 the floating Liquid Glass appearance only renders correctly for the outermost sheet. Resolve the first sheet's task before presenting the next, or redesign the flow as a navigation push.
Incorrect (sheet-from-sheet — user must dismiss twice):
import { Host, BottomSheet, Group, Button } from '@expo/ui/swift-ui';
<Host matchContents>
<BottomSheet isPresented={editorOpen} onIsPresentedChange={setEditorOpen}>
<Group>
<Button label="Open advanced" onPress={() => setAdvancedOpen(true)} />
<BottomSheet isPresented={advancedOpen} onIsPresentedChange={setAdvancedOpen}>
<Group><AdvancedSettings /></Group>
</BottomSheet>
</Group>
</BottomSheet>
</Host>Correct (push the advanced view onto the first sheet's navigation stack):
import { Host, BottomSheet, Group, Button } from '@expo/ui/swift-ui';
<Host matchContents>
<BottomSheet isPresented={editorOpen} onIsPresentedChange={setEditorOpen}>
<Group>
{advancedOpen
? <AdvancedSettings onBack={() => setAdvancedOpen(false)} />
: <Button label="Open advanced" onPress={() => setAdvancedOpen(true)} />}
</Group>
</BottomSheet>
</Host>When NOT to use this pattern:
- A
ConfirmationDialogpresented from within a sheet to confirm a destructive action is permitted by HIG, because it's a short transient confirmation, not a stacked task.
Reference: Modality | HIG
Use ConfirmationDialog or BottomSheet on iPhone Instead of Popover
HIG popover guidance is explicit: popovers are most appropriate on regular-width devices (iPad, Mac). On iPhone (compact width), SwiftUI auto-adapts a .popover() to a sheet — the anchor-to-trigger visual relationship HIG recommends is lost, and one-handed iPhone ergonomics suffer because the sheet covers the bottom of the screen. ConfirmationDialog adapts cleanly (sheet on iPhone, popover on iPad) for short option lists; BottomSheet is the right choice for longer transient content on iPhone.
Incorrect (Popover used unconditionally — bad fit on iPhone):
import { Host, Popover, Button, Group } from '@expo/ui/swift-ui';
<Host matchContents>
<Popover isPresented={filterOpen} onIsPresentedChange={setFilterOpen}>
<Popover.Trigger>
<Button label="Filter" onPress={() => setFilterOpen(true)} />
</Popover.Trigger>
<Popover.Content>
<Group><FilterOptions /></Group>
</Popover.Content>
</Popover>
</Host>Correct (BottomSheet on iPhone, Popover on iPad via Platform check):
import { Platform } from 'react-native';
import { Host, Popover, BottomSheet, Button, Group } from '@expo/ui/swift-ui';
<Host matchContents>
<Button label="Filter" onPress={() => setFilterOpen(true)} />
{Platform.isPad ? (
<Popover isPresented={filterOpen} onIsPresentedChange={setFilterOpen}>
<Popover.Content><Group><FilterOptions /></Group></Popover.Content>
</Popover>
) : (
<BottomSheet isPresented={filterOpen} onIsPresentedChange={setFilterOpen}>
<Group><FilterOptions /></Group>
</BottomSheet>
)}
</Host>Alternative (ConfirmationDialog auto-adapts for short option lists):
import { Host, ConfirmationDialog, Button } from '@expo/ui/swift-ui';
<Host matchContents>
<ConfirmationDialog title="Filter by status" isPresented={filterOpen} onIsPresentedChange={setFilterOpen}>
<ConfirmationDialog.Trigger>
<Button label="Filter" onPress={() => setFilterOpen(true)} />
</ConfirmationDialog.Trigger>
<ConfirmationDialog.Actions>
<Button label="Active" onPress={() => applyFilter('active')} />
<Button label="Archived" onPress={() => applyFilter('archived')} />
</ConfirmationDialog.Actions>
</ConfirmationDialog>
</Host>Reference: Popovers | HIG
Include a Partial Detent to Enable the Liquid Glass Sheet Appearance
iOS 26 only renders the floating, glass-edged sheet appearance when the sheet supports at least one partial-height detent (.medium, a fraction, or a fixed height). A .large-only configuration anchors the sheet to the screen edges with an opaque background — visually the pre-iOS-26 fallback. Always include a partial detent unless the screen genuinely requires full immersion.
Incorrect (large-only detent — sheet loses Liquid Glass appearance):
import { Host, BottomSheet, Group, Form } from '@expo/ui/swift-ui';
import { presentationDetents } from '@expo/ui/swift-ui/modifiers';
<Host useViewportSizeMeasurement style={{ flex: 1 }}>
<BottomSheet isPresented={open} onIsPresentedChange={setOpen}>
<Group modifiers={[presentationDetents(['large'])]}>
<Form><AccountSettings /></Form>
</Group>
</BottomSheet>
</Host>Correct (medium + large — sheet floats with Liquid Glass):
import { Host, BottomSheet, Group, Form } from '@expo/ui/swift-ui';
import { presentationDetents } from '@expo/ui/swift-ui/modifiers';
<Host useViewportSizeMeasurement style={{ flex: 1 }}>
<BottomSheet isPresented={open} onIsPresentedChange={setOpen}>
<Group modifiers={[presentationDetents(['medium', 'large'])]}>
<Form><AccountSettings /></Form>
</Group>
</BottomSheet>
</Host>Alternative (content-sized sheet for short tasks):
<BottomSheet isPresented={open} onIsPresentedChange={setOpen} fitToContents>
<Group><QuickAction /></Group>
</BottomSheet>Reference: presentationDetents | SwiftUI)
Reserve tint Modifier for Brand Surfaces — Keep Semantic System Colors
System colors (red for destructive, green for affirmative) carry semantic meaning users have learned: red = stop, dangerous, irreversible. Overriding .tint() to a brand red repurposes that signal for non-destructive actions and confuses users — particularly those using Increase Contrast or VoiceOver where colour is a primary cue. Apply brand tint only on neutral surfaces (a primary CTA, an accent indicator), never on a destructive control.
Incorrect (brand-red tint on a non-destructive button — collides with destructive semantics):
import { Host, Button } from '@expo/ui/swift-ui';
import { tint } from '@expo/ui/swift-ui/modifiers';
<Host matchContents>
<Button label="Subscribe" onPress={subscribe} modifiers={[tint('#E53935')]} />
</Host>Correct (brand tint on a primary CTA — neutral semantic):
import { Host, Button } from '@expo/ui/swift-ui';
import { tint, buttonStyle, controlSize } from '@expo/ui/swift-ui/modifiers';
<Host matchContents>
<Button
label="Subscribe"
onPress={subscribe}
modifiers={[tint('#0A84FF'), buttonStyle('borderedProminent'), controlSize('large')]}
/>
</Host>Alternative (destructive action uses the role — no tint needed):
<Button role="destructive" label="Delete subscription" onPress={cancel} />When NOT to use this pattern:
- Recording indicators, urgent alerts, and other contexts where red is the correct semantic. Use the system red color rather than a custom hex.
Reference: Color | HIG
Pass an Explicit colorScheme to Host When Overriding System Appearance
SwiftUI reads its color scheme from the hosting environment. Host's colorScheme prop seeds the SwiftUI environment with light or dark; omitting it lets SwiftUI follow the system. Forgetting to set it when the React Native app uses its own theme system (e.g., a theme toggle) leaves SwiftUI content out of sync — buttons render in light mode while the rest of the app is dark.
Incorrect (theme toggle changes React Native UI but not Host content):
import { useColorScheme } from 'react-native';
import { Host, Button } from '@expo/ui/swift-ui';
export function ActionBar() {
const scheme = useColorScheme();
return (
<Host matchContents>
<Button label="Save" onPress={save} />
</Host>
);
}Correct (Host receives the same scheme as RN):
import { useColorScheme } from 'react-native';
import { Host, Button } from '@expo/ui/swift-ui';
export function ActionBar() {
const scheme = useColorScheme();
return (
<Host matchContents colorScheme={scheme ?? 'light'}>
<Button label="Save" onPress={save} />
</Host>
);
}When NOT to use this pattern:
- If you specifically want SwiftUI to follow the OS appearance regardless of the app's in-app theme override.
Reference: Host colorScheme prop
Use ignoreSafeArea Only for Full-Bleed Surfaces
ignoreSafeArea on Host tells SwiftUI to extend its content under the safe area insets. Setting it indiscriminately pushes interactive controls (buttons, text fields) under the Dynamic Island or home indicator where the user cannot reach them. Reserve it for backgrounds and decorative content that should bleed edge-to-edge.
Incorrect (action sheet content goes under the home indicator):
import { Host, BottomSheet, Group, Button } from '@expo/ui/swift-ui';
<Host ignoreSafeArea="all" matchContents>
<BottomSheet isPresented onIsPresentedChange={setOpen}>
<Group>
<Button label="Delete account" onPress={deleteAccount} />
</Group>
</BottomSheet>
</Host>Correct (only ignore safe area on the background layer):
import { Host, BottomSheet, Group, Button } from '@expo/ui/swift-ui';
<Host matchContents>
<BottomSheet isPresented onIsPresentedChange={setOpen}>
<Group>
<Button label="Delete account" onPress={deleteAccount} />
</Group>
</BottomSheet>
</Host>Alternative (ignore only the keyboard inset for a chat composer):
<Host ignoreSafeArea="keyboard" matchContents>
<ChatComposer />
</Host>Warning (mount-only setting):
Like matchContents, ignoreSafeArea is read once on mount. Changing it later has no effect.
Reference: Host ignoreSafeArea
Use matchContents to Size Host to Its SwiftUI Content
By default, Host takes whatever size React Native gives it via style. SwiftUI components like Button, Text, and Image are intrinsically sized — without an explicit React Native frame, Host collapses to 0×0 and content disappears. matchContents lets the SwiftUI layout drive the Host's measured size back into React Native.
Incorrect (Host has no size — Button renders into 0×0):
import { Host, Button } from '@expo/ui/swift-ui';
<Host>
<Button label="Subscribe" onPress={handleSubscribe} />
</Host>Correct (Host adopts the SwiftUI content size):
import { Host, Button } from '@expo/ui/swift-ui';
<Host matchContents>
<Button label="Subscribe" onPress={handleSubscribe} />
</Host>Alternative (axis-specific matching for horizontal-only):
<Host matchContents={{ horizontal: true }} style={{ height: 56 }}>
<Button label="Subscribe" onPress={handleSubscribe} />
</Host>Warning (matchContents is mount-only):
The matchContents prop is read once on mount. Changing its value later does nothing. Decide up front whether the Host should self-size or be sized by React Native.
Reference: Host props
Use useViewportSizeMeasurement for Form and Fill-Available Content
SwiftUI's Form and List propose to fill all available space — they expect the parent to offer a concrete size. Inside a Host configured with matchContents, that available size is unknown, so the form collapses. useViewportSizeMeasurement tells the Host to propose the viewport size to SwiftUI layout before measuring back.
Incorrect (Form collapses inside Host with matchContents):
import { Host, Form, Section, TextField } from '@expo/ui/swift-ui';
<Host matchContents style={{ flex: 1 }}>
<Form>
<Section title="Profile">
<TextField placeholder="Display name" />
</Section>
</Form>
</Host>Correct (Host proposes viewport size to Form):
import { Host, Form, Section, TextField } from '@expo/ui/swift-ui';
<Host useViewportSizeMeasurement style={{ flex: 1 }}>
<Form>
<Section title="Profile">
<TextField placeholder="Display name" />
</Section>
</Form>
</Host>When NOT to use this pattern:
- For intrinsically sized content (single Button, Text, Image). Use
matchContentsinstead — the viewport-size mode would inflate the Host to fill the screen.
Reference: Host useViewportSizeMeasurement
Wrap All SwiftUI Trees in a Host Component
Host is the only component that mounts a SwiftUI hosting view inside the React Native tree. SwiftUI components from @expo/ui/swift-ui are not React Native views — they are native SwiftUI views that need a hosting boundary to receive a size, color scheme, and layout direction from React Native. Rendering a SwiftUI component outside a Host fails silently (no size, no events, no visuals).
Incorrect (SwiftUI component rendered at React Native root — view never lays out):
import { View } from 'react-native';
import { Button } from '@expo/ui/swift-ui';
export function CheckoutScreen() {
return (
<View style={{ flex: 1 }}>
<Button label="Pay" onPress={submitPayment} />
</View>
);
}Correct (Host bridges into SwiftUI):
import { View } from 'react-native';
import { Host, Button } from '@expo/ui/swift-ui';
export function CheckoutScreen() {
return (
<View style={{ flex: 1 }}>
<Host style={{ height: 56 }}>
<Button label="Pay" onPress={submitPayment} />
</Host>
</View>
);
}When NOT to use this pattern:
- Inside an already-mounted Host. Nesting Host inside Host is redundant — descendants are already in SwiftUI.
Reference: @expo/ui Host source
Use Button role='destructive' for Delete-Style Actions
The role prop maps to SwiftUI's ButtonRole. destructive renders the button in the system's destructive color (red on iOS), is announced as destructive by VoiceOver, and is positioned by the system at the bottom of confirmation dialogs and context menus. Faking the look with a tint modifier gives the colour but loses the semantics — and on iPad popovers the system arranges destructive actions differently.
Incorrect (tint-only — wrong VoiceOver semantic, wrong system placement):
import { Host, Button } from '@expo/ui/swift-ui';
import { tint } from '@expo/ui/swift-ui/modifiers';
<Host matchContents>
<Button label="Delete listing" onPress={deleteListing} modifiers={[tint('#FF3B30')]} />
</Host>Correct (role drives colour + semantics):
import { Host, Button } from '@expo/ui/swift-ui';
<Host matchContents>
<Button role="destructive" label="Delete listing" onPress={deleteListing} />
</Host>Alternative (cancel role for dismissal buttons in dialogs):
<Button role="cancel" label="Keep listing" onPress={closeDialog} />Reference: @expo/ui Button source
Use systemImage for Button Icons — SF Symbols Scale and Adapt Automatically
Button.systemImage accepts an SF Symbol name ("heart", "square.and.arrow.up", etc.). SF Symbols are vector glyphs that scale to Dynamic Type sizes, adapt their fill/outline to the system, animate via symbol effects, and stay sharp at every resolution. Embedding a raster PNG via children or a custom Image bypasses all of that — the icon misaligns at large Dynamic Type, and accessibility settings can't influence it.
Incorrect (raster PNG inside Button — no Dynamic Type, no symbol effects):
import { Host, Button, Image } from 'react-native';
import { Button as UIButton, Host as UIHost } from '@expo/ui/swift-ui';
<UIHost matchContents>
<UIButton onPress={share}>
<Image source={require('./assets/share-icon.png')} style={{ width: 22, height: 22 }} />
</UIButton>
</UIHost>Correct (systemImage — SF Symbol scales with text and supports effects):
import { Host, Button } from '@expo/ui/swift-ui';
<Host matchContents>
<Button label="Share" systemImage="square.and.arrow.up" onPress={share} />
</Host>Alternative (icon-only button via labelStyle modifier):
import { Button } from '@expo/ui/swift-ui';
import { labelStyle } from '@expo/ui/swift-ui/modifiers';
<Button
label="Share"
systemImage="square.and.arrow.up"
onPress={share}
modifiers={[labelStyle('iconOnly')]}
/>Reference: SF Symbols
Constrain Selectable Dates with the range Prop
DatePicker.range accepts { start?: Date; end?: Date }. Setting it disables out-of-range dates in the native picker, so the user cannot pick a check-out before check-in or a birthday in the future. Validating the date in JavaScript after the user selects it provides a worse experience — the user has already committed before seeing the error. Use range to constrain at the picker level.
Incorrect (no range — user can select an invalid future check-in):
import { Host, DatePicker } from '@expo/ui/swift-ui';
const [checkIn, setCheckIn] = useState<Date>();
<Host matchContents>
<DatePicker
title="Check-in"
selection={checkIn}
onDateChange={setCheckIn}
/>
</Host>Correct (range bounds the selectable interval to the next 12 months):
import { Host, DatePicker } from '@expo/ui/swift-ui';
const [checkIn, setCheckIn] = useState<Date>();
const today = new Date();
const twelveMonthsOut = new Date(today.getFullYear() + 1, today.getMonth(), today.getDate());
<Host matchContents>
<DatePicker
title="Check-in"
selection={checkIn}
onDateChange={setCheckIn}
range={{ start: today, end: twelveMonthsOut }}
/>
</Host>Alternative (open-ended lower bound for birthday — only past dates):
<DatePicker
title="Date of birth"
selection={birthday}
onDateChange={setBirthday}
range={{ end: new Date() }}
/>Reference: @expo/ui DatePicker source
Set Picker Appearance via pickerStyle Modifier, Not a Prop
Picker does not have a style or variant prop — its appearance comes from the pickerStyle modifier. Without it, the picker renders in its automatic default which iOS often chooses to be wheel-style inside Forms but menu-style elsewhere. Specifying the modifier locks in the appearance you want and keeps the picker visually consistent across Form vs free-floating placements.
Incorrect (no pickerStyle — default appearance varies by container):
import { Host, Picker, Text } from '@expo/ui/swift-ui';
import { tag } from '@expo/ui/swift-ui/modifiers';
<Host matchContents>
<Picker label="Plan" selection={plan} onSelectionChange={setPlan}>
<Text modifiers={[tag('basic')]}>Basic</Text>
<Text modifiers={[tag('pro')]}>Pro</Text>
<Text modifiers={[tag('enterprise')]}>Enterprise</Text>
</Picker>
</Host>Correct (segmented Picker — explicit appearance):
import { Host, Picker, Text } from '@expo/ui/swift-ui';
import { pickerStyle, tag } from '@expo/ui/swift-ui/modifiers';
<Host matchContents>
<Picker
label="Plan"
selection={plan}
onSelectionChange={setPlan}
modifiers={[pickerStyle('segmented')]}>
<Text modifiers={[tag('basic')]}>Basic</Text>
<Text modifiers={[tag('pro')]}>Pro</Text>
<Text modifiers={[tag('enterprise')]}>Enterprise</Text>
</Picker>
</Host>Alternative (menu-style for long option lists):
<Picker
label="Country"
selection={country}
onSelectionChange={setCountry}
modifiers={[pickerStyle('menu')]}>
{countries.map((c) => <Text key={c.code} modifiers={[tag(c.code)]}>{c.name}</Text>)}
</Picker>Reference: @expo/ui Picker source
Use SecureField for Password Inputs — Not TextField
SecureField is a separate native view that wires into the iOS password autofill machinery, biometric autofill (Face ID), and disables screenshot/share-sheet capture of the text contents. A TextField with custom obscuring (rendering bullets via JS) gives the visual mask but skips every one of those system integrations. Use SecureField whenever the value is a password, passcode, or credential.
Incorrect (TextField for password — no autofill, no biometric fill):
import { Host, TextField, useNativeState } from '@expo/ui/swift-ui';
export function LoginPasswordField() {
const password = useNativeState('');
return (
<Host matchContents>
<TextField text={password} placeholder="Password" />
</Host>
);
}Correct (SecureField — iOS autofill and biometric prompts attach):
import { Host, SecureField, useNativeState } from '@expo/ui/swift-ui';
export function LoginPasswordField() {
const password = useNativeState('');
return (
<Host matchContents>
<SecureField text={password} placeholder="Password" />
</Host>
);
}Reference: @expo/ui SecureField source
Provide min and max on Stepper to Bound the Increment Range
Stepper has +/− buttons that walk a numeric value by step. Without min and max, the user can freely run the value below zero or above whatever business logic permits — and the buttons stay enabled, falsely promising more room. Setting both bounds lets the native stepper dim the appropriate button at the edge, communicating the limit visually.
Incorrect (unbounded stepper — guest count can go negative):
import { Host, Stepper } from '@expo/ui/swift-ui';
<Host matchContents>
<Stepper label="Guests" value={guests} step={1} onValueChange={setGuests} />
</Host>Correct (min and max set the booking-valid range):
import { Host, Stepper } from '@expo/ui/swift-ui';
<Host matchContents>
<Stepper
label="Guests"
value={guests}
step={1}
min={1}
max={8}
onValueChange={setGuests}
/>
</Host>Reference: @expo/ui Stepper source
Use useNativeState for TextField text — Not React useState
TextField's text prop expects an ObservableState<string> created with useNativeState. The state lives on the native side and updates in place as the user types — no JS bridge round-trip per keystroke. Passing a React useState value wired through onTextChange works but every keystroke costs a JS render and a bridge crossing, which feels laggy on long lists or rich forms. Reserve React state only when the value drives non-SwiftUI consumers.
Incorrect (React state — keystroke triggers JS render and bridge call):
import { useState } from 'react';
import { Host, TextField } from '@expo/ui/swift-ui';
export function EmailField() {
const [email, setEmail] = useState('');
return (
<Host matchContents>
<TextField placeholder="you@example.com" onTextChange={setEmail} />
</Host>
);
}Correct (ObservableState — text updates stay on the native side):
import { Host, TextField, useNativeState } from '@expo/ui/swift-ui';
export function EmailField() {
const email = useNativeState('');
return (
<Host matchContents>
<TextField text={email} placeholder="you@example.com" />
</Host>
);
}Alternative (read the value when submitting, no per-keystroke React render):
import { Host, TextField, Button, useNativeState } from '@expo/ui/swift-ui';
export function EmailField({ onSubmit }: { onSubmit: (email: string) => void }) {
const email = useNativeState('');
return (
<Host matchContents>
<TextField text={email} placeholder="you@example.com" />
<Button label="Send" onPress={() => onSubmit(email.value)} />
</Host>
);
}Reference: useNativeState
Use Toggle for Async State, SyncToggle for Instant Native Updates
Toggle accepts isOn + onIsOnChange like a controlled React component — the user flicks, the callback fires, React updates state, the prop comes back. On a slow JS thread or laggy bridge this produces a visible flicker. SyncToggle instead binds directly to an ObservableState<boolean> and commits the new value to native state synchronously — no bridge round-trip — so the toggle appears to flick instantly. Use Toggle when the change must trigger React side effects (server save, navigation); use SyncToggle for pure UI state.
Incorrect (Toggle for pure UI state — visible flicker on JS-thread contention):
import { useState } from 'react';
import { Host, Toggle } from '@expo/ui/swift-ui';
const [pinned, setPinned] = useState(false);
<Host matchContents>
<Toggle label="Pin to top" isOn={pinned} onIsOnChange={setPinned} />
</Host>Correct (SyncToggle bound to ObservableState — instant native flick):
import { Host, SyncToggle, useNativeState } from '@expo/ui/swift-ui';
const pinned = useNativeState(false);
<Host matchContents>
<SyncToggle label="Pin to top" isOn={pinned} />
</Host>Alternative (Toggle when the change must persist server-side):
import { Host, Toggle } from '@expo/ui/swift-ui';
<Host matchContents>
<Toggle
label="Email digest"
isOn={digestEnabled}
onIsOnChange={async (next) => {
setDigestEnabled(next);
await api.updateNotificationPref({ digest: next });
}}
/>
</Host>Reference: @expo/ui SyncToggle source
Use Form for Settings-Style Screens — Not VStack Inside ScrollView
Form is SwiftUI's container for data entry and settings — it automatically applies inset-grouped backgrounds, separates rows, lays out labels and inputs to the platform's standard, and supports nested Section blocks with headers and footers. Reconstructing this look with VStack/ScrollView and custom dividers produces a brittle approximation that drifts as iOS updates. Reach for Form whenever the screen is a list of settings, profile fields, or grouped controls.
Incorrect (VStack-based settings — divergent visuals, no automatic grouping):
import { Host, ScrollView, VStack, Divider, Text, Toggle, TextField } from '@expo/ui/swift-ui';
<Host useViewportSizeMeasurement style={{ flex: 1 }}>
<ScrollView>
<VStack alignment="leading" spacing={16}>
<Text>Notifications</Text>
<Toggle label="Push" isOn={push} onIsOnChange={setPush} />
<Divider />
<Toggle label="Email" isOn={email} onIsOnChange={setEmail} />
</VStack>
</ScrollView>
</Host>Correct (Form with Section — adopts iOS grouped chrome):
import { Host, Form, Section, Toggle } from '@expo/ui/swift-ui';
<Host useViewportSizeMeasurement style={{ flex: 1 }}>
<Form>
<Section title="Notifications">
<Toggle label="Push" isOn={push} onIsOnChange={setPush} />
<Toggle label="Email" isOn={email} onIsOnChange={setEmail} />
</Section>
</Form>
</Host>When NOT to use this pattern:
- Free-form content layouts (a marketing screen, a custom dashboard). Use ScrollView + VStack.
Reference: Form | SwiftUI
Use Grid for Column-Aligned Content — Stacks Don't Align Across Rows
A VStack of HStacks sizes each HStack independently — column widths drift, labels misalign, and the result reads as a list rather than a table. Grid (with Grid.Row children) propagates column widths across rows so cells in the same column have the same width. Use Grid whenever rows share a column structure (labelled forms, key/value tables, two-column tags).
Incorrect (VStack of HStacks — second column drifts):
import { Host, VStack, HStack, Text } from '@expo/ui/swift-ui';
<Host matchContents>
<VStack alignment="leading" spacing={4}>
<HStack spacing={12}>
<Text>Plan</Text>
<Text>Pro Monthly</Text>
</HStack>
<HStack spacing={12}>
<Text>Renews</Text>
<Text>1 June 2026</Text>
</HStack>
</VStack>
</Host>Correct (Grid — both columns line up across rows):
import { Host, Grid, Text } from '@expo/ui/swift-ui';
<Host matchContents>
<Grid alignment="leading" horizontalSpacing={16} verticalSpacing={4}>
<Grid.Row>
<Text>Plan</Text>
<Text>Pro Monthly</Text>
</Grid.Row>
<Grid.Row>
<Text>Renews</Text>
<Text>1 June 2026</Text>
</Grid.Row>
</Grid>
</Host>When NOT to use this pattern:
- Free-flowing content where row sizes are intentionally independent (a feed of cards). Stack composition fits there.
Reference: Grid | SwiftUI
Pick Stack Direction by Content Flow — HStack for Rows, VStack for Columns
HStack arranges children horizontally with a single-row constraint; VStack does the opposite. Children inherit the cross-axis size from the stack — an HStack of long text gives each text view a sliver of horizontal space, while a VStack of the same gives each the full row width. Choose the direction that matches the natural growth axis of the content.
Incorrect (HStack for a list of subscription benefits — text gets cut off):
import { Host, HStack, Text } from '@expo/ui/swift-ui';
<Host matchContents>
<HStack spacing={12}>
<Text>Unlimited storage</Text>
<Text>Priority support</Text>
<Text>Early access to new features</Text>
</HStack>
</Host>Correct (VStack matches the column-list nature of the content):
import { Host, VStack, Text } from '@expo/ui/swift-ui';
<Host matchContents>
<VStack alignment="leading" spacing={12}>
<Text>Unlimited storage</Text>
<Text>Priority support</Text>
<Text>Early access to new features</Text>
</VStack>
</Host>Alternative (HStack with explicit baseline alignment for icon + label rows):
import { HStack, Image, Text } from '@expo/ui/swift-ui';
<HStack alignment="firstTextBaseline" spacing={8}>
<Image systemName="checkmark.circle.fill" />
<Text>Verified</Text>
</HStack>Reference: @expo/ui HStack source
Use LazyVStack or LazyHStack for Long Lists Inside ScrollView
VStack and HStack lay out all their children eagerly when mounted, regardless of whether they are visible. Inside a ScrollView, that means a 500-row list pays the full layout cost up front. LazyVStack/LazyHStack defer the layout of each child until it enters the viewport — initial scroll feels instant even for very long lists. Prefer List when you also need section headers, separators, or selection; reserve the lazy stacks for ScrollView-based custom layouts.
Incorrect (eager VStack inside ScrollView — frames every transaction at mount):
import { Host, ScrollView, VStack, Text } from '@expo/ui/swift-ui';
<Host useViewportSizeMeasurement style={{ flex: 1 }}>
<ScrollView>
<VStack alignment="leading" spacing={8}>
{transactions.map((t) => (
<Text key={t.id}>{t.merchant} — {t.amount}</Text>
))}
</VStack>
</ScrollView>
</Host>Correct (LazyVStack — only on-screen rows are laid out):
import { Host, ScrollView, LazyVStack, Text } from '@expo/ui/swift-ui';
<Host useViewportSizeMeasurement style={{ flex: 1 }}>
<ScrollView>
<LazyVStack alignment="leading" spacing={8}>
{transactions.map((t) => (
<Text key={t.id}>{t.merchant} — {t.amount}</Text>
))}
</LazyVStack>
</ScrollView>
</Host>When NOT to use this pattern:
- Very short lists (< 20 rows) where the lazy machinery costs more than the savings.
- Heterogeneous list shapes (sections, headers, selection). Use
Listinstead.
Reference: @expo/ui LazyVStack source
Set axes Explicitly on ScrollView for Horizontal or 2D Scrolling
ScrollView defaults to axes='vertical'. A horizontal carousel of cards rendered without an explicit axes='horizontal' simply won't scroll horizontally — the inner HStack will be clipped instead. Set axes to match the content's growth axis, or use axes='both' for 2D content like maps or large images. Pair with scrollIndicators modifier when the default per-axis indicators aren't appropriate.
Incorrect (default axes='vertical' — horizontal HStack of cards clips off-screen):
import { Host, ScrollView, HStack, Text } from '@expo/ui/swift-ui';
import { padding } from '@expo/ui/swift-ui/modifiers';
<Host matchContents>
<ScrollView>
<HStack spacing={12}>
{recommendations.map((r) => (
<Text key={r.id} modifiers={[padding({ all: 16 })]}>{r.title}</Text>
))}
</HStack>
</ScrollView>
</Host>Correct (axes='horizontal' — cards scroll sideways):
import { Host, ScrollView, HStack, Text } from '@expo/ui/swift-ui';
import { padding } from '@expo/ui/swift-ui/modifiers';
<Host matchContents>
<ScrollView axes="horizontal" showsIndicators={false}>
<HStack spacing={12}>
{recommendations.map((r) => (
<Text key={r.id} modifiers={[padding({ all: 16 })]}>{r.title}</Text>
))}
</HStack>
</ScrollView>
</Host>Reference: @expo/ui ScrollView source
Use Section.header and Section.footer Slots for Grouped Context
Section accepts either a title string (rendered as the default header style) or a custom header slot — and likewise a footer slot. Use the slot form when the header needs an icon, multiple weights of text, or live content; use the footer slot for the explanatory disclaimer text iOS Settings uses ("Email notifications are sent to admin@therocketgrowth.com"). Sections without these affordances feel less native.
Incorrect (footer-as-Text-row — disrupts grouped chrome, no italic muted styling):
import { Host, Form, Section, Toggle, Text } from '@expo/ui/swift-ui';
<Host useViewportSizeMeasurement style={{ flex: 1 }}>
<Form>
<Section title="Email digest">
<Toggle label="Weekly summary" isOn={weekly} onIsOnChange={setWeekly} />
<Text>Sent every Monday at 8 AM in your local timezone.</Text>
</Section>
</Form>
</Host>Correct (footer slot — caption text muted and below the section):
import { Host, Form, Section, Toggle, Text } from '@expo/ui/swift-ui';
<Host useViewportSizeMeasurement style={{ flex: 1 }}>
<Form>
<Section
title="Email digest"
footer={<Text>Sent every Monday at 8 AM in your local timezone.</Text>}>
<Toggle label="Weekly summary" isOn={weekly} onIsOnChange={setWeekly} />
</Section>
</Form>
</Host>Alternative (custom header with live state for sidebar collapsible sections, iOS 17+):
import { Section, HStack, Text, Image } from '@expo/ui/swift-ui';
<Section
header={
<HStack alignment="firstTextBaseline" spacing={6}>
<Image systemName="bell.badge" />
<Text>Pending invites</Text>
</HStack>
}
isExpanded={pendingOpen}
onIsExpandedChange={setPendingOpen}>
<InviteRow />
</Section>Reference: @expo/ui Section source
Wrap State-Driven Prop Changes in withAnimation for Smooth Transitions
When a prop change triggers a layout, color, or opacity transition, SwiftUI animates the change only if the state update happens inside withAnimation. The expo-ui withAnimation helper takes a callback and an animation config — it tags the state update so the native side runs the transition. Calling state setters directly produces an instant snap.
Incorrect (state setter called bare — sheet appears with no animation):
import { Host, Button, BottomSheet, Group } from '@expo/ui/swift-ui';
const [open, setOpen] = useState(false);
<Host matchContents>
<Button label="Open editor" onPress={() => setOpen(true)} />
<BottomSheet isPresented={open} onIsPresentedChange={setOpen}>
<Group><EditorContent /></Group>
</BottomSheet>
</Host>Correct (withAnimation wraps the setter — sheet eases in):
import { Host, Button, BottomSheet, Group, withAnimation } from '@expo/ui/swift-ui';
const [open, setOpen] = useState(false);
<Host matchContents>
<Button
label="Open editor"
onPress={() => withAnimation({ type: 'spring' }, () => setOpen(true))}
/>
<BottomSheet isPresented={open} onIsPresentedChange={setOpen}>
<Group><EditorContent /></Group>
</BottomSheet>
</Host>When NOT to use this pattern:
- One-off state updates that have no visible transition target (e.g., updating a hidden counter).
Reference: withAnimation in expo-ui
Order Modifiers from Inside-Out — Each Wraps the Previous
SwiftUI modifiers are applied in array order — each modifier wraps the previous result. padding then background paints the background outside the padding (full bleed). background then padding paints the background under the content but the padding pushes the content outside the painted area. Modifier order is semantically meaningful, not just stylistic.
Incorrect (padding outside background — content overflows the painted shape):
import { Host, Text } from '@expo/ui/swift-ui';
import { background, padding, cornerRadius } from '@expo/ui/swift-ui/modifiers';
<Host matchContents>
<Text modifiers={[padding({ all: 12 }), background('#0A84FF'), cornerRadius(8)]}>
Premium
</Text>
</Host>Correct (background inside padding — pill-shaped tag fits the text):
import { Host, Text } from '@expo/ui/swift-ui';
import { background, padding, cornerRadius, foregroundStyle } from '@expo/ui/swift-ui/modifiers';
<Host matchContents>
<Text
modifiers={[
foregroundStyle('white'),
padding({ horizontal: 12, vertical: 6 }),
background('#0A84FF'),
cornerRadius(8),
]}>
Premium
</Text>
</Host>When NOT to use this pattern:
- Modifiers that don't paint or size (e.g.,
disabled,accessibilityLabel) are order-insensitive. Place them anywhere.
Reference: SwiftUI view modifiers
Use the disabled Modifier — Don't Conditionally Render
Conditional rendering swaps the view out of the tree when the control becomes unavailable. SwiftUI's accessibility focus order then changes (VoiceOver users skip the gap), and any animation tied to the surrounding layout reruns. Applying disabled(true) keeps the control in place — it dims visually, blocks interaction, and remains accessible (VoiceOver still reads it, marked as "dimmed").
Incorrect (conditionally rendered — accessibility focus order shifts when button disappears):
import { Host, Button } from '@expo/ui/swift-ui';
<Host matchContents>
{form.isValid && (
<Button label="Submit" onPress={submit} />
)}
</Host>Correct (disabled modifier keeps the control mounted):
import { Host, Button } from '@expo/ui/swift-ui';
import { disabled } from '@expo/ui/swift-ui/modifiers';
<Host matchContents>
<Button label="Submit" onPress={submit} modifiers={[disabled(!form.isValid)]} />
</Host>When NOT to use this pattern:
- When the control should be hidden entirely (e.g., a "remove" button on an item that hasn't been added). Conditional render is correct there because the control has no meaning.
Reference: disabled modifier)
Use frame for Explicit Sizing — fixedSize to Opt Out of Flex
frame proposes a width/height to the SwiftUI layout engine — the view may be smaller if its content is smaller. fixedSize tells SwiftUI to use the view's intrinsic content size and ignore the parent's proposed flex space. These solve different problems: frame constrains; fixedSize opts out of expansion. Mixing them up causes text to truncate when it should wrap, or buttons to expand when they should hug their label.
Incorrect (Text wrapped to a tiny frame — truncates instead of wrapping):
import { Host, VStack, Text } from '@expo/ui/swift-ui';
import { frame } from '@expo/ui/swift-ui/modifiers';
<Host matchContents>
<VStack>
<Text modifiers={[frame({ width: 100 })]}>
Your subscription renews on the first of every month.
</Text>
</VStack>
</Host>Correct (frame caps width, Text wraps naturally):
import { Host, VStack, Text } from '@expo/ui/swift-ui';
import { frame } from '@expo/ui/swift-ui/modifiers';
<Host matchContents>
<VStack>
<Text modifiers={[frame({ maxWidth: 280 })]}>
Your subscription renews on the first of every month.
</Text>
</VStack>
</Host>Alternative (button should hug its label, not stretch to fill the HStack):
import { HStack, Button, Spacer } from '@expo/ui/swift-ui';
import { fixedSize } from '@expo/ui/swift-ui/modifiers';
<HStack>
<Button label="Save" onPress={save} modifiers={[fixedSize()]} />
<Spacer />
</HStack>Reference: frame modifier)
Import Modifiers from the @expo/ui/swift-ui/modifiers Subpath
@expo/ui exposes modifiers through a dedicated subpath export (@expo/ui/swift-ui/modifiers). Importing from the main @expo/ui/swift-ui entry pulls in component bindings the modifier file doesn't need, defeating the package's tree-shaking design. The subpath also makes intent explicit at the import site — readers see at a glance which symbols are modifiers vs components.
Incorrect (deep import that bypasses the package export map):
import { Button } from '@expo/ui/swift-ui';
import { padding, cornerRadius } from '@expo/ui/build/swift-ui/modifiers';Correct (subpath export — declared in package.json):
import { Button, Host } from '@expo/ui/swift-ui';
import { padding, cornerRadius, foregroundStyle } from '@expo/ui/swift-ui/modifiers';
<Host matchContents>
<Button label="Done" onPress={done} modifiers={[padding({ all: 12 }), cornerRadius(8)]} />
</Host>Alternative (importing only types when no runtime usage):
import type { ViewModifier } from '@expo/ui/swift-ui/modifiers';
function applyDestructiveLook(): ViewModifier[] {
return [/* shared modifier stack */];
}Reference: @expo/ui package.json exports
Use padding for Inner Space — frame for Outer Constraints
padding adds space inside a view's bounds, expanding the view to accommodate content plus padding. frame fixes the outer bounds, leaving content size to fit within. Using both unconditionally (or using frame when padding is intended) double-counts and produces views that are larger or smaller than expected.
Incorrect (frame with hardcoded numbers — must be manually recomputed if label length changes):
import { Host, Button } from '@expo/ui/swift-ui';
import { frame, background, cornerRadius } from '@expo/ui/swift-ui/modifiers';
<Host matchContents>
<Button
label="Submit application"
onPress={submit}
modifiers={[frame({ width: 180, height: 44 }), background('#0A84FF'), cornerRadius(8)]}
/>
</Host>Correct (padding lets the label drive width — easier to maintain):
import { Host, Button } from '@expo/ui/swift-ui';
import { padding, background, cornerRadius, foregroundStyle } from '@expo/ui/swift-ui/modifiers';
<Host matchContents>
<Button
label="Submit application"
onPress={submit}
modifiers={[
foregroundStyle('white'),
padding({ horizontal: 24, vertical: 12 }),
background('#0A84FF'),
cornerRadius(8),
]}
/>
</Host>When NOT to use this pattern:
- Fixed-size icons or controls that must align to a grid (toolbar items, tab bar icons). Use
framewith concrete dimensions.
Reference: padding modifier)
Apply Presentation Modifiers to Sheet Content, Not the Trigger
presentationDetents, presentationDragIndicator, presentationBackgroundInteraction, and interactiveDismissDisabled configure the presented view, not the view that triggered presentation. They must be applied to the sheet's content (in expo-ui, the Group inside BottomSheet's children), not to the button that opens the sheet. Misapplied, they silently no-op.
Incorrect (presentation modifiers on the trigger button — no effect):
import { Host, BottomSheet, Group, Button } from '@expo/ui/swift-ui';
import { presentationDetents, presentationDragIndicator } from '@expo/ui/swift-ui/modifiers';
<Host matchContents>
<Button
label="Edit"
onPress={() => setOpen(true)}
modifiers={[presentationDetents(['medium']), presentationDragIndicator('visible')]}
/>
<BottomSheet isPresented={open} onIsPresentedChange={setOpen}>
<Group>
<EditorContent />
</Group>
</BottomSheet>
</Host>Correct (modifiers attached to the sheet's content Group):
import { Host, BottomSheet, Group, Button } from '@expo/ui/swift-ui';
import { presentationDetents, presentationDragIndicator } from '@expo/ui/swift-ui/modifiers';
<Host matchContents>
<Button label="Edit" onPress={() => setOpen(true)} />
<BottomSheet isPresented={open} onIsPresentedChange={setOpen}>
<Group modifiers={[presentationDetents(['medium', 'large']), presentationDragIndicator('visible')]}>
<EditorContent />
</Group>
</BottomSheet>
</Host>Reference: BottomSheet expects Group with presentation modifiers
Apply Visual Modifications via the modifiers Prop, Not React Native style
SwiftUI views in @expo/ui/swift-ui are native views — they do not honour React Native's style prop for things like padding, corner radius, background, or shadow. Pass these through the modifiers array, where each entry maps to a SwiftUI view modifier on the native side. Setting style on a SwiftUI button does nothing visible.
Incorrect (style prop ignored — button has no padding, no radius):
import { Host, Button } from '@expo/ui/swift-ui';
<Host matchContents>
<Button
label="Continue"
onPress={continueCheckout}
style={{ padding: 16, borderRadius: 12, backgroundColor: '#0A84FF' }}
/>
</Host>Correct (modifiers reach the SwiftUI side):
import { Host, Button } from '@expo/ui/swift-ui';
import { padding, cornerRadius, background, foregroundStyle } from '@expo/ui/swift-ui/modifiers';
<Host matchContents>
<Button
label="Continue"
onPress={continueCheckout}
modifiers={[
padding({ all: 16 }),
background('#0A84FF'),
foregroundStyle('white'),
cornerRadius(12),
]}
/>
</Host>When NOT to use this pattern:
- The
styleprop onHostitself is honoured — that's the React Native side of the bridge. Use it to size the Host, not its SwiftUI children.
Reference: @expo/ui Host source — only Host accepts a React Native `style` prop; SwiftUI children don't
Use Alert Only for App-Blocking Critical Information
Apple's HIG positions Alert as the highest-modality presentation: it blocks all other interaction until dismissed. Reach for it only when the information is critical and the user must acknowledge it before continuing — auth errors, irreversible failures, system-level prompts. For everything else (filters, options, confirmations, supplementary information) use the lighter presentation: ConfirmationDialog, BottomSheet, Popover, Menu.
Incorrect (Alert for a non-critical filter choice — over-modal):
import { Host, Alert, Button } from '@expo/ui/swift-ui';
<Host matchContents>
<Alert title="Choose sort order" isPresented={open} onIsPresentedChange={setOpen}>
<Alert.Trigger>
<Button label="Sort" onPress={() => setOpen(true)} />
</Alert.Trigger>
<Alert.Actions>
<Button label="Newest" onPress={() => applySort('newest')} />
<Button label="Oldest" onPress={() => applySort('oldest')} />
</Alert.Actions>
</Alert>
</Host>Correct (ConfirmationDialog — lighter modality for option choices):
import { Host, ConfirmationDialog, Button } from '@expo/ui/swift-ui';
<Host matchContents>
<ConfirmationDialog title="Choose sort order" isPresented={open} onIsPresentedChange={setOpen}>
<ConfirmationDialog.Trigger>
<Button label="Sort" onPress={() => setOpen(true)} />
</ConfirmationDialog.Trigger>
<ConfirmationDialog.Actions>
<Button label="Newest" onPress={() => applySort('newest')} />
<Button label="Oldest" onPress={() => applySort('oldest')} />
</ConfirmationDialog.Actions>
</ConfirmationDialog>
</Host>When NOT to use this pattern:
- Genuinely blocking conditions: payment failure, auth expired, data-loss confirmation. Alert is the correct affordance there.
Reference: Modality | HIG
Wrap BottomSheet Content in Group to Attach Presentation Modifiers
BottomSheet's children must be a single SwiftUI view that accepts the modifiers array — Group is the standard "transparent container" that carries presentation modifiers (presentationDetents, presentationDragIndicator, interactiveDismissDisabled) to the sheet's content surface. Rendering bare children leaves the sheet with no way to attach those modifiers — it falls back to defaults (large detent only, no drag indicator).
Incorrect (bare children — presentation modifiers have nowhere to attach):
import { Host, BottomSheet, VStack, Text, Button } from '@expo/ui/swift-ui';
<Host matchContents>
<BottomSheet isPresented={open} onIsPresentedChange={setOpen}>
<VStack>
<Text>Are you sure?</Text>
<Button label="Confirm" onPress={confirm} />
</VStack>
</BottomSheet>
</Host>Correct (Group carries the presentation modifiers):
import { Host, BottomSheet, Group, VStack, Text, Button } from '@expo/ui/swift-ui';
import { presentationDetents, presentationDragIndicator } from '@expo/ui/swift-ui/modifiers';
<Host matchContents>
<BottomSheet isPresented={open} onIsPresentedChange={setOpen}>
<Group modifiers={[presentationDetents(['medium']), presentationDragIndicator('visible')]}>
<VStack>
<Text>Are you sure?</Text>
<Button label="Confirm" onPress={confirm} />
</VStack>
</Group>
</BottomSheet>
</Host>Reference: BottomSheet JSDoc — uses Group for presentation modifiers
Choose ContextMenu OR SwipeActions per Row — Not Both
A list row that responds to both long-press (context menu) and edge swipe (swipe actions) overloads the user's gesture vocabulary. They typically discover one and don't notice the other, but the existence of both makes the row's interaction surface feel inconsistent. HIG guidance: pick the gesture that fits the primary use. SwipeActions for fast frequent actions (archive, delete in a mail list). ContextMenu for rare contextual options (share, copy link, rename) — particularly when there are more than two actions.
Incorrect (both gestures attached to the same row — discoverability ambiguity):
import { List, ContextMenu, SwipeActions, Button, Text } from '@expo/ui/swift-ui';
<List>
<ContextMenu>
<ContextMenu.Trigger>
<SwipeActions>
<Text>Quarterly report</Text>
<SwipeActions.Actions edge="trailing">
<Button role="destructive" label="Delete" onPress={() => deleteFile(id)} />
</SwipeActions.Actions>
</SwipeActions>
</ContextMenu.Trigger>
<ContextMenu.Items>
<Button label="Share" onPress={() => share(id)} />
<Button label="Copy link" onPress={() => copyLink(id)} />
</ContextMenu.Items>
</ContextMenu>
</List>Correct (swipe for the frequent destructive action; context menu only if needed):
import { List, SwipeActions, Button, Text } from '@expo/ui/swift-ui';
<List>
<SwipeActions>
<Text>Quarterly report</Text>
<SwipeActions.Actions edge="trailing">
<Button role="destructive" label="Delete" onPress={() => deleteFile(id)} />
</SwipeActions.Actions>
</SwipeActions>
</List>Reference: SwipeActions in @expo/ui
Use DisclosureGroup for Collapsible Detail Inside Forms
DisclosureGroup is SwiftUI's expand/collapse primitive for detail rows: a label, a chevron, and a body of children that animates open. It works in any container — Form, List, VStack — unlike Section's isExpanded prop, which only applies inside a list with sidebar style. For optional advanced fields inside a settings form, DisclosureGroup is the right choice.
Incorrect (Section.isExpanded inside a regular Form — chevron doesn't appear):
import { Host, Form, Section, Toggle } from '@expo/ui/swift-ui';
<Host useViewportSizeMeasurement style={{ flex: 1 }}>
<Form>
<Section
title="Advanced"
isExpanded={advancedOpen}
onIsExpandedChange={setAdvancedOpen}>
<Toggle label="Telemetry" isOn={telemetry} onIsOnChange={setTelemetry} />
</Section>
</Form>
</Host>Correct (DisclosureGroup — works inside any Form):
import { Host, Form, Section, DisclosureGroup, Toggle } from '@expo/ui/swift-ui';
<Host useViewportSizeMeasurement style={{ flex: 1 }}>
<Form>
<Section title="Privacy">
<DisclosureGroup
label="Advanced"
isExpanded={advancedOpen}
onIsExpandedChange={setAdvancedOpen}>
<Toggle label="Telemetry" isOn={telemetry} onIsOnChange={setTelemetry} />
</DisclosureGroup>
</Section>
</Form>
</Host>Reference: @expo/ui DisclosureGroup source
Use Link for URL Navigation — Button for In-App Actions
Link renders a SwiftUI link that the system dispatches via the URL handlers — universal links route into the app, web URLs open in SafariViewController, mailto/tel links open the right system app. A Button with a custom Linking.openURL call bypasses all of that and skips system affordances like long-press preview. Use Link whenever the action is "go to this URL"; reserve Button for in-app actions.
Incorrect (Button + Linking.openURL — skips universal-link routing, no long-press preview):
import { Linking } from 'react-native';
import { Host, Button } from '@expo/ui/swift-ui';
<Host matchContents>
<Button label="Open documentation" onPress={() => Linking.openURL('https://docs.example.com')} />
</Host>Correct (Link — system handles URL routing and preview):
import { Host, Link } from '@expo/ui/swift-ui';
<Host matchContents>
<Link label="Open documentation" destination="https://docs.example.com" />
</Host>Alternative (custom label content for marketing-style links):
import { Link, HStack, Text, Image } from '@expo/ui/swift-ui';
import { foregroundStyle } from '@expo/ui/swift-ui/modifiers';
<Link destination="https://docs.example.com">
<HStack spacing={6}>
<Image systemName="book.closed" />
<Text modifiers={[foregroundStyle('accentColor')]}>Read the guide</Text>
</HStack>
</Link>Reference: @expo/ui Link source
Set onPrimaryAction on Menu to Disambiguate Tap from Long-Press
A SwiftUI Menu with onPrimaryAction adopts a hybrid affordance: tap runs the primary action immediately; long-press opens the menu chooser. This pattern is iOS-standard for "Send" buttons that also support "Send later", "Schedule send" — fast for the common path, accessible for variants. Without onPrimaryAction, every tap opens the menu, forcing an extra interaction for the most common case.
Incorrect (no primary action — sending an email takes two taps):
import { Host, Menu, Button } from '@expo/ui/swift-ui';
<Host matchContents>
<Menu label="Send" systemImage="paperplane.fill">
<Button label="Send now" onPress={sendNow} />
<Button label="Schedule" onPress={openScheduler} />
<Button label="Save draft" onPress={saveDraft} />
</Menu>
</Host>Correct (primary action — tap sends, long-press for variants):
import { Host, Menu, Button } from '@expo/ui/swift-ui';
<Host matchContents>
<Menu
label="Send"
systemImage="paperplane.fill"
onPrimaryAction={sendNow}>
<Button label="Schedule" onPress={openScheduler} />
<Button label="Save draft" onPress={saveDraft} />
</Menu>
</Host>When NOT to use this pattern:
- When there is no clear "primary" choice — every option in the menu is equally weighted. Plain Menu is correct then.
Reference: @expo/ui Menu source
Use ShareLink for System Share — Not a Custom Sheet
ShareLink opens the iOS system share sheet — the canonical surface for sharing URLs, files, and text. The system populates it with AirDrop targets, installed app extensions, copy-to-clipboard, and AirPlay. Building a custom share sheet with BottomSheet and a list of buttons can only cover what you hardcode and misses every third-party share extension the user has installed. Use ShareLink whenever the share target is a URL or file the system can dispatch.
Incorrect (custom share sheet — misses AirDrop, system extensions):
import { Host, BottomSheet, Group, Button, VStack } from '@expo/ui/swift-ui';
<Host matchContents>
<BottomSheet isPresented={open} onIsPresentedChange={setOpen}>
<Group>
<VStack>
<Button label="Copy link" onPress={() => copyToClipboard(itemUrl)} />
<Button label="Email" onPress={() => openMail(itemUrl)} />
<Button label="Message" onPress={() => openMessages(itemUrl)} />
</VStack>
</Group>
</BottomSheet>
</Host>Correct (ShareLink — full system share sheet, every installed extension):
import { Host, ShareLink } from '@expo/ui/swift-ui';
<Host matchContents>
<ShareLink
item="https://app.example.com/listing/abc123"
subject="Lakeside cottage — 3 nights in June"
preview={{ title: 'Lakeside cottage', image: 'https://app.example.com/img/abc123.jpg' }}
/>
</Host>Alternative (async item resolution — resolves the URL only when the user taps share):
<ShareLink
getItemAsync={async () => api.createShareableLink(listingId)}
preview={{ title: 'Lakeside cottage', image: thumbnailUrl }}
/>Reference: @expo/ui ShareLink source
Set TabView Appearance via tabViewStyle Modifier
TabView accepts the same modifier-driven style pattern as Picker. Without tabViewStyle, it falls back to automatic, which iOS resolves contextually but may not match the intent — particularly on iPad where you typically want sidebarAdaptable. Specify the modifier to pin the appearance.
Incorrect (no tabViewStyle — falls back to platform default, no sidebar on iPad):
import { Host, TabView } from '@expo/ui/swift-ui';
<Host useViewportSizeMeasurement style={{ flex: 1 }}>
<TabView selection={tab} onSelectionChange={setTab}>
<TabView.Tab value="feed">
<FeedScreen />
</TabView.Tab>
<TabView.Tab value="inbox">
<InboxScreen />
</TabView.Tab>
</TabView>
</Host>Correct (sidebarAdaptable — sidebar on iPad, bottom tabs on iPhone):
import { Host, TabView } from '@expo/ui/swift-ui';
import { tabViewStyle } from '@expo/ui/swift-ui/modifiers';
<Host useViewportSizeMeasurement style={{ flex: 1 }}>
<TabView
selection={tab}
onSelectionChange={setTab}
modifiers={[tabViewStyle({ type: 'sidebarAdaptable' })]}>
<TabView.Tab value="feed">
<FeedScreen />
</TabView.Tab>
<TabView.Tab value="inbox">
<InboxScreen />
</TabView.Tab>
</TabView>
</Host>Alternative (swipeable page-style for onboarding):
<TabView modifiers={[tabViewStyle({ type: 'page', indexDisplayMode: 'always' })]}>
<TabView.Tab value="welcome"><WelcomeSlide /></TabView.Tab>
<TabView.Tab value="permissions"><PermissionsSlide /></TabView.Tab>
<TabView.Tab value="ready"><ReadySlide /></TabView.Tab>
</TabView>When NOT to use this pattern:
- For routed bottom-tab navigation across full-screen routes, prefer
expo-router/unstable-native-tabs— that is a navigation primitive, not a UI primitive.
Reference: @expo/ui TabView source
Choose selection (Controlled) or defaultSelection (Uncontrolled) — Not Both
TabView (and similar selection-based components) accept either a controlled selection paired with onSelectionChange, or an uncontrolled defaultSelection that the native view manages internally. Passing both is a code smell — the component silently ignores defaultSelection and is driven by selection. Decide up front whether the parent owns the selection or the native view does, and pick one prop.
Incorrect (both props supplied — defaultSelection is silently ignored):
import { Host, TabView } from '@expo/ui/swift-ui';
const [tab, setTab] = useState('feed');
<Host useViewportSizeMeasurement style={{ flex: 1 }}>
<TabView
selection={tab}
defaultSelection="feed"
onSelectionChange={setTab}>
<TabView.Tab value="feed"><FeedScreen /></TabView.Tab>
<TabView.Tab value="inbox"><InboxScreen /></TabView.Tab>
</TabView>
</Host>Correct (controlled — parent drives selection):
import { Host, TabView } from '@expo/ui/swift-ui';
const [tab, setTab] = useState('feed');
<Host useViewportSizeMeasurement style={{ flex: 1 }}>
<TabView selection={tab} onSelectionChange={setTab}>
<TabView.Tab value="feed"><FeedScreen /></TabView.Tab>
<TabView.Tab value="inbox"><InboxScreen /></TabView.Tab>
</TabView>
</Host>Alternative (uncontrolled — native view manages selection internally):
<TabView defaultSelection="feed">
<TabView.Tab value="feed"><FeedScreen /></TabView.Tab>
<TabView.Tab value="inbox"><InboxScreen /></TabView.Tab>
</TabView>Reference: @expo/ui TabView source — selection vs defaultSelection
Guard iOS 26-Only Features With a Platform Version Check
@expo/ui JSDoc annotates platform availability per modifier and prop: @platform ios 17.0+, @platform ios 18.0+, and iOS 26 surface for Liquid Glass and tab-bar accessory APIs. On older iOS versions, those modifiers either no-op or throw depending on the underlying SwiftUI signature. Guard with React Native's Platform.Version check before applying.
Incorrect (glassEffect on iOS 19 device — modifier no-ops, no fallback):
import { Host, Image } from '@expo/ui/swift-ui';
import { glassEffect } from '@expo/ui/swift-ui/modifiers';
<Host matchContents>
<Image systemName="play.fill" modifiers={[glassEffect()]} />
</Host>Correct (apply glass only on iOS 26+):
import { Platform } from 'react-native';
import { Host, Image } from '@expo/ui/swift-ui';
import { glassEffect, background, cornerRadius } from '@expo/ui/swift-ui/modifiers';
const isIOS26 = Platform.OS === 'ios' && parseInt(String(Platform.Version), 10) >= 26;
<Host matchContents>
<Image
systemName="play.fill"
modifiers={isIOS26 ? [glassEffect()] : [background('#1C1C1E'), cornerRadius(8)]}
/>
</Host>Alternative (TextField selection prop requires iOS 18+ — gate the prop, not just the modifier):
const supportsSelection = parseInt(String(Platform.Version), 10) >= 18;
const selection = supportsSelection ? selectionState : undefined;
<TextField text={text} selection={selection} />Reference: @expo/ui JSDoc @platform annotations
Use TextFieldRef for Imperative Focus and Selection
TextField and SecureField expose a ref of type TextFieldRef/SecureFieldRef with focus(), blur(), clear(), setText(), and setSelection() methods. These cover the imperative cases declarative props can't reach: focusing on mount, jumping the cursor after a "paste from clipboard" action, clearing on submit. Wiring a text useNativeState for the value and a ref for the imperative handle is the standard combination.
Incorrect (declarative-only — no way to clear on submit, no programmatic focus):
import { Host, TextField, Button, useNativeState } from '@expo/ui/swift-ui';
const message = useNativeState('');
<Host matchContents>
<TextField text={message} placeholder="Reply" />
<Button
label="Send"
onPress={() => {
sendMessage(message.value);
message.value = '';
}}
/>
</Host>Correct (ref provides clear + focus — flow recovers cleanly):
import { useRef } from 'react';
import { Host, TextField, Button, useNativeState, type TextFieldRef } from '@expo/ui/swift-ui';
const message = useNativeState('');
const inputRef = useRef<TextFieldRef>(null);
<Host matchContents>
<TextField ref={inputRef} text={message} placeholder="Reply" autoFocus />
<Button
label="Send"
onPress={async () => {
await sendMessage(message.value);
await inputRef.current?.clear();
await inputRef.current?.focus();
}}
/>
</Host>Alternative (jump cursor after inserting a mention):
await inputRef.current?.setText(`${message.value}@${mention.handle} `);
await inputRef.current?.setSelection(message.value.length + mention.handle.length + 2, 0);Reference: @expo/ui TextFieldRef
Related skills
FAQ
What does expo-ui do?
expo-ui is a Claude Code skill for ai & agent building. It helps developers move faster with AI-assisted coding.
When should I use expo-ui?
When you need to helps with ai & agent building tasks during ai-assisted development, or when expo-ui is a claude code skill for ai & agent building. it helps developers move faster with ai-assisted coding.
What are the main capabilities?
expo-ui; AI & Agent Building; AI-coding skill.