Template and components that I use in Expo Router apps that are generally optimized for iOS, dark mode, and servers. Main part is the forms which look like Apple's settings app. These should be replaced with proper SwiftUI/Jetpack Compose in the future, but it's still useful to have JS versions for platforms that don't have native support.
bacomponents-animation.mp4


For best results, just copy the files to another project. Here are the other deps:
bunx expo install expo-haptics expo-symbols expo-blur expo-web-browser @bacons/apple-colors vaul @react-native-segmented-control/segmented-control @react-native-community/datetimepicker
You can also just bootstrap a project with this repo:
bunx create-expo -t https://github.com/EvanBacon/expo-router-forms-components
Use the correct stack header settings for peak iOS defaults:
import Stack from "@/components/ui/Stack";
import ThemeProvider from "@/components/ui/ThemeProvider";
export default function Layout() {
return (
<ThemeProvider>
<Stack
screenOptions={{
title: "🥓 Bacon",
}}
/>
</ThemeProvider>
);
}
Use headerLargeTitle: true
to get the large header title.
Use <Form.Link headerRight>
to add a link to the right side of the header with correct hit size and padding for Forms. The default color will be system blue.
<Stack
screenOptions={{
title: "🥓 Bacon",
headerRight: () => (
<Form.Link headerRight href="/info">
Info
</Form.Link>
),
}}
/>
This stack uses vaul
on web to make modal
look like a native modal.
Works on web too!


You can open routes as a bottom sheet on iOS:
<Stack.Screen name="info" sheet />
This sets custom options for React Native Screens:
{
presentation: "formSheet",
gestureDirection: "vertical",
animation: "slide_from_bottom",
sheetGrabberVisible: true,
sheetInitialDetentIndex: 0,
sheetAllowedDetents: [0.5, 1.0],
}
- Use
sheetAllowedDetents
to change the snap points of the sheet. - Change the corder radius with
sheetCornerRadius: 48
.
The custom tabs adds blurry backgrounds and haptics on iOS. You can also use the shortcut systemImage
to set the icon.
import ThemeProvider from "@/components/ui/ThemeProvider";
import Tabs from "@/components/ui/Tabs";
export default function Layout() {
return (
<ThemeProvider>
<Tabs>
<Tabs.Screen name="(index)" systemImage="house.fill" title="Home" />
<Tabs.Screen name="(info)" systemImage="brain.fill" title="Info" />
</Tabs>
</ThemeProvider>
);
}
Start lists with a <Form.List>
and add sections with <Form.Section>
. Setting navigationTitle="Settings"
will update the title of the stack header.
<Form.List navigationTitle="Settings">
<Form.Section title="Developer">
<Form.Link target="_blank" href="https://evanbacon.dev">
Evan Bacon
</Form.Link>
<Form.Link href="https://evanbacon.dev">Evan Bacon in browser</Form.Link>
</Form.Section>
</Form.List>
Internals
Form list is a wrapper around a scroll view with some extra styles and helpers.
<BodyScrollView
contentContainerStyle={{
padding: 16,
gap: 24,
}}
>
<Form.Section title="Developer">
<Form.Link target="_blank" href="https://evanbacon.dev">
Evan Bacon
</Form.Link>
<Form.Link href="https://evanbacon.dev">Evan Bacon in browser</Form.Link>
</Form.Section>
</BodyScrollView>
All top-level children will become items.
Add title
and footer
to a section. These can be strings or React nodes.
import * as AC from "@bacons/apple-colors";
<Form.Section
title="Header"
footer={
<Text>
Help improve Search by allowing Apple to store the searches you enter into
Safari, Siri, and Spotlight in a way that is not linked to you.{"\n\n"}
Searches include lookups of general knowledge, and requests to do things like
play music and get directions.{"\n"}
<Link style={{ color: AC.link }} href="/two">
About Search & Privacy...
</Link>
</Text>
}
>
<Text>Default</Text>
</Form.Section>;
Form.Text
has extra types forhint
and custom styles to have adaptive colors for dark mode. The font size is also larger to match the Apple defaults.- Adds the
systemImage
prop to append an SF Symbol icon before the text. The color of this icon will adopt the color of the text style.
<Form.Text>Hey</Form.Text>
Add a hint to the right-side of the form item:
<Form.Text hint="right">Left</Form.Text>
Add a custom press handler to the form item:
<Form.Text
onPress={() => {
console.log("Pressed");
}}
>
Press me
</Form.Text>
You can also use <Button />
from React Native similar to SwiftUI:
<Button title="Open" onPress={() => console.log("Pressed")} />
Open with in-app browser using target="_blank"
(only works when the href
is an external URL):
<Form.Link target="_blank" href="https://evanbacon.dev">
Evan Bacon
</Form.Link>
Add a hint to the right-side of the form item:
<Form.Link hint="123" href="/foo">
Evan Bacon
</Form.Link>
Alternatively, use an HStack-type system instead of the hint
hack:
<Form.HStack>
<Form.Text>Foo</Form.Text>
<View style={{ flex: 1 }} />
<Form.Text style={Form.FormFont.secondary}>Bar</Form.Text>
</Form.HStack>
Add a quick icon before the text:
<Form.Link href="/two" systemImage="person.fill.badge.plus">
Evan Bacon
</Form.Link>
Customize the color, size, etc:
<Form.Link
href="/two"
systemImage={{
name: "person.fill.badge.plus",
color: AC.systemBlue,
size: 128,
}}
>
Evan Bacon
</Form.Link>
Beautifully display a key/value pair with the hint=""
property. This can also be created manually for extra customization.

