About this course
<p>If you've built a couple of React Native apps, the following probably feels familiar. You start with a <code>colors.ts</code> file and a simple <code>Button</code> component. Everything looks fine at first. Then the design changes a few times, and that small color file starts growing. Your Button also picks up another prop every time someone asks for a slightly different style.</p>
<p>The setup works at first, but after a few design changes, even a small update can mean checking several files across the project.</p>
<p>Here's what a typical color file looks like after a while:</p>
<pre><code class="language-tsx">export const colors = {
primary: '#6e4836',
primaryPressed: '#5a3a2b',
textPrimary: '#261d16',
textSecondary: '#72665e',
// and forty more
};
</code></pre>
<p>And here's what a typical <code>Button</code> looks like after a few redesigns:</p>
<pre><code class="language-tsx"><Button
variant="primary"
size="md"
fullWidth
loading={busy}
containerStyle={styles.checkoutButton}
labelStyle={styles.checkoutLabel}
/>
</code></pre>
<p>These are simplified examples, but you get the idea. The color object keeps growing, and the Button collects more props to handle every new design request.</p>
<p>Each change seems reasonable when you make it. But the annoying part comes later, when changing one design decision means checking several files to see that nothing else has changed unexpectedly.</p>
<p>Part of the problem is how many component libraries are distributed. Their code lives in <code>node_modules</code>, so you usually customize them through the props and options they expose. When the library doesn't cover your exact use case, you add another prop or override. Keep doing that and the component can become harder to maintain than the original problem you were trying to solve.</p>
<p>This is one reason I like shadcn/ui's approach. You don't install a traditional component package and treat its components as fixed. The CLI copies the source code into your project, where you can change it whenever you need to. For React Native, there's a project built around the same idea called <a href="https://reactnativereusables.com/">React Native Reusables</a>.</p>
<p>To make this practical, I'll use React Native Reusables, Expo, and Uniwind to build a <strong>cart and checkout screen for a coffee shop's pickup orders</strong>. It includes order items with quantity controls, a pickup-time dropdown, a name field, an "own cup" toggle, a bill summary, a checkout bar that stays at the bottom, and an order-confirmation dialog. If you want to see how reusable sections come together in complete interfaces, you can also browse <a href="https://shadcnstudio.com/templates">shadcn templates</a>.</p>
<p>I'll also point out a few things that are easy to miss when working with these components, including:</p>
<ul>
<li>How shadcn/ui and its CLI work, and why you own the component code</li>
<li>How design tokens (named colors) replace a <code>colors.ts</code> file</li>
<li>How to use components such as Card, Select, Switch, and AlertDialog in React Native</li>
<li>How to handle mobile-specific issues, such as safe areas, keyboards, and tap targets</li>
<li>How to switch between light and dark mode, and how to change the whole theme from one file</li>
</ul>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><a href="#heading-prerequisites">Prerequisites</a></li>
<li><a href="#heading-tools-you-will-use">Tools You Will Use</a></li>
<li><a href="#heading-how-shadcnui-works">How shadcn/ui Works</a></li>
<li><a href="#heading-what-react-native-reusables-gives-you">What React Native Reusables Gives You</a></li>
<li><a href="#heading-what-youre-building">What You're Building</a></li>
<li><a href="#heading-step-1-create-the-app">Step 1: Create the App</a><ul>
<li><a href="#heading-answer-the-clis-questions">Answer the CLI's Questions</a></li>
<li><a href="#heading-look-at-the-project-structure">Look at the Project Structure</a></li>
<li><a href="#heading-run-the-app">Run the App</a></li>
</ul>
</li>
<li><a href="#heading-step-2-understand-the-theme-file">Step 2: Understand the Theme File</a><ul>
<li><a href="#heading-the-palette-also-exists-in-libthemets">The Palette Also Exists inlib/theme.ts</a></li>
<li><a href="#heading-try-it-change-one-color">Try It: Change One Color</a></li>
</ul>
</li>
<li><a href="#heading-step-3-read-three-core-components">Step 3: Read Three Core Components</a><ul>
<li><a href="#heading-the-text-component">The Text Component</a></li>
<li><a href="#heading-the-button-component">The Button Component</a></li>
<li><a href="#heading-the-icon-component">The Icon Component</a></li>
</ul>
</li>
<li><a href="#heading-step-4-add-the-components-you-need">Step 4: Add the Components You Need</a></li>
<li><a href="#heading-step-5-set-up-the-data-and-the-screen">Step 5: Set Up the Data and the Screen</a><ul>
<li><a href="#heading-the-screen-layout">The Screen Layout</a></li>
</ul>
</li>
<li><a href="#heading-step-6-build-the-order-rows">Step 6: Build the Order Rows</a></li>
<li><a href="#heading-step-7-build-the-pickup-options">Step 7: Build the Pickup Options</a><ul>
<li><a href="#heading-problem-1-portals-and-the-portalhost">Problem 1: Portals and the PortalHost</a></li>
<li><a href="#heading-problem-2-safe-area-insets">Problem 2: Safe Area Insets</a></li>
<li><a href="#heading-problem-3-the-select-value-is-an-object">Problem 3: The Select Value Is an Object</a></li>
<li><a href="#heading-making-the-label-tappable">Making the Label Tappable</a></li>
<li><a href="#heading-keyboard-props-for-the-name-field">Keyboard Props for the Name Field</a></li>
</ul>
</li>
<li><a href="#heading-step-8-build-the-bill-component">Step 8: Build the Bill Component</a></li>
<li><a href="#heading-step-9-build-the-fixed-checkout-bar">Step 9: Build the Fixed Checkout Bar</a><ul>
<li><a href="#heading-handling-the-home-indicator">Handling the Home Indicator</a></li>
<li><a href="#heading-a-note-on-thin-borders">A Note on Thin Borders</a></li>
</ul>
</li>
<li><a href="#heading-step-10-add-the-confirmation-and-put-the-screen-together">Step 10: Add the Confirmation and Put the Screen Together</a><ul>
<li><a href="#heading-1-it-also-needs-the-portalhost">1. It Also Needs thePortalHost</a></li>
<li><a href="#heading-2-the-footer-reverses-its-order-on-phones">2. The Footer Reverses its Order on Phones</a></li>
<li><a href="#heading-haptics">Haptics</a></li>
<li><a href="#heading-the-full-screen">The Full Screen</a></li>
</ul>
</li>
<li><a href="#heading-step-11-add-dark-mode">Step 11: Add Dark Mode</a><ul>
<li><a href="#heading-1-the-navigation-header">1. The Navigation Header</a></li>
<li><a href="#heading-2-the-status-bar">2. The Status Bar</a></li>
<li><a href="#heading-3-saving-the-choice">3. Saving the Choice</a></li>
</ul>
</li>
<li><a href="#heading-step-12-create-your-own-theme">Step 12: Create Your Own Theme</a><ul>
<li><a href="#heading-update-the-tokens">Update the Tokens</a></li>
<li><a href="#heading-update-libthemets">Update lib/theme.ts</a></li>
</ul>
</li>
<li><a href="#heading-common-mistakes-and-how-to-fix-them">Common Mistakes and How to Fix Them</a></li>
<li><a href="#heading-conclusion">Conclusion</a><ul>
<li><a href="#heading-resources">Resources</a></li>
</ul>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>To follow along, you should have:</p>
<ul>
<li><strong>Node.js 20 or newer.</strong> You can use pnpm, npm, yarn, or bun. The commands in this tutorial use pnpm, so swap the prefix if you use something else.</li>
<li><strong>Basic knowledge of React and TypeScript</strong>, especially hooks such as <code>useState</code>.</li>
<li><strong>Some familiarity with React Native</strong>, such as <code>View</code>, <code>Text</code>, and <code>ScrollView</code>. You don't need to know Tailwind, but it helps.</li>
<li><strong>Optional:</strong> Xcode or Android Studio if you want to run the app in a simulator. Everything also runs in a web browser through React Native Web, and that's how the screenshots in this tutorial were taken.</li>
</ul>
<p>You don't need any accounts or API keys, and nothing here costs money.</p>
<h2 id="heading-tools-you-will-use">Tools You Will Use</h2>
<p>Before creating the app, let's look at the tools in this project. Each one has a specific job, and knowing that will make the setup easier to follow.</p>
<p><strong>shadcn/ui</strong> is a collection of UI components for the web. It's not installed as a package. Instead, a command-line tool (CLI) copies the source code of each component into your project, and from then on the code is yours to edit.</p>
<p><strong>React Native Reusables</strong> is the React Native version of shadcn/ui. It gives you components such as Button, Card, Select, and Dialog that are written for React Native and styled with Tailwind-style class names. It uses the same CLI idea as shadcn/ui.</p>
<p><strong>RN Primitives</strong> is a React Native port of Radix UI, which is the library that shadcn/ui uses for behavior on the web. A "primitive" is a component that handles the behavior but has no styling. For example, the <code>select</code> primitive knows how a dropdown opens and closes and how it's announced to screen readers, but it has no colors or borders. React Native Reusables adds the looks on top.</p>
<p><strong>Uniwind</strong> is a tool that lets you style React Native components with Tailwind CSS class names, such as <code>className="bg-primary p-4"</code>. It converts those classes into regular React Native styles while the app is being bundled, so there's no extra work at runtime. <a href="https://www.nativewind.dev/">Nativewind</a> does a similar job, and React Native Reusables supports both. This tutorial uses Uniwind.</p>
<p><strong>Expo and Expo Router</strong> provide the project setup, the development server, and file-based navigation. If you have used Next.js, Expo Router works in a similar way.</p>
<h2 id="heading-how-shadcnui-works">How shadcn/ui Works</h2>
<p>The React Native Reusables documentation opens with a quote from shadcn:</p>
<blockquote>
<p>This is not a component library. It is how you build your component library.</p>
</blockquote>
<p>You won't add shadcn/ui as a regular dependency in <code>package.json</code>. It uses a <strong>registry</strong> instead. A registry is a collection of JSON files hosted on a server. Each file describes a component and includes the information the CLI needs to add it to your project. When you run the CLI, it reads the relevant file and copies the component's source code into your project.</p>
<p>To see what this looks like, here's the registry entry for the <strong>Badge</strong> component. A Badge is a small label, such as the "Medium" tag next to a coffee in the cart you will build later. This is a shortened version of the real file:</p>
<pre><code class="language-json">{
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
"name": "badge",
"type": "registry:ui",
"title": "Badge",
"author": "@mrzachnugent",
"description": "Displays a badge or a component that looks like a badge.",
"dependencies": ["@rn-primitives/slot"],
"registryDependencies": ["https://reactnativereusables.com/r/uniwind/text.json"],
"files": [
{
"path": "./node_modules/@rnr/registry/src/uniwind/components/ui/badge.tsx",
"content": "import { TextClassContext } from '@/components/ui/text';\nimport ...",
"type": "registry:ui"
}
]
}
</code></pre>
<p>The file is public, so you can look at it yourself:</p>
<pre><code class="language-bash">curl https://reactnativereusables.com/r/uniwind/badge.json
</code></pre>
<p>Here's what the important fields mean:</p>
<ul>
<li><code>content</code> holds the full source code of the component, stored as a string.</li>
<li><code>dependencies</code> lists npm packages that the component needs. The CLI installs these first.</li>
<li><code>registryDependencies</code> lists other registry entries that the component needs. In this case, Badge needs the <strong>Text</strong> component, which is a small wrapper around React Native's <code>Text</code> that you will read about in Step 3. When you run the CLI to add Badge, it also adds Text for you because of this field.</li>
</ul>
<p>That's how a component gets from the registry into your app. The registry stores the component information, and the CLI uses it to create the files in your repository. React Native Reusables uses the shadcn CLI behind the scenes, so you can follow the same workflow when adding React Native components.</p>
<p>There are a few reasons this approach is useful when you're building an app:</p>
<ol>
<li><strong>You own the file:</strong> A component such as <code>components/ui/button.tsx</code> sits in your repository and shows up in your Git diffs. If you want a darker pressed state, you edit that line. You don't need to invent a new prop, maintain a fork, or open an issue.</li>
<li><strong>Design lives in named colors instead of props:</strong> Colors are declared once as named roles, such as <code>primary</code>, <code>muted</code>, <code>border</code>, and <code>destructive</code>. You then use them in class names, like <code>className="bg-primary"</code>. This replaces the <code>colors.ts</code> file from the introduction, because there's one place that defines your colors, and every component reads from it. You'll set this up in Step 2.</li>
<li><strong>Behavior comes from a separate package:</strong> Things such as focus handling, open and close state, and screen reader support live in RN Primitives. You only write the styling.</li>
</ol>
<p>There's one trade-off to keep in mind. Since the component code is copied into your project, updates from the registry aren't applied automatically. If a bug is fixed upstream, you'll need to run the CLI again with <code>--overwrite</code>. If you have customized that component, you may also need to reapply your changes. This is worth considering before you make large changes to several components.</p>
<h2 id="heading-what-react-native-reusables-gives-you">What React Native Reusables Gives You</h2>
<p>At the time of writing, React Native Reusables includes thirty components: accordion, alert, alert-dialog, aspect-ratio, avatar, badge, button, card, checkbox, collapsible, context-menu, dialog, dropdown-menu, hover-card, input, label, menubar, popover, progress, radio-group, select, separator, skeleton, switch, tabs, text, textarea, toggle, toggle-group, and tooltip. The available components cover many of the building blocks you need for a typical app interface.</p>
<p>React Native is different from the web in several ways, so the components can't be an exact copy of the web version. The documentation lists the main differences, and you'll run into each of them in this tutorial. If you're also building web interfaces with shadcn/ui, <a href="https://shadcnstudio.com/components">shadcn components</a> can be useful when you need more ready-made examples to work from.</p>
<table>
<thead>
<tr>
<th>The difference</th>
<th>What it means</th>
<th>Where you will see it</th>
</tr>
</thead>
<tbody><tr>
<td>Portals need a <code>PortalHost</code></td>
<td>On the web, floating content such as dropdowns renders into <code>document.body</code>. React Native has no <code>document.body</code>, so you need a <code>PortalHost</code> component to render into.</td>
<td>Step 7</td>
</tr>
<tr>
<td>Styles don't cascade</td>
<td>A text color set on a parent view doesn't reach the text inside it, so React Native Reusables uses a <code>Text</code> component with a workaround.</td>
<td>Step 3</td>
</tr>
<tr>
<td>There are no <code>data-*</code> attributes</td>
<td>Web shadcn changes styles based on attributes like <code>data-state</code>. React Native has none, so variants depend on props and state.</td>
<td>Step 7</td>
</tr>
<tr>
<td>Icons take props instead of classes</td>
<td>Icon libraries expect <code>color</code> and <code>size</code> props, so icons are wrapped to accept class names.</td>
<td>Step 3</td>
</tr>
</tbody></table>
<h2 id="heading-what-youre-building">What You're Building</h2>
<p>We'll build a cart and checkout screen for a coffee shop where customers place an order and pick it up at the counter. The screen uses twelve components from the registry. While putting it together, I'll also explain a few React Native details that are easy to miss, including how dropdowns are rendered, how safe areas affect the layout, and how to keep the checkout bar visible while the rest of the page scrolls:</p>
<table>
<thead>
<tr>
<th>Part of the screen</th>
<th>Components used</th>
<th>The mobile problem it teaches</th>
</tr>
</thead>
<tbody><tr>
<td>Item rows</td>
<td>Card, Avatar, Badge, Button</td>
<td>No cascading styles, no <code>truncate</code>, small tap targets</td>
</tr>
<tr>
<td>Quantity stepper</td>
<td>Button, Icon</td>
<td>Icons take props, not classes</td>
</tr>
<tr>
<td>Pickup time</td>
<td>Select, Label</td>
<td>Dropdowns need a portal</td>
</tr>
<tr>
<td>Name field</td>
<td>Input, Label</td>
<td>The keyboard can cover the field</td>
</tr>
<tr>
<td>Own cup toggle</td>
<td>Switch, Label</td>
<td>Making the label text tappable</td>
</tr>
<tr>
<td>Bill</td>
<td>Separator, Text</td>
<td>Digits that don't line up</td>
</tr>
<tr>
<td>Checkout bar</td>
<td>Button, AlertDialog</td>
<td>No <code>position: fixed</code>, and the home indicator</td>
</tr>
</tbody></table>
<p>Here's the finished screen:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5e0f2c2e490269cb30227a2b/a30e05d8-efcb-41ed-ad2c-fd22763d7ea2.png" alt="The finished cart screen in light mode" style="display: block;" width="600" height="400" loading="lazy">
<img src="https://cdn.hashnode.com/uploads/covers/5e0f2c2e490269cb30227a2b/f24bc348-c9a2-4197-92a6-c60479fb3686.png" alt="The finished cart screen in dark mode" style="display: block;" width="600" height="400" loading="lazy">
<p>The complete code is available in the <a href="https://github.com/shadcnstudio/shadcn-react-native-app">GitHub repository</a> if you want to compare your work with the final result.</p>
<h2 id="heading-step-1-create-the-app">Step 1: Create the App</h2>
<p>We can let the React Native Reusables CLI create the initial project for us. It sets up Expo Router, the styling configuration, the theme file, and a few starter components. Run the following command to get started:</p>
<pre><code class="language-bash">pnpm dlx @react-native-reusables/cli@latest init
</code></pre>
<h3 id="heading-answer-the-clis-questions">Answer the CLI's Questions</h3>
<p>The CLI asks four questions.</p>
<p><strong>What is the name of your project?</strong> The name you type becomes the folder name. It suggests <code>my-app</code>.</p>
<p><strong>Select a template.</strong> There are three options, and the one you need isn't the one selected by default:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5e0f2c2e490269cb30227a2b/7ce945cb-ac0e-49f0-990b-c0c2cf956f29.png" alt="The template selection prompt in the terminal" style="display: block;" width="600" height="400" loading="lazy">
<pre><code class="language-plaintext">❯ Minimal (Nativewind)
Minimal (Uniwind)
Clerk auth (Nativewind)
</code></pre>
<p>Press the down arrow once and choose <strong>Minimal (Uniwind)</strong>. This is the one choice that's tricky to change later. Nativewind and Uniwind are two different ways to use Tailwind in React Native, and the CLI keeps a separate registry for each one. The components it writes for Uniwind are different from the ones it writes for Nativewind, so the code in this tutorial won't match if you pick Nativewind.</p>
<p><strong>Would you like to install dependencies?</strong> Choose yes, and select the package manager you use.</p>
<p><strong>Would you like to initialize a Git repository?</strong> Choose yes. You're about to replace the starter screen, and having a commit to compare against is helpful.</p>
<h3 id="heading-look-at-the-project-structure">Look at the Project Structure</h3>
<p>The CLI creates the following files:</p>
<pre><code class="language-plaintext">shadcn-react-native-app/
├── app/
│ ├── _layout.tsx # ThemeProvider, Stack, and the PortalHost
│ ├── +html.tsx # web only, the root HTML shell
│ ├── +not-found.tsx
│ └── index.tsx # the starter screen
├── components/ui/
│ ├── button.tsx
│ ├── icon.tsx
│ └── text.tsx
├── lib/
│ ├── theme.ts # the same palette again, in JavaScript
│ └── utils.ts # cn()
├── global.css # the theme tokens
├── components.json # the shadcn config, same as on the web
├── metro.config.js # withUniwindConfig
└── uniwind-types.d.ts # generated, do not edit
</code></pre>
<p>Before moving on, let's take a look at a few files in this structure. You'll use them throughout the tutorial:</p>
<ul>
<li><code>components.json</code> is the same shadcn configuration file used on the web. It tells the CLI where to put components (<code>@/components/ui</code>) and where the <code>cn()</code> helper lives (<code>@/lib/utils</code>).</li>
<li><code>global.css</code> holds your theme tokens, which you'll learn about in Step 2.</li>
<li><code>lib/utils.ts</code> exports <code>cn()</code>, a helper that combines class names and resolves conflicts between them.</li>
<li><code>app/_layout.tsx</code> contains a <code>PortalHost</code> component. It looks like leftover scaffolding, but you must not delete it. Step 7 explains why.</li>
</ul>
<p>Uniwind reads <code>global.css</code> while Metro bundles the app and turns your class strings into React Native style objects. This happens at build time, so a long <code>className</code> string doesn't slow down your app.</p>
<h3 id="heading-run-the-app">Run the App</h3>
<p>Start the development server:</p>
<pre><code class="language-bash">pnpm dev
</code></pre>
<p>Press <code>w</code> to open it in the browser, <code>i</code> for the iOS simulator, or <code>a</code> for Android. The starter screen has a theme toggle in the header, which shows that the theme setup is working.</p>
<p>You can also run a diagnostic command at any point if something seems wrong:</p>
<pre><code class="language-bash">pnpm dlx @react-native-reusables/cli@latest doctor
</code></pre>
<p>It checks your setup and reports which styling library it detects. For this project, the output should be:</p>
<pre><code class="language-plaintext">ℹ Styling Library: Uniwind
✔ All checks passed.
</code></pre>
<p>If it says Nativewind, fix that before you add any components.</p>
<p>Once the setup is working, you should see the starter screen. Try the theme toggle in the header to make sure light and dark mode both work, and check that the console is free of errors:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5e0f2c2e490269cb30227a2b/72fbc819-a026-41cb-824d-6f50b9e88625.png" alt="The starter screen" style="display: block;" width="600" height="400" loading="lazy">
<h2 id="heading-step-2-understand-the-theme-file">Step 2: Understand the Theme File</h2>
<p>Now that the app is running, let's look at its theme setup. The <code>global.css</code> file is where we'll define the colors used by the components. If you've previously kept these values in a <code>colors.ts</code> or <code>theme.ts</code> file, this is the setup that replaces it.</p>
<pre><code class="language-css">@import "tailwindcss";
@import "uniwind";
@import "tw-animate-css";
@theme {
--radius: 10px;
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--spacing-hairline: hairlineWidth();
}
@layer theme {
:root {
@variant light {
--color-background: oklch(1 0 0);
--color-foreground: oklch(0.145 0 0);
--color-card: oklch(1 0 0);
--color-primary: oklch(0.205 0 0);
--color-muted-foreground: oklch(0.556 0 0);
--color-border: oklch(0.922 0 0);
/* the rest of the list */
}
@variant dark {
--color-background: oklch(0.145 0 0);
--color-foreground: oklch(0.985 0 0);
--color-card: oklch(0.205 0 0);
--color-primary: oklch(0.922 0 0);
--color-muted-foreground: oklch(0.708 0 0);
--color-border: oklch(1 0 0 / 10%);
/* the rest of the list */
}
}
}
</code></pre>
<p>There are two conventions in this file that you need to know.</p>
<p>First, the <code>--color-</code> prefix creates the class names. When you declare <code>--color-primary</code>, Tailwind automatically creates <code>bg-primary</code>, <code>text-primary</code>, and <code>border-primary</code>. If you declare <code>--primary</code> instead, you only get a CSS variable that nothing uses.</p>
<p>This is important because most shadcn theme generators produce the web format, where the variables are named <code>--primary</code>. If you paste that output without renaming the variables, nothing breaks, but nothing changes either. Step 12 shows you how to convert them.</p>
<p>Second, <code>@variant light</code> and <code>@variant dark</code> replace <code>:root</code> and <code>.dark</code>. On the web, dark mode works by adding a <code>.dark</code> class to the page. A phone has no page to add a class to, so Uniwind manages the switch. When you call <code>Uniwind.setTheme</code>, it swaps the active values. You'll use this in Step 11.</p>
<p>You may also notice that the file uses <code>oklch()</code> colors. Phones don't have a browser to read that format, but Uniwind converts the colors to a format React Native understands while bundling, so you can use modern color syntax anyway.</p>
<h3 id="heading-the-palette-also-exists-in-libthemets">The Palette Also Exists in <code>lib/theme.ts</code></h3>
<p>If you open <code>lib/theme.ts</code>, you'll find the same colors again as a JavaScript object. This duplication is needed because React Navigation, which draws the header and screen background, reads colors from a plain JavaScript object and can't read CSS variables. As a result, you have to keep two copies of your palette in sync. Step 12 shows what happens when they differ.</p>
<h3 id="heading-try-it-change-one-color">Try It: Change One Color</h3>
<p>Let's change one of the tokens to see how the theme works. Update the primary color in the light theme to a value that's easy to notice:</p>
<pre><code class="language-css">--color-primary: oklch(0.6 0.24 25);
</code></pre>
<p>Save the file and look at the starter screen. The <strong>Browse the Docs</strong> button is now red, even though you didn't edit <code>button.tsx</code>:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5e0f2c2e490269cb30227a2b/0d2116eb-9052-40a1-9163-69f1a89dbd14.png" alt="The starter button turned red after changing one token" style="display: block;" width="600" height="400" loading="lazy">
<p>Notice that every component using <code>bg-primary</code> picks up the new color. You only changed one token, but the button updated without touching its component file. Change the value back before continuing.</p>
<h2 id="heading-step-3-read-three-core-components">Step 3: Read Three Core Components</h2>
<p>The starter project already gives us three components: <code>Text</code>, <code>Button</code>, and <code>Icon</code>. I'd take a few minutes to look through them before building the screen. They introduce patterns you'll see again in the other registry components.</p>
<h3 id="heading-the-text-component">The Text Component</h3>
<p>In React Native, styles don't cascade the way they do in CSS. If you set a text color on a <code>View</code>, the text inside it doesn't inherit that color. This is a problem for a component such as <code><Button variant="destructive"></code>, which needs its label to be white. The label is a <code>Text</code> component that the Button doesn't control, because it comes in through <code>children</code>. On the web, the Button would simply set a color and let it flow down to the label.</p>
<p>React Native Reusables solves this with React context. This is the code in <code>components/ui/text.tsx</code>:</p>
<pre><code class="language-tsx">const TextClassContext = React.createContext<string | undefined>(undefined);
function Text({ className, asChild = false, variant = 'default', ...props }) {
const textClass = React.useContext(TextClassContext);
const Component = asChild ? Slot : RNText;
return (
<Component
className={cn(textVariants({ variant }), textClass, className)}
role={variant ? ROLE[variant] : undefined}
aria-level={variant ? ARIA_LEVEL[variant] : undefined}
{...props}
/>
);
}
</code></pre>
<p>A parent component, such as Button, puts a class string into the context. Every <code>Text</code> inside it reads that string and applies it. Your own <code>className</code> still wins because it comes last in the <code>cn()</code> call.</p>
<p>The <code>Text</code> component also has a <code>variant</code> prop with options like <code>h1</code> to <code>h4</code>, <code>p</code>, <code>code</code>, <code>lead</code>, <code>large</code>, <code>small</code>, and <code>muted</code>. For example, <code><Text variant="muted"></code> gives you the mobile version of <code>text-muted-foreground text-sm</code>. The variants also set the <code>role</code> and <code>aria-level</code> props, so screen readers announce headings correctly without any extra work.</p>
<h3 id="heading-the-button-component">The Button Component</h3>
<p>The Button in <code>components/ui/button.tsx</code> uses <code>cva</code> (class-variance-authority) the same way the web version does. Here's a shortened version:</p>
<pre><code class="language-tsx">const buttonVariants = cva(
cn(
'group shrink-0 flex-row items-center justify-center gap-2 rounded-md shadow-none',
Platform.select({
web: "focus-visible:border-ring focus-visible:ring-ring/50 ... transition-all",
})
),
{
variants: {
variant: {
default: cn(
'bg-primary active:bg-primary/90 shadow-sm shadow-black/5',
Platform.select({ web: 'hover:bg-primary/90' })
),
/* ... */
},
},
}
);
</code></pre>
<p>There are four differences from the web version:</p>
<ul>
<li><code>flex-row</code> <strong>is written out:</strong> React Native lays out children in a column by default, which is the opposite of CSS. Every horizontal layout in this tutorial includes <code>flex-row</code> for that reason.</li>
<li><code>active:</code> <strong>replaces</strong> <code>hover:</code><strong>:</strong> A finger can't really hover, so <code>active:</code> is the pressed state on a phone.</li>
<li><strong>Web-only classes are wrapped in</strong> <code>Platform.select</code><strong>:</strong> Classes such as <code>focus-visible:ring-[3px]</code> mean nothing in React Native, so they're only included in the web build.</li>
<li><strong>A</strong> <code>TextClassContext.Provider</code> <strong>wraps the</strong> <code>Pressable</code><strong>:</strong> This uses the context from the previous section, and it's why <code><Button variant="destructive"><Text>Delete</Text></Button></code> has white text automatically.</li>
</ul>
<h3 id="heading-the-icon-component">The Icon Component</h3>
<p>Icons from <code>lucide-react-native</code> take <code>color</code> and <code>size</code> as props, not as class names. The <code>Icon</code> component in <code>components/ui/icon.tsx</code> teaches Uniwind how to convert one into the other:</p>
<pre><code class="language-tsx">const StyledIcon = withUniwind(IconImpl, {
size: { fromClassName: 'className', styleProperty: 'width' },
color: { fromClassName: 'className', styleProperty: 'color' },
});
</code></pre>
<p>This code reads the <code>width</code> from the class string and passes it as <code>size</code>, and it reads the <code>color</code> and passes it as <code>color</code>. That's why <code><Icon as={PlusIcon} className="size-4 text-muted-foreground" /></code> works the way you would expect. <code>withUniwind</code> is useful any time you use a third-party component that takes props where you want to use classes.</p>
<p>You don't need to run anything for this step. The important part is understanding what <code>TextClassContext</code> is doing. Once that clicks, the other components in the registry are much easier to follow.</p>
<h2 id="heading-step-4-add-the-components-you-need">Step 4: Add the Components You Need</h2>
<p>We already have three components from the template. For the checkout screen, we need a few more. The CLI can add all of them in one go:</p>
<pre><code class="language-bash">pnpm dlx @react-native-reusables/cli@latest add card separator input label select switch badge alert-dialog avatar
</code></pre>
<p>The CLI detects that you're using Uniwind and writes the matching versions. It also installs any required <code>@rn-primitives</code> packages and adds a file called <code>native-only-animated-view.tsx</code>, which Select and AlertDialog use for their animations.</p>
<p>The output looks like this:</p>
<pre><code class="language-plaintext">ℹ Styling Library: Uniwind
✔ Created 10 files:
- components/ui/separator.tsx
- components/ui/input.tsx
- components/ui/label.tsx
- components/ui/switch.tsx
- components/ui/avatar.tsx
- components/ui/native-only-animated-view.tsx
- components/ui/card.tsx
- components/ui/badge.tsx
- components/ui/select.tsx
- components/ui/alert-dialog.tsx
ℹ Skipped 3 files: (files might be identical, use --overwrite to overwrite)
- components/ui/text.tsx
- components/ui/icon.tsx
- components/ui/button.tsx
</code></pre>
<p>The CLI created ten files, which is more than the nine you asked for, because <code>native-only-animated-view.tsx</code> is a dependency of Select and AlertDialog. It skipped the three files that the template already gave you, so it does not overwrite your copies. This is the "you own the file" idea at work.</p>
<p>Run <code>pnpm dev</code> again to make sure the app still starts. The <code>components/ui/</code> folder should now contain thirteen files.</p>
<h2 id="heading-step-5-set-up-the-data-and-the-screen">Step 5: Set Up the Data and the Screen</h2>
<p>Before we start building the UI, let's add the sample menu data and decide where the screen state will live. I'm going to store prices in cents rather than floating-point numbers. This avoids calculation results such as <code>$4.7499999</code>.</p>
<p>We'll also keep the state in the main screen component and pass the relevant values down as props. That way, changing an item quantity or selecting the own-cup option automatically updates the bill.</p>
<p>Create <code>lib/menu.ts</code>:</p>
<pre><code class="language-tsx">export type CartItem = {
id: string;
name: string;
detail: string;
size?: 'Small' | 'Medium' | 'Large';
emoji: string;
/** Cents. Money in floats is how you end up charging someone $4.7499999. */
unitPrice: number;
qty: number;
};
export const INITIAL_CART: CartItem[] = [
{
id: 'flat-white',
name: 'Flat white',
detail: 'Oat milk, extra shot',
size: 'Medium',
emoji: '☕️',
unitPrice: 475,
qty: 2,
},
{
id: 'cold-brew',
name: 'Cold brew',
detail: 'Hold the ice',
size: 'Large',
emoji: '🧋',
unitPrice: 525,
qty: 1,
},
{
id: 'croissant',
name: 'Almond croissant',
detail: 'Warmed',
emoji: '🥐',
unitPrice: 390,
qty: 1,
},
];
export const PICKUP_TIMES = [
{ value: 'asap', label: 'As soon as it is ready' },
{ value: '15', label: 'In 15 minutes' },
{ value: '30', label: 'In 30 minutes' },
{ value: '60', label: 'In an hour' },
];
export const CUP_DISCOUNT = 25;
export const TAX_RATE = 0.0825;
export function money(cents: number) {
const sign = cents < 0 ? '-' : '';
return `${sign}$${(Math.abs(cents) / 100).toFixed(2)}`;
}
export function billFor(items: CartItem[], ownCup: boolean) {
const subtotal = items.reduce((sum, item) => sum + item.unitPrice * item.qty, 0);
const discount = ownCup && subtotal > 0 ? CUP_DISCOUNT : 0;
const tax = Math.round((subtotal - discount) * TAX_RATE);
return { subtotal, discount, tax, total: subtotal - discount + tax };
}
</code></pre>
<p>The <code>PICKUP_TIMES</code> list is in this file instead of inside a component because two different components need it. You'll see why in Step 7.</p>
<h3 id="heading-the-screen-layout">The Screen Layout</h3>
<p>This is basically how <code>app/index.tsx</code> looks. The parts that don't exist yet are left as comments, and you'll write the full file later in Step 10.</p>
<pre><code class="language-tsx"><View className="bg-background flex-1">
<ScrollView
className="flex-1"
contentContainerClassName="gap-4 p-4 pb-8"
keyboardShouldPersistTaps="handled"
automaticallyAdjustKeyboardInsets>
{/* the three cards */}
</ScrollView>
<CheckoutBar {...} />
</View>
</code></pre>
<p>There are three details in this layout that you should examine more closely:</p>
<ul>
<li>First, <code>className</code> on a <code>ScrollView</code> styles the scroll container, not its content. This is the class name version of the <code>style</code> and <code>contentContainerStyle</code> split in React Native. If you put padding in <code>className</code>, it gets clipped, and <code>gap</code> has no effect. Use <code>contentContainerClassName</code> for anything that should apply to the content. Uniwind adds this prop to <code>ScrollView</code>.</li>
<li>There's no <code>space-y-4</code>. In CSS, <code>space-y-*</code> works by selecting siblings, and React Native has no selectors. Use <code>gap-4</code> on the parent instead, which React Native supports on iOS, Android, and the web.</li>
<li><code>keyboardShouldPersistTaps="handled"</code> keeps taps working while the keyboard is open. Without it, the first tap after the keyboard appears only closes the keyboard, so the user has to tap twice. <code>automaticallyAdjustKeyboardInsets</code> is an iOS feature that scrolls the content so the focused input isn't hidden behind the keyboard.</li>
</ul>
<p>At this stage, you should see only a header with the new title and an empty page below it:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5e0f2c2e490269cb30227a2b/02188707-8809-4cc8-bc1d-1a79330bebd3.png" alt="An empty screen with the new header title" style="display: block;" width="600" height="400" loading="lazy">
<h2 id="heading-step-6-build-the-order-rows">Step 6: Build the Order Rows</h2>
<p>The order row has quite a bit going on in a small space: an avatar, product name, size badge, price, and quantity controls. Long names make this especially tricky on a phone, so we'll handle the layout carefully.</p>
<p>Create <code>components/cart/order-row.tsx</code>:</p>
<pre><code class="language-tsx">import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Icon } from '@/components/ui/icon';
import { Text } from '@/components/ui/text';
import { money, type CartItem } from '@/lib/menu';
import { MinusIcon, PlusIcon, Trash2Icon } from 'lucide-react-native';
import { View } from 'react-native';
type OrderRowProps = {
item: CartItem;
onChangeQty: (id: string, qty: number) => void;
};
export function OrderRow({ item, onChangeQty }: OrderRowProps) {
const lastOne = item.qty <= 1;
return (
<View className="flex-row gap-3">
<Avatar alt={item.name} className="size-14 rounded-2xl">
<AvatarFallback className="bg-muted rounded-2xl">
<Text className="text-2xl">{item.emoji}</Text>
</AvatarFallback>
</Avatar>
<View className="flex-1 gap-2">
<View className="flex-row items-start gap-3">
<View className="flex-1 gap-1">
<View className="flex-row items-center gap-2">
<Text numberOfLines={1} className="shrink font-medium">
{item.name}
</Text>
{item.size ? (
<Badge variant="secondary">
<Text>{item.size}</Text>
</Badge>
) : null}
</View>
<Text numberOfLines={1} variant="muted">
{item.detail}
</Text>
</View>
<Text className="text-sm font-medium tabular-nums">
{money(item.unitPrice * item.qty)}
</Text>
</View>
<View className="border-border flex-row items-center gap-1 self-start rounded-full border p-0.5">
<Button
size="icon"
variant="ghost"
hitSlop={8}
className="size-7 rounded-full"
accessibilityLabel={lastOne ? `Remove ${item.name}` : `One fewer ${item.name}`}
onPress={() => onChangeQty(item.id, item.qty - 1)}>
<Icon as={lastOne ? Trash2Icon : MinusIcon} className="size-4" />
</Button>
<Text className="w-5 text-center text-sm font-medium tabular-nums">{item.qty}</Text>
<Button
size="icon"
variant="ghost"
hitSlop={8}
className="size-7 rounded-full"
accessibilityLabel={`One more ${item.name}`}
onPress={() => onChangeQty(item.id, item.qty + 1)}>
<Icon as={PlusIcon} className="size-4" />
</Button>
</View>
</View>
</View>
);
}
</code></pre>
<p>A few details in this component deserve a closer look.</p>
<p>First, <code>Avatar</code> requires the <code>alt</code> prop. TypeScript won't compile without it. The value becomes the accessibility label, so a screen reader says "Flat white" instead of just "image".</p>
<p>The radius is repeated on the avatar fallback. <code>Avatar</code> and <code>AvatarFallback</code> are each <code>rounded-full</code> by default. If you only set <code>rounded-2xl</code> on <code>Avatar</code>, the emoji background stays a circle and pokes out of the rounded square.</p>
<p>You should also use <code>numberOfLines={1}</code> instead of <code>truncate</code>. Tailwind's <code>truncate</code> relies on <code>text-overflow: ellipsis</code>, which React Native doesn't support, and Uniwind doesn't convert it. The class compiles to nothing, so a long product name pushes the price off the screen. The <code>numberOfLines</code> prop is the React Native way to do this, and it also works in the web build.</p>
<p>The name has <code>shrink</code> and its column has <code>flex-1</code>. Without <code>shrink</code>, a long name won't get smaller and will push the badge out of the row. This is regular Flexbox behavior, but it shows up more on a phone because there's less width to work with.</p>
<p><code>hitSlop={8}</code> makes small buttons easier to tap. Apple recommends touch targets of at least 44 points and Android recommends 48dp, but a <code>size-7</code> button is only 28 points wide. The <code>hitSlop</code> prop makes the touchable area larger without changing how the button looks. There's no CSS equivalent.</p>
<p>Finally, <code>tabular-nums</code> keeps digits the same width. In most fonts, the digit <code>1</code> is narrower than <code>8</code>, so prices would shift sideways every time the quantity changes. Uniwind converts <code>tabular-nums</code> to React Native's <code>fontVariant</code>, so it works on iOS, Android, and the web.</p>
<p>After adding this component and rendering it inside a Card (which you'll do in Step 10), you should see three rows:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5e0f2c2e490269cb30227a2b/d361c2e7-8eaa-466d-aed1-7b162b6ace95.png" alt="The order rows inside a card" style="display: block;" width="600" height="400" loading="lazy">
<p>Tapping plus on the flat white should change its price from <code>$9.50</code> to <code>$14.25</code>. Tapping the trash icon on an item with a quantity of one should remove the row. The minus button turns into a trash icon whenever the quantity is one.</p>
<h2 id="heading-step-7-build-the-pickup-options">Step 7: Build the Pickup Options</h2>
<p>The pickup card brings together three controls: a Select, an Input, and a Switch. The input and switch are fairly straightforward. The Select needs a little more attention because it has three platform-related quirks, and two of them can fail silently.</p>
<p>Create <code>components/cart/pickup-card.tsx</code>:</p>
<pre><code class="language-tsx">import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { Text } from '@/components/ui/text';
import { PICKUP_TIMES } from '@/lib/menu';
import { View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
type PickupCardProps = {
pickupValue: string;
onPickupValueChange: (value: string) => void;
name: string;
onNameChange: (value: string) => void;
ownCup: boolean;
onOwnCupChange: (value: boolean) => void;
};
export function PickupCard({
pickupValue,
onPickupValueChange,
name,
onNameChange,
ownCup,
onOwnCupChange,
}: PickupCardProps) {
const insets = useSafeAreaInsets();
// Look the label up from our own data. The Option handed back by onValueChange
// does not carry a usable label on web. See the note below.
const selected = PICKUP_TIMES.find((time) => time.value === pickupValue);
const contentInsets = {
top: insets.top,
bottom: insets.bottom,
left: 16,
right: 16,
};
return (
<View className="gap-5">
<View className="gap-2">
<Label nativeID="pickup-time">Pickup time</Label>
<Select
value={selected}
onValueChange={(option) => {
if (option) {
onPickupValueChange(option.value);
}
}}>
<SelectTrigger className="w-full" aria-labelledby="pickup-time">
<SelectValue placeholder="Pick a time" />
</SelectTrigger>
<SelectContent insets={contentInsets} className="w-full">
{PICKUP_TIMES.map((time) => (
<SelectItem key={time.value} value={time.value} label={time.label} />
))}
</SelectContent>
</Select>
</View>
<View className="gap-2">
<Label nativeID="order-name">Name for the order</Label>
<Input
aria-labelledby="order-name"
value={name}
onChangeText={onNameChange}
placeholder="Who is picking it up?"
autoCapitalize="words"
autoComplete="name"
returnKeyType="done"
/>
</View>
<View className="flex-row items-center justify-between gap-4">
<View className="flex-1 gap-1">
<Label nativeID="own-cup" onPress={() => onOwnCupChange(!ownCup)}>
Bringing my own cup
</Label>
<Text variant="muted">Takes 25¢ off and saves a lid.</Text>
</View>
<Switch nativeID="own-cup" checked={ownCup} onCheckedChange={onOwnCupChange} />
</View>
</View>
);
}
</code></pre>
<h3 id="heading-problem-1-portals-and-the-portalhost">Problem 1: Portals and the PortalHost</h3>
<p>When a dropdown opens, it needs to appear above the other content on the screen. For that reason, <code>SelectContent</code> isn't rendered in the same place where you declare it in the component tree. It uses <code>@rn-primitives/portal</code> to render the content in another part of the app. This technique is called a portal.</p>
<p>On the web, a portal renders into <code>document.body</code>, and every page has one. React Native has nothing like that. So the portal package keeps a list of portal contents in memory, and a component called <code>PortalHost</code> reads that list and displays it. This is a simplified version of that code from <code>node_modules/@rn-primitives/portal/dist/index.js</code>:</p>
<pre><code class="language-jsx">var DEFAULT_PORTAL_HOST = "INTERNAL_PRIMITIVE_DEFAULT_HOST_NAME";
var updatePortal = (hostName, name, children) => { /* writes into the map */ };
function PortalHost({ name = DEFAULT_PORTAL_HOST }) { /* reads the map */ }
</code></pre>
<p>If no <code>PortalHost</code> is mounted, the content is saved but never displayed. The dropdown opens into nothing, and you get no error or warning.</p>
<p>The template already includes a <code>PortalHost</code> at the bottom of the root layout:</p>
<pre><code class="language-tsx"><ThemeProvider value={NAV_THEME[theme ?? 'light']}>
<StatusBar style={theme === 'dark' ? 'light' : 'dark'} />
<Stack />
<PortalHost />
</ThemeProvider>
</code></pre>
<p>It looks like unnecessary code, but you need to keep it. The AlertDialog in Step 10 uses it too.</p>
<h3 id="heading-problem-2-safe-area-insets">Problem 2: Safe Area Insets</h3>
<p>There's another issue to consider when showing a dropdown on a phone. Parts of the screen are reserved for the operating system, such as the area around the notch and the home indicator. If a dropdown opens near the bottom of the screen, one of its options could appear underneath the home indicator. In that area, a tap may be handled by the operating system instead of your app.</p>
<p>To avoid this, pass the safe area insets to the content:</p>
<pre><code class="language-tsx">const insets = useSafeAreaInsets();
const contentInsets = { top: insets.top, bottom: insets.bottom, left: 16, right: 16 };
<SelectContent insets={contentInsets} className="w-full">
</code></pre>
<p>The <code>insets</code> prop is part of <code>PositionedContentProps</code> in <code>@rn-primitives/types</code>, so every positioned component in the registry accepts it, including Popover, DropdownMenu, HoverCard, Tooltip, and Select. It's a good habit to pass it every time.</p>
<h3 id="heading-problem-3-the-select-value-is-an-object">Problem 3: The Select Value Is an Object</h3>
<p>One more thing to watch out for is the value returned by <code>onValueChange</code>. It may look like you're selecting a string, but the callback returns an <code>Option</code> object:</p>
<pre><code class="language-tsx">type Option = { value: string; label: string } | undefined;
</code></pre>
<p>This causes a bug that only appears on one platform. If you store the whole <code>Option</code> in state and show <code>option.label</code> later, such as in a confirmation dialog, it displays correctly on a phone ("In 30 minutes") but incorrectly in the browser ("30").</p>
<p>The reason is in the primitive's source code. This is the native version, from <code>select.js</code>:</p>
<pre><code class="language-jsx">onValueChange({ value: itemValue, label });
</code></pre>
<p>And this is the web version, from <code>select.web.js</code>. It uses Radix, which only returns a value:</p>
<pre><code class="language-jsx">onValueChange({ value: val, label: val });
</code></pre>
<p>So the label is correct on native, but on the web it's a copy of the value. If you only test in a simulator, you won't notice this problem.</p>
<p>The fix is to keep only the value in state and look up the label from your own list:</p>
<pre><code class="language-tsx">const selected = PICKUP_TIMES.find((time) => time.value === pickupValue);
</code></pre>
<p>This is why <code>PICKUP_TIMES</code> is in <code>lib/menu.ts</code>. The pickup card needs it to build the options, and the main screen needs it to write the confirmation message.</p>
<h3 id="heading-making-the-label-tappable">Making the Label Tappable</h3>
<p>The switch itself is quite small, so users may tap the label next to it instead. It makes sense to let that label toggle the switch as well. You can do that by adding an <code>onPress</code> handler to the <code>Label</code>:</p>
<pre><code class="language-tsx"><Label nativeID="own-cup" onPress={() => onOwnCupChange(!ownCup)}>
Bringing my own cup
</Label>
<Switch nativeID="own-cup" checked={ownCup} onCheckedChange={onOwnCupChange} />
</code></pre>
<p>Giving both elements the same <code>nativeID</code> connects them for screen readers. The <code>onPress</code> on the <code>Label</code> is what makes the text toggle the switch, because React Native has no automatic <code><label for></code> behavior.</p>
<p>While you're here, open <code>components/ui/switch.tsx</code>. On the web, shadcn colors a switch with <code>data-[state=checked]:bg-primary</code>, which reads an attribute set in the DOM. React Native has no <code>data-*</code> attributes, so the component checks the prop in JavaScript:</p>
<pre><code class="language-tsx">props.checked ? 'bg-primary' : 'bg-input dark:bg-input/80',
</code></pre>
<p>The result is the same. Keep this in mind if you copy a <code>data-[state=...]</code> selector from a web example and it has no effect.</p>
<h3 id="heading-keyboard-props-for-the-name-field">Keyboard Props for the Name Field</h3>
<p><code>Input</code> is a <code>TextInput</code> with your border and radius applied. You should set these four props on every text input:</p>
<ul>
<li><code>autoCapitalize="words"</code> capitalizes each word, which suits a name.</li>
<li><code>autoComplete="name"</code> lets the operating system offer to fill in the name.</li>
<li><code>returnKeyType="done"</code> changes the return key label to "Done".</li>
<li><code>keyboardType="number-pad"</code> shows a numeric keyboard when you need digits.</li>
</ul>
<p>Together with <code>automaticallyAdjustKeyboardInsets</code> on the ScrollView from Step 5, this keeps the field visible while the keyboard is open. If you need more control, <code>react-native-keyboard-controller</code> is already a dependency of the template, and it provides <code>KeyboardAwareScrollView</code> and <code>KeyboardProvider</code>.</p>
<p>When you open the dropdown, it should appear right under the trigger at the same width, with a check mark next to the current value:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5e0f2c2e490269cb30227a2b/d5234c02-19f7-4938-b889-24be5f13c175.png" alt="The open pickup time dropdown" style="display: block;" width="600" height="400" loading="lazy">
<p>Try picking a time and check that the trigger updates. Tap the words "Bringing my own cup" and confirm the switch flips. Type in the name field and confirm the keyboard doesn't cover it. If the dropdown opens and shows nothing, check your <code>PortalHost</code>.</p>
<h2 id="heading-step-8-build-the-bill-component">Step 8: Build the Bill Component</h2>
<p>The bill component is pretty small, but there are two details you should pay particular attention to here, especially when the values change while someone is editing their order.</p>
<p>Create <code>components/cart/bill.tsx</code>:</p>
<pre><code class="language-tsx">import { Separator } from '@/components/ui/separator';
import { Text } from '@/components/ui/text';
import { cn } from '@/lib/utils';
import { money } from '@/lib/menu';
import { View } from 'react-native';
type BillProps = {
subtotal: number;
discount: number;
tax: number;
total: number;
};
function Line({ label, value, tone }: { label: string; value: number; tone?: 'muted' | 'credit' }) {
return (
<View className="flex-row items-center justify-between">
<Text className={cn('text-sm', tone === 'muted' && 'text-muted-foreground')}>{label}</Text>
<Text
className={cn(
'text-sm tabular-nums',
tone === 'muted' && 'text-muted-foreground',
tone === 'credit' && 'text-foreground'
)}>
{money(value)}
</Text>
</View>
);
}
export function Bill({ subtotal, discount, tax, total }: BillProps) {
return (
<View className="gap-2">
<Line label="Subtotal" value={subtotal} tone="muted" />
{discount > 0 ? <Line label="Own cup" value={-discount} tone="credit" /> : null}
<Line label="Tax" value={tax} tone="muted" />
<Separator className="my-1" />
<View className="flex-row items-center justify-between">
<Text className="font-medium">Total</Text>
<Text className="text-lg font-semibold tabular-nums">{money(total)}</Text>
</View>
</View>
);
}
</code></pre>
<p>First, use <code>tabular-nums</code> on every number. Right-aligned prices in a proportional font don't line up at the decimal point, and using <code>tabular-nums</code> fixes that, as in the order rows.</p>
<p>Second, only show the discount row when it applies. The code uses <code>{discount > 0 ? <Line /> : null}</code> instead of <code>{discount && <Line />}</code>. In JSX, if the left side of <code>&&</code> is the number <code>0</code>, React renders the <code>0</code>. In React Native, a bare number outside a <code>Text</code> component throws the error <code>Text strings must be rendered within a <Text> component</code>. Using a boolean check and returning <code>null</code> avoids this whole category of bugs.</p>
<p>To test it, turn on the own cup switch and scroll down. An <strong>Own cup</strong> line should appear with <code>-$0.25</code>, and the tax should change from <code>$1.54</code> to <code>$1.52</code>, because tax is calculated on the discounted subtotal:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5e0f2c2e490269cb30227a2b/5fd0c79e-fcf4-4d7e-9129-bfb1e4d120d5.png" alt="The bill with the own cup discount applied" style="display: block;" width="600" height="400" loading="lazy">
<p>The total changes from <code>$20.19</code> to <code>$19.92</code>, which is 27 cents lower instead of 25. The extra two cents come from the lower tax.</p>
<h2 id="heading-step-9-build-the-fixed-checkout-bar">Step 9: Build the Fixed Checkout Bar</h2>
<p>I want the total and checkout button to remain visible while the user scrolls through the cart. On the web, you might reach for <code>position: sticky</code> and <code>bottom: 0</code>. React Native doesn't work that way, but we can get the same result with a simple flex layout.</p>
<p>You can get the same result with Flexbox. This is the layout from Step 5 again:</p>
<pre><code class="language-tsx"><View className="bg-background flex-1">
<ScrollView className="flex-1" ...>{/* content */}</ScrollView>
<CheckoutBar ... />
</View>
</code></pre>
<p>The outer <code>View</code> is a column. The <code>ScrollView</code> has <code>flex-1</code>, so it takes all the leftover height. The checkout bar is a sibling placed after it, so it sits at the bottom and doesn't scroll. You don't need any positioning or z-index.</p>
<h3 id="heading-handling-the-home-indicator">Handling the Home Indicator</h3>
<p>On phones without a home button, the bottom 34 points or so belong to the system. A button drawn there is hard or impossible to press. To avoid this, add the bottom safe area inset as padding.</p>
<pre><code class="language-tsx">const insets = useSafeAreaInsets();
<View
className="border-border bg-background flex-row items-center gap-4 border-t px-4 pt-3"
style={{ paddingBottom: insets.bottom + 12 }}>
</code></pre>
<p>The bottom padding is an inline <code>style</code> instead of a class because its value depends on the device and is only known when the app runs. Using <code>className</code> and <code>style</code> on the same element is fine, and this is the case it's meant for.</p>
<p>On devices with no gesture bar, and in a browser tab, <code>insets.bottom</code> is 0, so the extra <code>12</code> keeps the bar from looking cramped.</p>
<p>Create the full component in <code>components/cart/checkout-bar.tsx</code>. It includes the confirmation dialog, which Step 10 explains.</p>
<pre><code class="language-tsx">import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
import { Button } from '@/components/ui/button';
import { Icon } from '@/components/ui/icon';
import { Text } from '@/components/ui/text';
import { money } from '@/lib/menu';
import * as Haptics from 'expo-haptics';
import { ArrowRightIcon } from 'lucide-react-native';
import { View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
type CheckoutBarProps = {
total: number;
itemCount: number;
pickupLabel: string;
name: string;
onConfirm: () => void;
};
export function CheckoutBar({ total, itemCount, pickupLabel, name, onConfirm }: CheckoutBarProps) {
const insets = useSafeAreaInsets();
const empty = itemCount === 0;
function confirm() {
// expo-haptics ships a web implementation that falls back to navigator.vibrate,
// so this needs no platform guard.
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
onConfirm();
}
return (
<View
className="border-border bg-background flex-row items-center gap-4 border-t px-4 pt-3"
style={{ paddingBottom: insets.bottom + 12 }}>
<View className="flex-1">
<Text variant="muted" className="text-xs">
{empty ? 'Nothing in the cart' : `${itemCount} ${itemCount === 1 ? 'item' : 'items'}`}
</Text>
<Text className="text-xl font-semibold tabular-nums">{money(total)}</Text>
</View>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size="lg" disabled={empty}>
<Text>Place order</Text>
<Icon as={ArrowRightIcon} />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Send this to the counter?</AlertDialogTitle>
<AlertDialogDescription>
{`${money(total)} for ${name.trim() || 'pickup'}, ${pickupLabel.toLowerCase()}. We start making it right away, so it cannot be cancelled after this.`}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
<Text>Not yet</Text>
</AlertDialogCancel>
<AlertDialogAction onPress={confirm}>
<Text>Send it</Text>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</View>
);
}
</code></pre>
<h3 id="heading-a-note-on-thin-borders">A Note on Thin Borders</h3>
<p><code>border-t</code> draws a line that's one logical point thick, which is two or three physical pixels on most modern screens. Next to a true one-pixel line, it looks heavy. React Native's <code>StyleSheet.hairlineWidth</code> gives you the thinnest line the screen can draw, and the template connects it to Tailwind with this line in <code>global.css</code>:</p>
<pre><code class="language-css">--spacing-hairline: hairlineWidth();
</code></pre>
<p>This adds <code>hairline</code> to Tailwind's spacing scale, so <code>h-hairline</code> gives you a divider that's one physical pixel tall. The Common Mistakes section later explains one catch when you try to use it as an override.</p>
<p>That's the checkout bar in place. Scroll to the bottom of the screen and check that it stays below the bill:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5e0f2c2e490269cb30227a2b/a0c4a55c-a723-4a00-b6ff-c46e75c27b30.png" alt="The checkout bar at the bottom after scrolling down" style="display: block;" width="600" height="400" loading="lazy">
<p>Scroll back to the top and the bar stays in the same place:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5e0f2c2e490269cb30227a2b/5af1750b-9b8c-422d-9273-6b2321096e73.png" alt="The checkout bar at the bottom after scrolling up" style="display: block;" width="600" height="400" loading="lazy">
<p>On a phone with a notch, the button should be above the gesture bar. If you remove all items with the trash buttons, the button should be disabled and the label should say "Nothing in the cart".</p>
<h2 id="heading-step-10-add-the-confirmation-and-put-the-screen-together">Step 10: Add the Confirmation and Put the Screen Together</h2>
<p>The checkout bar already contains the confirmation dialog. Before putting the full screen together, there are two differences between the React Native implementation and the web version that are worth looking at.</p>
<h3 id="heading-1-it-also-needs-the-portalhost">1. It Also Needs the <code>PortalHost</code></h3>
<p>On iOS, it also wraps itself in <code>FullWindowOverlay</code> from <code>react-native-screens</code>, so it draws above the native navigation header instead of under it. This is handled inside <code>alert-dialog.tsx</code>, which is why that file imports something that looks unrelated.</p>
<h3 id="heading-2-the-footer-reverses-its-order-on-phones">2. The Footer Reverses its Order on Phones</h3>
<p><code>AlertDialogFooter</code> uses <code>flex-col-reverse</code> and only becomes a row at the <code>sm:</code> breakpoint. On a phone, the button you write last is shown on top. Writing Cancel first and Action second puts the confirm button where the thumb is and the cancel button below it.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5e0f2c2e490269cb30227a2b/0187ff24-1e34-414c-8189-e6f816f64d48.png" alt="The confirmation dialog" style="display: block;" width="600" height="400" loading="lazy">
<h3 id="heading-haptics">Haptics</h3>
<p>When the customer confirms the order, the <code>confirm</code> function calls <code>Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success)</code>. This gives a short success vibration on supported devices.</p>
<p>You might expect to wrap this call in a platform check because browsers don't have the same haptics support as native apps. In this case, <code>expo-haptics</code> already includes a web implementation that uses <code>navigator.vibrate</code>, with a fallback for iOS Safari. If the browser can't provide haptics, the call simply does nothing, so we don't need an extra platform check.</p>
<p><code>notificationAsync</code> is meant for outcomes such as success or failure. <code>impactAsync</code> is for physical collisions, and <code>selectionAsync</code> is for moving through a list of options. Using the wrong one won't cause an error, but it feels slightly off.</p>
<h3 id="heading-the-full-screen">The Full Screen</h3>
<p>Now we can bring everything together in the main screen. This is where we'll keep the state, calculate the bill using <code>billFor</code>, and pass the required values and callbacks to each component.</p>
<p>When an order is placed, it sets <code>orderNumber</code>, and the screen shows a confirmation instead. Inside that confirmation, <code><Stack.Screen options></code> changes the header title to "Order sent" without navigating anywhere, so the whole flow stays in one file and one route.</p>
<p>Replace <code>app/index.tsx</code> with:</p>
<pre><code class="language-tsx">import { Bill } from '@/components/cart/bill';
import { CheckoutBar } from '@/components/cart/checkout-bar';
import { OrderRow } from '@/components/cart/order-row';
import { PickupCard } from '@/components/cart/pickup-card';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Icon } from '@/components/ui/icon';
import { Separator } from '@/components/ui/separator';
import { Text } from '@/components/ui/text';
import { billFor, INITIAL_CART, PICKUP_TIMES, type CartItem } from '@/lib/menu';
import { Stack } from 'expo-router';
import { CircleCheckIcon, MoonStarIcon, SunIcon } from 'lucide-react-native';
import * as React from 'react';
import { ScrollView, View } from 'react-native';
import { Uniwind, useUniwind } from 'uniwind';
const SCREEN_OPTIONS = {
title: 'Your order',
headerRight: () => <ThemeToggle />,
};
export default function CartScreen() {
const [items, setItems] = React.useState<CartItem[]>(INITIAL_CART);
const [pickupValue, setPickupValue] = React.useState(PICKUP_TIMES[0].value);
const [name, setName] = React.useState('');
const [ownCup, setOwnCup] = React.useState(false);
const [orderNumber, setOrderNumber] = React.useState<string | null>(null);
const bill = billFor(items, ownCup);
const itemCount = items.reduce((count, item) => count + item.qty, 0);
const pickupLabel =
PICKUP_TIMES.find((time) => time.value === pickupValue)?.label ?? PICKUP_TIMES[0].label;
function changeQty(id: string, qty: number) {
setItems((current) =>
qty <= 0
? current.filter((item) => item.id !== id)
: current.map((item) => (item.id === id ? { ...item, qty } : item))
);
}
function placeOrder() {
setOrderNumber(`A-${Math.floor(Math.random() * 90 + 10)}`);
}
function startOver() {
setItems(INITIAL_CART);
setOwnCup(false);
setName('');
setPickupValue(PICKUP_TIMES[0].value);
setOrderNumber(null);
}
if (orderNumber) {
return (
<>
<Stack.Screen options={{ ...SCREEN_OPTIONS, title: 'Order sent' }} />
<View className="bg-background flex-1 items-center justify-center gap-6 p-8">
<Icon as={CircleCheckIcon} className="text-primary size-16" />
<View className="gap-2">
<Text variant="h3" className="text-center">
We are on it
</Text>
<Text variant="muted" className="text-center">
{`Order ${orderNumber} for ${name.trim() || 'pickup'}. Come to the far end of the counter and look for your name on the cup.`}
</Text>
</View>
<Button variant="outline" onPress={startOver}>
<Text>Start another order</Text>
</Button>
</View>
</>
);
}
return (
<>
<Stack.Screen options={SCREEN_OPTIONS} />
<View className="bg-background flex-1">
<ScrollView
className="flex-1"
contentContainerClassName="gap-4 p-4 pb-8"
keyboardShouldPersistTaps="handled"
automaticallyAdjustKeyboardInsets>
<Card className="gap-3 py-4">
<CardHeader className="px-4">
<CardTitle>Fernwood Coffee</CardTitle>
<CardDescription>112 Marine Drive, counter pickup</CardDescription>
</CardHeader>
<CardContent className="gap-4 px-4">
{items.length === 0 ? (
<Text variant="muted">Your cart is empty. Add something warm.</Text>
) : null}
{items.map((item, index) => (
<React.Fragment key={item.id}>
{index > 0 ? <Separator /> : null}
<OrderRow item={item} onChangeQty={changeQty} />
</React.Fragment>
))}
</CardContent>
</Card>
<Card className="gap-3 py-4">
<CardHeader className="px-4">
<CardTitle>Pickup</CardTitle>
</CardHeader>
<CardContent className="px-4">
<PickupCard
pickupValue={pickupValue}
onPickupValueChange={setPickupValue}
name={name}
onNameChange={setName}
ownCup={ownCup}
onOwnCupChange={setOwnCup}
/>
</CardContent>
</Card>
<Card className="gap-3 py-4">
<CardHeader className="px-4">
<CardTitle>Bill</CardTitle>
</CardHeader>
<CardContent className="px-4">
<Bill {...bill} />
</CardContent>
</Card>
</ScrollView>
<CheckoutBar
total={bill.total}
itemCount={itemCount}
pickupLabel={pickupLabel}
name={name}
onConfirm={placeOrder}
/>
</View>
</>
);
}
const THEME_ICONS = {
light: SunIcon,
dark: MoonStarIcon,
};
function ThemeToggle() {
const { theme } = useUniwind();
return (
<Button
onPressIn={() => Uniwind.setTheme(theme === 'dark' ? 'light' : 'dark')}
size="icon"
variant="ghost"
className="ios:size-9 web:mx-4 rounded-full"
accessibilityLabel="Switch theme">
<Icon as={THEME_ICONS[theme ?? 'light']} className="size-5" />
</Button>
);
}
</code></pre>
<p>Notice that <code>pickupLabel</code> is the fix from Step 7 in use. The screen looks up the label from <code>PICKUP_TIMES</code> and passes a plain string to <code>CheckoutBar</code>, so <code>CheckoutBar</code> never touches an <code>Option</code> object or the incorrect web label.</p>
<p>To test it, place an order. The dialog should show your name and the pickup time you chose (such as "in 30 minutes"), not the raw value <code>30</code>. Confirm the order, and you should see the confirmation screen:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5e0f2c2e490269cb30227a2b/36e526dd-72aa-4ed6-84f0-ae16abf720a2.png" alt="The order sent screen" style="display: block;" width="600" height="400" loading="lazy">
<p>Tap "Start another order" and the cart returns to its original state.</p>
<p>The checkout screen is now working, and each part is kept in its own component. You can use the same approach when you build other screens: keep the component focused, pass the data it needs through props, and let the shared theme handle the styling.</p>
<h2 id="heading-step-11-add-dark-mode">Step 11: Add Dark Mode</h2>
<p>We already added the theme toggle in Step 10. Here's the component again so we can look at how it switches between light and dark mode:</p>
<pre><code class="language-tsx">import { Uniwind, useUniwind } from 'uniwind';
function ThemeToggle() {
const { theme } = useUniwind();
return (
<Button
onPressIn={() => Uniwind.setTheme(theme === 'dark' ? 'light' : 'dark')}
size="icon"
variant="ghost"
className="ios:size-9 web:mx-4 rounded-full"
accessibilityLabel="Switch theme">
<Icon as={THEME_ICONS[theme ?? 'light']} className="size-5" />
</Button>
);
}
</code></pre>
<p><code>Uniwind.setTheme</code> changes the values behind every token, so everything that uses <code>bg-card</code>, <code>text-muted-foreground</code>, and similar classes updates at once. <code>useUniwind</code> subscribes the component to the current theme, so the icon changes too.</p>
<p>This code also uses the <code>ios:</code> and <code>web:</code> prefixes. Uniwind supports platform variants, so <code>web:mx-4</code> only adds margin in the browser, and <code>ios:size-9</code> only applies on iOS. It's similar to <code>Platform.select</code>, but it stays inside the class string.</p>
<p>Three things aren't handled by <code>setTheme</code>, and if you forget them, you end up with a screen that's only partly dark. The template already sets up the first two.</p>
<h3 id="heading-1-the-navigation-header">1. The Navigation Header</h3>
<p>React Navigation takes its colors from a plain object, so the root layout needs to pass it the right one:</p>
<pre><code class="language-tsx">const { theme } = useUniwind();
<ThemeProvider value={NAV_THEME[theme ?? 'light']}>
</code></pre>
<h3 id="heading-2-the-status-bar">2. The Status Bar</h3>
<p>The clock and battery icons are drawn by the operating system, so you need to tell it which style to use. Without this, you can get dark text on a dark bar.</p>
<pre><code class="language-tsx"><StatusBar style={theme === 'dark' ? 'light' : 'dark'} />
</code></pre>
<h3 id="heading-3-saving-the-choice">3. Saving the Choice</h3>
<p><code>Uniwind.setTheme</code> only applies to the current session. The next time the app starts, it follows the system setting again. To keep the user's choice, save it with <code>expo-secure-store</code> or <code>AsyncStorage</code> and call <code>setTheme</code> when the app starts. You can call <code>setTheme('system')</code> to go back to following the operating system.</p>
<p>When you tap the header icon, the cards, borders, inputs, switch, header, and status bar should all change together. You can explore more ready-made <a href="https://shadcnstudio.com/blocks">shadcn blocks</a> if you want examples of how these components are used in larger interfaces:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5e0f2c2e490269cb30227a2b/4e54a370-5335-42fe-95df-8eee7b033e78.png" alt="The screen in dark mode" style="display: block;" width="600" height="400" loading="lazy">
<p>Look for anything that stays light while the rest goes dark. A light header usually means the <code>ThemeProvider</code> is not receiving the new <code>NAV_THEME</code>.</p>
<h2 id="heading-step-12-create-your-own-theme">Step 12: Create Your Own Theme</h2>
<p>The screen is currently using the default gray theme. Let's change it to a coffee-inspired palette with warm cream backgrounds and brown accents. This will also help us see why the palette needs to be updated in both <code>global.css</code> and <code>lib/theme.ts</code>.</p>
<h3 id="heading-update-the-tokens">Update the Tokens</h3>
<p>Replace the <code>@layer theme</code> block in <code>global.css</code> with this coffee palette:</p>
<pre><code class="language-css">@layer theme {
:root {
@variant light {
--color-background: oklch(0.98 0.008 75);
--color-foreground: oklch(0.24 0.02 60);
--color-card: oklch(1 0.003 90);
--color-card-foreground: oklch(0.24 0.02 60);
--color-popover: oklch(1 0.003 90);
--color-popover-foreground: oklch(0.24 0.02 60);
--color-primary: oklch(0.44 0.06 45);
--color-primary-foreground: oklch(0.99 0.01 85);
--color-secondary: oklch(0.93 0.03 80);
--color-secondary-foreground: oklch(0.35 0.05 45);
--color-muted: oklch(0.95 0.012 80);
--color-muted-foreground: oklch(0.52 0.02 60);
--color-accent: oklch(0.92 0.04 78);
--color-accent-foreground: oklch(0.32 0.05 45);
--color-destructive: oklch(0.577 0.245 27.325);
--color-border: oklch(0.89 0.02 75);
--color-input: oklch(0.89 0.02 75);
--color-ring: oklch(0.44 0.06 45);
}
@variant dark {
--color-background: oklch(0.17 0.012 60);
--color-foreground: oklch(0.96 0.008 85);
--color-card: oklch(0.22 0.016 62);
--color-card-foreground: oklch(0.96 0.008 85);
--color-popover: oklch(0.22 0.016 62);
--color-popover-foreground: oklch(0.96 0.008 85);
--color-primary: oklch(0.85 0.09 75);
--color-primary-foreground: oklch(0.22 0.03 55);
--color-secondary: oklch(0.29 0.022 62);
--color-secondary-foreground: oklch(0.94 0.01 85);
--color-muted: oklch(0.27 0.018 62);
--color-muted-foreground: oklch(0.72 0.015 75);
--color-accent: oklch(0.31 0.03 65);
--color-accent-foreground: oklch(0.95 0.01 85);
--color-destructive: oklch(0.704 0.191 22.216);
--color-border: oklch(1 0 0 / 12%);
--color-input: oklch(1 0 0 / 15%);
--color-ring: oklch(0.85 0.09 75);
}
}
}
</code></pre>
<p>If you don't want to choose thirty colors by hand, you can generate a theme with the <a href="https://shadcnstudio.com/theme-generator">shadcn theme generator</a> or <a href="https://tweakcn.com/">tweakcn</a>. Both export CSS variables, but you need to make two changes before the output works in this project:</p>
<ol>
<li>Rename each variable to include the <code>--color-</code> prefix. For example, <code>--primary</code> becomes <code>--color-primary</code>.</li>
<li>Move the <code>:root</code> block inside <code>@variant light</code>, and the <code>.dark</code> block inside <code>@variant dark</code>.</li>
</ol>
<p>Also remove <code>--radius</code> from what you paste, because the template already defines it along with the derived sizes. Remove any <code>--shadow-*</code> variables too, since nothing in the registry reads them.</p>
<h3 id="heading-update-libthemets">Update lib/theme.ts</h3>
<p>After you save and reload, the result looks like this:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5e0f2c2e490269cb30227a2b/eb94958b-f7d3-4d64-9703-2b9153e4ea6f.png" alt="The new theme with a white header" style="display: block;" width="600" height="400" loading="lazy">
<p>The page is cream, the button is brown, and the badges have the right color, but the header is still white with a visible line under it. The reason is that React Navigation draws the header using <code>lib/theme.ts</code>, and that file still has the old palette. Nothing is broken and nothing warns you, so this mistake is easy to miss. If you're designing the same interface in Figma, the <a href="https://shadcnstudio.com/figma">shadcn Figma resources</a> can also help you keep the design and implementation aligned.</p>
<p>Update <code>lib/theme.ts</code> to match:</p>
<pre><code class="language-tsx">import { DarkTheme, DefaultTheme, type Theme } from 'expo-router/react-navigation';
export const THEME = {
light: {
background: '#fcf8f3',
foreground: '#261d16',
card: '#fffffd',
primary: '#6e4836',
border: '#e3d9cd',
destructive: '#e7000b',
/* the rest of the list */
},
dark: {
background: '#130e0a',
foreground: '#f4f1ec',
card: '#201913',
primary: '#f1c68b',
border: '#3a3129',
destructive: '#ff6467',
/* the rest of the list */
},
};
export const NAV_THEME: Record<'light' | 'dark', Theme> = {
light: {
...DefaultTheme,
colors: {
background: THEME.light.background,
border: THEME.light.border,
card: THEME.light.background,
notification: THEME.light.destructive,
primary: THEME.light.primary,
text: THEME.light.foreground,
},
},
dark: {
...DarkTheme,
colors: {
background: THEME.dark.background,
border: THEME.dark.border,
card: THEME.dark.background,
notification: THEME.dark.destructive,
primary: THEME.dark.primary,
text: THEME.dark.foreground,
},
},
};
</code></pre>
<p>This isn't a direct copy of <code>global.css</code>, so three points need explanation:</p>
<ul>
<li><strong>Use hex colors instead of</strong> <code>oklch()</code><strong>:</strong> Uniwind converts <code>oklch()</code> for your classes, but React Navigation does not, so give it a format that React Native can read directly. You can convert a color with any online converter, or paste the <code>oklch()</code> value into your browser's developer tools color picker and read the hex value.</li>
<li><strong>Map React Navigation's</strong> <code>card</code> <strong>to your</strong> <code>background</code><strong>, not your</strong> <code>card</code><strong>.</strong> The two names look the same but mean different things. In shadcn, <code>card</code> is a raised surface on top of the page. In React Navigation, <code>card</code> is the background of the screen and header. If you map them by name, the header ends up a shade lighter than the page. You won't notice this with the stock template because it uses the same value for <code>card</code> and <code>background</code>. It only shows up once you create a theme where cards stand out from the page, like this one.</li>
<li><strong>Use a solid color in place of a transparent border.</strong> In dark mode, <code>--color-border</code> is <code>oklch(1 0 0 / 12%)</code>, which is white at 12% opacity. A navigation theme needs a solid color, so work out what that border looks like on your background and use that value.</li>
</ul>
<p>After this change, the header and page match in both light and dark mode, and you didn't edit any component files:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5e0f2c2e490269cb30227a2b/f24b38ac-9742-44d3-92d6-2061477bc314.png" alt="The final theme with a matching header" style="display: block;" width="600" height="400" loading="lazy">
<img src="https://cdn.hashnode.com/uploads/covers/5e0f2c2e490269cb30227a2b/8627fc51-5083-4f8f-bae4-370f860b389e.png" alt="The final theme in the other mode" style="display: block;" width="600" height="400" loading="lazy">
<p>Switch between light and dark a few times and watch the header. If it's a shade lighter or darker than the page, one of the values in <code>lib/theme.ts</code> is still from the old palette.</p>
<h2 id="heading-common-mistakes-and-how-to-fix-them">Common Mistakes and How to Fix Them</h2>
<p>If you're coming from web development, some of these differences may catch you off guard. I've collected the issues covered in the tutorial in the table below, along with the fix for each one. You can refer back to it when something doesn't behave as expected.</p>
<table>
<thead>
<tr>
<th>What you do</th>
<th>What happens</th>
<th>The fix</th>
</tr>
</thead>
<tbody><tr>
<td><code><Badge>Medium</Badge></code></td>
<td>Red screen: "Text strings must be rendered within a <code><Text></code>."</td>
<td>Wrap the text: <code><Badge><Text>Medium</Text></Badge></code>. The same applies to <code>Button</code>.</td>
</tr>
<tr>
<td><code>{count && <Line/>}</code> when <code>count</code> is <code>0</code></td>
<td>The same error, caused by a stray <code>0</code></td>
<td><code>{count > 0 ? <Line/> : null}</code></td>
</tr>
<tr>
<td><code>space-y-3</code> on a column</td>
<td>Nothing happens and there is no warning</td>
<td>Use <code>gap-3</code> on the parent</td>
</tr>
<tr>
<td><code>className="p-4"</code> on a <code>ScrollView</code></td>
<td>Padding is clipped and gaps are ignored</td>
<td>Use <code>contentContainerClassName</code></td>
</tr>
<tr>
<td><code>truncate</code> on a long name</td>
<td>The text isn't truncated and the price is pushed off screen</td>
<td>Use <code>numberOfLines={1}</code></td>
</tr>
<tr>
<td>Using <code>flex</code> and expecting a row</td>
<td>Items stack vertically</td>
<td>React Native defaults to a column, so add <code>flex-row</code></td>
</tr>
<tr>
<td><code>hover:bg-accent</code></td>
<td>Nothing happens on a device</td>
<td>Use <code>active:</code> for the pressed state</td>
</tr>
<tr>
<td>Deleting <code>PortalHost</code> from the layout</td>
<td>Dropdowns open into nothing, with no error</td>
<td>Put it back</td>
</tr>
<tr>
<td>A Select near the bottom of the screen</td>
<td>The last option is under the home indicator</td>
<td>Pass <code>insets</code> to <code>SelectContent</code></td>
</tr>
<tr>
<td>Showing <code>option.label</code> from <code>onValueChange</code></td>
<td>Correct on native, but the raw value on the web</td>
<td>Look up the label from your own data</td>
</tr>
<tr>
<td>A bottom bar with only <code>p-4</code></td>
<td>The button is under the gesture bar</td>
<td>Use <code>paddingBottom: insets.bottom + 12</code> in <code>style</code></td>
</tr>
<tr>
<td>28-point stepper buttons</td>
<td>Hard to tap with a thumb</td>
<td>Add <code>hitSlop={8}</code></td>
</tr>
<tr>
<td>Changing only <code>global.css</code> for a new theme</td>
<td>The header keeps the old colors</td>
<td>Update <code>lib/theme.ts</code> too</td>
</tr>
<tr>
<td><code><Avatar></code> without <code>alt</code></td>
<td>TypeScript won't compile</td>
<td><code>alt</code> is required, and it becomes the accessibility label</td>
</tr>
<tr>
<td><code>rounded-2xl</code> on <code>Avatar</code> only</td>
<td>A circular fallback inside rounded corners</td>
<td>Repeat it on <code>AvatarFallback</code></td>
</tr>
<tr>
<td>Guarding haptics with <code>Platform.OS !== 'web'</code></td>
<td>A working web fallback is removed</td>
<td><code>expo-haptics</code> already handles the web</td>
</tr>
<tr>
<td>Right-aligned prices in a normal font</td>
<td>Digits shift whenever the number changes</td>
<td>Use <code>tabular-nums</code>, which also works on native</td>
</tr>
<tr>
<td><code><Separator className="h-hairline" /></code></td>
<td>Both height classes are kept</td>
<td>See below</td>
</tr>
</tbody></table>
<p>The last row needs more explanation, because it looks like a bug in <code>cn()</code>, but it's not.</p>
<p><code>cn()</code> uses <code>tailwind-merge</code>, which resolves conflicts between classes. It knows the default Tailwind scale, but it doesn't know about custom values you declare in <code>global.css</code>. The value <code>hairline</code> comes from <code>--spacing-hairline: hairlineWidth()</code>, so <code>tailwind-merge</code> can't tell that <code>h-hairline</code> and <code>h-[1px]</code> set the same property, and it keeps both:</p>
<pre><code class="language-jsx">cn('h-[1px] w-full', 'h-hairline my-1') // 'h-[1px] w-full h-hairline my-1'
cn('h-[1px] w-full', 'h-2 my-1') // 'w-full h-2 my-1'
</code></pre>
<p>The second result is correct. In the first, which height wins depends on the order of the CSS. The other overrides in this tutorial, such as <code>size-14</code> over <code>size-8</code>, work correctly because those are names <code>tailwind-merge</code> recognizes. If you want it to understand your custom scale, you can teach it with <code>extendTailwindMerge</code> in <code>lib/utils.ts</code>. Otherwise, use an arbitrary value, or edit the component file directly, since you own it.</p>
<p>There's a related case in the stepper buttons. They pass <code>size-7</code> to a <code>Button</code> whose icon size is <code>h-10 w-10 sm:h-9 sm:w-9</code>:</p>
<pre><code class="language-jsx">cn('h-10 w-10 sm:h-9 sm:w-9', 'size-7 rounded-full')
// 'sm:h-9 sm:w-9 size-7 rounded-full'
</code></pre>
<p><code>size-7</code> replaces the base <code>h-10 w-10</code>, but the two <code>sm:</code> classes stay, because <code>tailwind-merge</code> treats a responsive variant as a different class. On a phone this makes no difference, since <code>sm:</code> starts at 640px and phones are narrower than that. In a desktop browser, though, those buttons grow to 36 points.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>In this tutorial, we built a coffee shop cart and checkout screen with React Native Reusables, Expo, and Uniwind. We added the order rows, pickup options, bill summary, fixed checkout bar, and confirmation dialog. We also looked at how the registry and CLI add component source code to the project and how the theme tokens control the app's colors.</p>
<p>One thing worth getting into the habit of is checking the component source when something doesn't behave the way you expect. Since the components live in your project, you can open them and see how they work. That's how we understood the difference in the Select label between web and native. It also makes customization easier because you can see which part of the component needs to change instead of adding more workarounds around it.</p>
<p>You can find the complete project in the <a href="https://github.com/shadcnstudio/shadcn-react-native-app">GitHub repository</a>. If you run into an issue or want to compare your code with the finished screen, clone the repository and check the implementation. If you're looking for more complete starting points for your next project, take a look at <a href="https://shadcnstudio.com/templates">shadcn templates</a>.</p>
<p>I captured the screenshots using the React Native Web build at a phone-sized viewport. The same project also runs on iOS and Android. I've called out the platform differences in the relevant sections, including the Select label and portal host in Step 7, safe area handling in Step 9, and haptics in Step 10.</p>
<h3 id="heading-resources">Resources</h3>
<p><strong>React Native Reusables</strong></p>
<ul>
<li><a href="https://reactnativereusables.com/">reactnativereusables.com</a>: documentation and the registry</li>
<li><a href="https://reactnativereusables.com/docs">Introduction</a>: the list of differences from web shadcn</li>
<li><a href="https://reactnativereusables.com/docs/cli">CLI reference</a>: the <code>init</code>, <code>add</code>, and <code>doctor</code> commands</li>
<li><a href="https://reactnativereusables.com/docs/create-your-own-registry">Create your own registry</a>: publish components your team can add with the CLI</li>
<li><a href="https://github.com/founded-labs/react-native-reusables">The repository</a>: MIT licensed</li>
<li>Any registry entry is a public URL: <code>reactnativereusables.com/r/uniwind/<name>.json</code> or <code>.../r/nativewind/<name>.json</code></li>
</ul>
<p><strong>Tools used</strong></p>
<ul>
<li><a href="https://rnprimitives.com/">RN Primitives</a></li>
<li><a href="https://docs.uniwind.dev/">Uniwind</a></li>
<li><a href="https://www.nativewind.dev/">Nativewind</a></li>
<li><a href="https://docs.expo.dev/router/introduction/">Expo Router</a></li>
<li><a href="https://ui.shadcn.com/">shadcn/ui</a> and the <a href="https://ui.shadcn.com/docs/registry">registry specification</a></li>
</ul>
<p><strong>APIs used</strong></p>
<ul>
<li><a href="https://github.com/appandflow/react-native-safe-area-context"><code>useSafeAreaInsets</code></a></li>
<li><a href="https://docs.expo.dev/versions/latest/sdk/haptics/"><code>expo-haptics</code></a></li>
<li><a href="https://reactnative.dev/docs/view#hitslop"><code>hitSlop</code></a></li>
<li><a href="https://reactnative.dev/docs/text#numberoflines"><code>numberOfLines</code></a></li>
<li><a href="https://github.com/dcastil/tailwind-merge"><code>tailwind-merge</code></a></li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/color_value/oklch"><code>oklch()</code></a></li>
</ul>