crispy: iOS-style form components for Expo Router apps
Free UI components I use for building Expo Router apps
At a glance
- What is it?
- crispy is a set of free React Native components that make an Expo Router app look like the iOS settings app, with form lists, sections, SF Symbol icons, bottom sheets and dark mode colours. It is not published as a package, the recommended install is to copy the files into your project, and its own README says these should eventually be native SwiftUI and Compose components instead.
- Who is it for?
- crispy fits an Expo Router app that needs an iOS-looking settings screen this week and would rather not write the form primitives by hand, and it fits a project that intends to replace them with native code later.
- Can I use it commercially?
- Not without permission. GitHub finds no licence file in the repository, and without a licence all rights are reserved by default: you may read the code but not reuse it. Check the README, or ask the authors, before using it.
- Is it still maintained?
- Yes. The repository last received commits 74 days ago.
- What is it written in?
- Mainly TypeScript, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on October 23, 2026, and from our analysis. They are not legal advice.
Editorial analysis
The install is a copy, not a dependency
The README is direct about how this is meant to be used. For best results, just copy the files to another project. Here are the other deps.
That one command installs the peer packages the components expect:
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/datetimepickerThe dependency list itself is a description of what the components do. expo-symbols gives you SF Symbols, expo-haptics gives you the taps, expo-blur gives you the frosted backgrounds, vaul gives you a bottom sheet that works on the web, @bacons/apple-colors gives you Apple's system colour values, and the segmented control and the date-time picker fill in the two native controls that would otherwise need building.
The alternative to copying is bootstrapping a whole project from the template:
bunx create-expo -t https://github.com/EvanBacon/expo-router-forms-componentsThe homepage is crispy.expo.app, and the repository description is free UI components I use for building Expo Router apps. The default branch is main, the primary language is TypeScript, and the last push was on 2026-08-11.
The copy-the-files model has an obvious consequence. There is no package version to depend on and no changelog to follow, so when the author changes a component you either re-copy the file or fork the code. That is a deliberate trade of upgrade convenience for control over the code in your repository.
Forms built to look like the iOS settings app
The README calls the main part of the project the forms which look like Apple's settings app, and the components follow the structure of that screen. A list starts with Form.List, sections are added with Form.Section, and all top-level children of a section become items. Setting navigationTitle on the list updates the title of the stack header, so the form and the navigation bar agree without extra wiring:
<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>A section takes a title and a footer, and both can be strings or React nodes, which is what allows the long explanatory footer with a link in it that the README shows in the Form Sections section.
The items themselves are the second layer. Form.Text is the basic label, Form.Link is a tappable row that navigates or opens a URL, and there is a Button component the README describes as similar to SwiftUI. Two smaller pieces of polish are worth naming. Form.Link takes a target of _blank, which opens the URL in the in-app browser and only works when the href is an external URL. And the hint property displays a key or value pair on the right side of the row, with the README offering Form.HStack as a manual alternative when you want more control than the hint gives you:
<Form.HStack>
<Form.Text>Foo</Form.Text>
<View style={{ flex: 1 }} />
<Form.Text style={Form.FormFont.secondary}>Bar</Form.Text>
</Form.HStack>The acknowledgement that these are imitations is in the README itself. They should be replaced with proper SwiftUI and Jetpack Compose in the future, but it is still useful to have JS versions for platforms that don't have native support.
Dark mode, font sizes and SF Symbols are part of the component
The details that make a settings screen feel native are small, and the README names them for Form.Text specifically. It has extra types for hint and custom styles to have adaptive colors for dark mode, and the font size is also larger to match the Apple defaults.
That last point is the kind of thing that separates a screen that looks right from one that looks almost right. The iOS settings rows use a specific type size and weight, and a React Native Text default will be visibly smaller, so the component corrects it rather than leaving it to you.
The second detail is the systemImage prop, which appends an SF Symbol icon before the text. The README notes that the color of this icon will adopt the color of the text style, which is why the icon does not need its own colour configuration and why it stays legible in dark mode.
The icon can be more than a name. In the Form Link section the README shows the same systemImage used with an object rather than a string, which is how you set a colour and a size:
<Form.Link
href="/two"
systemImage={{
name: "person.fill.badge.plus",
color: AC.systemBlue,
size: 128,
}}
>
Evan Bacon
</Form.Link>The colours come from the @bacons/apple-colors package, imported as AC in the example, so systemBlue and the rest of Apple's palette are named constants rather than hex values you copy by hand.
The stack header and the bottom sheet carry the iOS feel
Two parts of the screen are the layout rather than the content, and both need the platform's own conventions.
The first is the header. The README says to use the correct stack header settings for peak iOS defaults, which means wrapping the navigation in a ThemeProvider and using the project's own Stack component with screen options. Setting headerLargeTitle to true gets the large title, and Form.Link accepts a headerRight prop to put a link on the right side of the header with the correct hit size and padding for forms, defaulting to the system blue.
The second is the sheet. A route can be opened as a bottom sheet on iOS with a single prop on the screen:
<Stack.Screen name="info" sheet />and that sets custom options for React Native Screens. The options the README lists are the full form sheet configuration:
{
presentation: "formSheet",
gestureDirection: "vertical",
animation: "slide_from_bottom",
sheetGrabberVisible: true,
sheetInitialDetentIndex: 0,
sheetAllowedDetents: [0.5, 1.0],
}Two of those are adjustable by design. sheetAllowedDetents changes the snap points of the sheet, and the README also mentions changing the corner radius with sheetCornerRadius set to 48, which is the value that matches the system's rounded sheet.
That the sheet works on the web too is the reason vaul is in the dependency list. The README says the stack uses vaul on web to make modal look like a native modal, which is the difference between a web build that feels like a phone app and one that shows a browser dialog.
Tabs add blur and haptics rather than just icons
The tabs component is the smaller of the two navigation pieces, and the README describes it in one sentence: the custom tabs adds blurry backgrounds and haptics on iOS. You can also use the shortcut systemImage to set the icon.
The layout is the same shape as the stack, a ThemeProvider wrapping the tabs, with each screen declared by name:
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>
);
}The blur and the haptic feedback are the parts that are not obvious from a component list. A blurred tab bar is one of the things that makes a screen read as iOS rather than as a generic React Native app, and the tab press feedback is what the eye expects even when the user never looks at it. Both need expo-blur and expo-haptics, which is why those are in the peer dependency list even if your forms never touch them.
The systemImage shortcut uses the same SF Symbol vocabulary as the form items, so a tab bar icon and a row icon come from the same source and match.
The repository is an app template rather than a library
The package.json describes an Expo application, not a publishable library. The name is bacomponents, the version is 1.0.0, the entry point is expo-router/entry, and the scripts are the ones an Expo app needs:
"scripts": {
"start": "expo start",
"android": "expo run:android",
"ios": "expo run:ios",
"web": "expo start --web",
"lint": "expo lint",
"deploy:ios": "npx testflight",
"deploy:web": "expo export -p web && npx eas-cli@latest deploy"
},The dependency list is where the weight is. There is expo at 55.0.0-preview.5 with a long tail of expo-* packages at 55.0.1, expo-router at 55.0.0-beta.2, the Radix UI React packages for the web build, @bacons/apple-colors and @bacons/mdx, the Tailwind PostCSS plugin, clsx, and agent-browser. The manifest also pins two things through resolutions, lightningcss at 1.30.1 and @expo/metro-runtime at 55.0.1, which is the sort of pinning you do when a preview release of the framework has a dependency that has moved underneath it.
The repository layout matches a working app rather than a library package. There is an src directory, a docs directory, an assets directory, and the configuration files you would expect: app.json and eas.json for the Expo application and build configuration, babel.config.js, metro.config.js and a metro.transformer.js, postcss.config.mjs, eslint.config.js, tsconfig.json, global.d.ts and a nativewind-env.d.ts. There is also a .claude directory, a CLAUDE.md file and a .mcp.json at the root, which together suggest the project is worked on with an AI assistant in the loop.
The lockfile is bun.lock, so the toolchain is Bun even though the scripts call expo directly.
Editorial conclusion
crispy fits an Expo Router app that needs an iOS-looking settings screen this week and would rather not write the form primitives by hand, and it fits a project that intends to replace them with native code later. It is the wrong choice if you need a versioned package with a changelog you can pin, because there is no published package and the recommended install is copying files, and the wrong choice if you need Android or web parity, since the design is explicitly optimised for iOS. Verify first which of the listed peer dependencies you actually need, since the components assume expo-haptics, expo-symbols, expo-blur, vaul and the Apple colour set are present, and check that expo-router and the Expo SDK versions in your app match the ones this template targets.
Frequently asked questions
How do I install the crispy components?
The README recommends copying the files into your own project, then installing the peer dependencies with 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 bootstrap a whole project with bunx create-expo -t https://github.com/EvanBacon/expo-router-forms-components.
What components does crispy provide?
Form.List, Form.Section, Form.Text, Form.Link, Form.HStack and a Button, plus a Stack, a Tabs component and a ThemeProvider. Form lists hold sections, top-level children of a section become items, and sections take a title and a footer that can be strings or React nodes.
How do I open a route as a bottom sheet with crispy?
Add the sheet prop to the screen, for example <Stack.Screen name="info" sheet />. That sets React Native Screens options for a form sheet presentation with a vertical gesture, a slide from bottom animation, a visible grabber and allowed detents of 0.5 and 1.0, and the corner radius can be changed with sheetCornerRadius.
Does crispy work on the web and on Android?
The README says the components are generally optimized for iOS, dark mode and servers, and that the bottom sheet works on the web too because the stack uses vaul there to make a modal look like a native modal. The README also states that these should be replaced with proper SwiftUI and Jetpack Compose in the future, but remain useful for platforms without native support.
Is crispy published as a package?
The repository is an Expo app template whose package.json names it bacomponents with the expo-router/entry main field, and the README's install instructions tell you to copy the files into your own project. The repository has no GitHub releases and the last push was on 2026-08-11.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/evanbacon-crispy)Was this useful?