The key here is to use flexShrink
to support floating to the right, then wrapping correctly when the text gets too long.
Use flexWrap
to position the text below the title when it gets too long instead of shifting the title down vertically.
<Form.Section title="Right text">
<Form.Text hint="Long hint with extra content that should float below the content">
Hint
</Form.Text>
{/* Custom */}
<Form.HStack>
<Form.Text>Opening</Form.Text>
{/* Spacer */}
<View style={{ flex: 1 }} />
{/* Right */}
<Form.Text style={{ flexShrink: 1, color: AC.secondaryLabel }}>
Long list of text that should wrap around when it gets too long
</Form.Text>
</Form.HStack>
{/* Custom with wrap-below */}
<Form.HStack style={{ flexWrap: "wrap" }}>
<Form.Text>Opening</Form.Text>
{/* Spacer */}
<View style={{ flex: 1 }} />
{/* Right */}
<Form.Text style={{ flexShrink: 1, color: AC.secondaryLabel }}>
Long list of text that should wrap around when it gets too long
</Form.Text>
</Form.HStack>
</Form.Section>
Add a list item with an image and text + description combo:
<Form.HStack style={{ gap: 16 }}>
<Image
source={{ uri: "https://github.com/evanbacon.png" }}
style={{
aspectRatio: 1,
height: 48,
borderRadius: 999,
}}
/>
<View style={{ gap: 4 }}>
<Form.Text>Evan's iPhone</Form.Text>
<Form.Text style={Form.FormFont.caption}>This iPhone 16 Pro Max</Form.Text>
</View>
{/* Spacer */}
<View style={{ flex: 1 }} />
<Image source="sf:person.fill.badge.plus" size={24} />
</Form.HStack>
Create a linkable version like this:
<Form.Link href="/two">
<View style={{ gap: 4 }}>
<Form.Text>Evan's iPhone</Form.Text>
<Form.Text style={Form.FormFont.caption}>This iPhone 16 Pro Max</Form.Text>
</View>
</Form.Link>
You can add a toggle/switch item using the Form.Toggle
component (extends Form.Text
):
const [on, setOn] = useState(false);
return (
<Form.Toggle value={on} onValueChange={setOn}>
Wi-Fi
</Form.Toggle>
);
This is similar to using the Switch
component as a hint
on text but with reduced padding:
<Form.Section itemStyle={{ paddingVertical: 8 }}>
<Form.Text hint={<Switch value={on} onValueChange={setOn} />}>
Wi-Fi
</Form.Text>
</Form.Section>
I've added a fork of React Native Web's Switch
which looks more like iOS than the old Material design. This makes it a bit easier to change the theme.
import { Switch } from "@components/ui/Switch";
Web not supported.
Select dates and time using the Form.DatePicker
component (extends Form.Text
):
<Form.DatePicker value={new Date()} onChange={(e) => e.nativeEvent.timestamp}>
Birthday
</Form.DatePicker>
Select time:
<Form.DatePicker value={new Date()} mode="time">
Schedule a time
</Form.DatePicker>
Select both date and time (iOS only):
<Form.DatePicker value={new Date()} mode="datetime">
Schedule a time
</Form.DatePicker>
Make it look cool and monochrome with: accentColor={AC.label}
Both of the following yield similar results. Dark/light theme, correct padding and font sizes for text inputs.
<Form.Section>
<TextInput placeholder="First name" />
<Form.TextField placeholder="Last name" />
</Form.Section>
The default listStyle
is "auto"
but you can access the old-style with "grouped"
:
<Form.List listStyle="grouped">
<Form.Section title="Developer">
<Form.Link target="_blank" href="https://evanbacon.dev">
Evan Bacon
</Form.Link>
</Form.Section>
</Form.List>
Data fetching can be done by using the nearest Form.List
for pull-to-refresh functionality.
function App() {
return (
<Form.List>
<Data />
</Form.List>
);
}
function Data() {
const [data, setData] = useState(
// Read some cached data if any exists.
() => localStorage.getItem("data")
);
// This will be called when the user pulls to refresh.
Form.useListRefresh(async () => {
// Perform data fetching...
const data = await fetchData();
localStorage.setItem("data", data);
setData(data);
});
return <Text>Data</Text>;
}
You can trigger the refresh programmatically with the callback from Form.useListRefresh()
:
const refresh = Form.useListRefresh();
// Register a button to refresh the list...
<Stack.Screen
options={{
headerRight: () => (
<Form.Text bold onPress={refresh}>
Refresh
</Form.Text>
),
}}
/>;
This primitive can be extended to create comprehensive refreshing per-list.
Be sure to use @bacons/apple-colors
for high-quality P3 colors.
Use the Image
component to use Apple's SF Symbols.
import { Image } from "@/components/ui/img";
<Image source="sf:star" size={24} tintColor={AC.systemBlue} />;
The <Image />
component is a wrapper around the expo-image
package which supports SF Symbols and SVGs. The SF symbols must be prefixed with sf:
to load them correctly. Use size
to set the font size of the symbol, and tintColor
to set the color of the symbol.
Avoid using <StatusBar>
on iOS as the system has built-in support for changing the color better than most custom solutions. Enable OS-changing with:
{
"expo": {
"userInterfaceStyle": "automatic",
"ios": {
"infoPlist": {
"UIViewControllerBasedStatusBarAppearance": true,
}
}
}
}
This won't work as expected in Expo Go. Use a dev client to understand the behavior better.


npx expo install @react-native-segmented-control/segmented-control
For tabbed content that doesn't belong in the router, use the Segment
component:
import {
Segments,
SegmentsList,
SegmentsContent,
SegmentsTrigger,
} from "@/components/ui/Segments";
export default function Page() {
return (
<Segments defaultValue="account">
<SegmentsList>
<SegmentsTrigger value="account">Account</SegmentsTrigger>
<SegmentsTrigger value="password">Password</SegmentsTrigger>
</SegmentsList>
<SegmentsContent value="account">
<Text>Account Section</Text>
</SegmentsContent>
<SegmentsContent value="password">
<Text>Password Section</Text>
</SegmentsContent>
</Segments>
);
}
This can be used with React Server Components as the API is entirely declarative.


Add a toggle switch item using hint
and Switch
from React Native:
<Form.Text hint={<Switch />}>Label</Form.Text>
You can also build the item manually for more customization:
<Form.HStack>
<Form.Text>Label</Form.Text>
<View style={{ flex: 1 }} />
<Switch />
</Form.HStack>
Similar to SwiftUI's
ContentUnavailableView
.
Simulator.Screen.Recording.-.iPhone.16.Pro.Max.-.2025-01-15.at.15.28.05.mp4
For empty states, use the <ContentUnavailable />
component.
There are three main uses:
- No search results:
<ContentUnavailable search />
. Use search as a string to show the invalid query<ContentUnavailable search="my query" />
. - No internet connection:
<ContentUnavailable internet />
. This shows an animated no connection screen. - Everything else. Use
title
,description
, andsystemImage
to customize the message.<ContentUnavailable title="No content" systemImage="car" description="Could not find car" />
Other info:
- The
systemImage
can be the name of an SF Symbol or a React node. This is useful for custom/animated icons. actions
can be provided for a list of buttons to render under the content, e.g.<ContentUnavailable internet actions={<Button title="Refresh" />} />
This project uses a custom suspensy font loading system to show a fallback screen while the fonts are loading. Fonts are only async in development when connected to a server, in production they are embedded in the app.
<Suspense fallback={<SplashFallback />}>
<AsyncFont src={SourceCodePro_400Regular} fontFamily="Source Code Pro" />
<Stack />
</Suspense>
The metro.config.js
is configured to load SVGs as React components. This ensures they are optimized for web / native / React Server Components. I'd prefer if Metro had support for import qualifiers like import SVGImage from "./image.svg?jsx"
, so we could still use SVGs as assets for expo-image
, but this is a good workaround for now.