If you've built a couple of React Native apps, the following probably feels familiar. You start with a colors.ts file and a simple Button 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.
The setup works at first, but after a few design changes, even a small update can mean checking several files across the project.
Here's what a typical color file looks like after a while:
export const colors = { primary: '#6e4836', primaryPressed: '#5a3a2b', textPrimary: '#261d16', textSecondary: '#72665e', // and forty more }; And here's what a typical Button looks like after a few redesigns:
<Button variant="primary" size="md" fullWidth loading={busy} containerStyle={styles.checkoutButton} labelStyle={styles.checkoutLabel} /> 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.
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.
Part of the problem is how many component libraries are distributed. Their code lives in node_modules, 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.
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 React Native Reusables.
To make this practical, I'll use React Native Reusables, Expo, and Uniwind to build a cart and checkout screen for a coffee shop's pickup orders. 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 shadcn templates.
I'll also point out a few things that are easy to miss when working with these components, including:
- How shadcn/ui and its CLI work, and why you own the component code
- How design tokens (named colors) replace a
colors.tsfile - How to use components such as Card, Select, Switch, and AlertDialog in React Native
- How to handle mobile-specific issues, such as safe areas, keyboards, and tap targets
- How to switch between light and dark mode, and how to change the whole theme from one file
Table of Contents
- Prerequisites
- Tools You Will Use
- How shadcn/ui Works
- What React Native Reusables Gives You
- What You're Building
- Step 1: Create the App
- Step 2: Understand the Theme File
- Step 3: Read Three Core Components
- Step 4: Add the Components You Need
- Step 5: Set Up the Data and the Screen
- Step 6: Build the Order Rows
- Step 7: Build the Pickup Options
- Step 8: Build the Bill Component
- Step 9: Build the Fixed Checkout Bar
- Step 10: Add the Confirmation and Put the Screen Together
- Step 11: Add Dark Mode
- Step 12: Create Your Own Theme
- Common Mistakes and How to Fix Them
- Conclusion
Prerequisites
To follow along, you should have:
- Node.js 20 or newer. You can use pnpm, npm, yarn, or bun. The commands in this tutorial use pnpm, so swap the prefix if you use something else.
- Basic knowledge of React and TypeScript, especially hooks such as
useState. - Some familiarity with React Native, such as
View,Text, andScrollView. You don't need to know Tailwind, but it helps. - Optional: 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.
You don't need any accounts or API keys, and nothing here costs money.
Tools You Will Use
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.
shadcn/ui 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.
React Native Reusables 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.
RN Primitives 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 select 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.
Uniwind is a tool that lets you style React Native components with Tailwind CSS class names, such as className="bg-primary p-4". It converts those classes into regular React Native styles while the app is being bundled, so there's no extra work at runtime. Nativewind does a similar job, and React Native Reusables supports both. This tutorial uses Uniwind.
Expo and Expo Router provide the project setup, the development server, and file-based navigation. If you have used Next.js, Expo Router works in a similar way.
How shadcn/ui Works
The React Native Reusables documentation opens with a quote from shadcn:
This is not a component library. It is how you build your component library.
You won't add shadcn/ui as a regular dependency in package.json. It uses a registry 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.
To see what this looks like, here's the registry entry for the Badge 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:
{ "$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" } ] } The file is public, so you can look at it yourself:
curl https://reactnativereusables.com/r/uniwind/badge.json Here's what the important fields mean:
contentholds the full source code of the component, stored as a string.dependencieslists npm packages that the component needs. The CLI installs these first.registryDependencieslists other registry entries that the component needs. In this case, Badge needs the Text component, which is a small wrapper around React Native'sTextthat 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.
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.
There are a few reasons this approach is useful when you're building an app:
- You own the file: A component such as
components/ui/button.tsxsits 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. - Design lives in named colors instead of props: Colors are declared once as named roles, such as
primary,muted,border, anddestructive. You then use them in class names, likeclassName="bg-primary". This replaces thecolors.tsfile 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. - Behavior comes from a separate package: Things such as focus handling, open and close state, and screen reader support live in RN Primitives. You only write the styling.
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 --overwrite. 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.
What React Native Reusables Gives You
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.
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, shadcn components can be useful when you need more ready-made examples to work from.
| The difference | What it means | Where you will see it |
|---|---|---|
Portals need a PortalHost | On the web, floating content such as dropdowns renders into document.body. React Native has no document.body, so you need a PortalHost component to render into. | Step 7 |
| Styles don't cascade | A text color set on a parent view doesn't reach the text inside it, so React Native Reusables uses a Text component with a workaround. | Step 3 |
There are no data-* attributes | Web shadcn changes styles based on attributes like data-state. React Native has none, so variants depend on props and state. | Step 7 |
| Icons take props instead of classes | Icon libraries expect color and size props, so icons are wrapped to accept class names. | Step 3 |
What You're Building
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:
| Part of the screen | Components used | The mobile problem it teaches |
|---|---|---|
| Item rows | Card, Avatar, Badge, Button | No cascading styles, no truncate, small tap targets |
| Quantity stepper | Button, Icon | Icons take props, not classes |
| Pickup time | Select, Label | Dropdowns need a portal |
| Name field | Input, Label | The keyboard can cover the field |
| Own cup toggle | Switch, Label | Making the label text tappable |
| Bill | Separator, Text | Digits that don't line up |
| Checkout bar | Button, AlertDialog | No position: fixed, and the home indicator |
Here's the finished screen:
The complete code is available in the GitHub repository if you want to compare your work with the final result.
Step 1: Create the App
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:
pnpm dlx @react-native-reusables/cli@latest init Answer the CLI's Questions
The CLI asks four questions.
What is the name of your project? The name you type becomes the folder name. It suggests my-app.
Select a template. There are three options, and the one you need isn't the one selected by default:
❯ Minimal (Nativewind) Minimal (Uniwind) Clerk auth (Nativewind) Press the down arrow once and choose Minimal (Uniwind). 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.
Would you like to install dependencies? Choose yes, and select the package manager you use.
Would you like to initialize a Git repository? Choose yes. You're about to replace the starter screen, and having a commit to compare against is helpful.
Look at the Project Structure
The CLI creates the following files:
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 Before moving on, let's take a look at a few files in this structure. You'll use them throughout the tutorial:
components.jsonis the same shadcn configuration file used on the web. It tells the CLI where to put components (@/components/ui) and where thecn()helper lives (@/lib/utils).global.cssholds your theme tokens, which you'll learn about in Step 2.lib/utils.tsexportscn(), a helper that combines class names and resolves conflicts between them.app/_layout.tsxcontains aPortalHostcomponent. It looks like leftover scaffolding, but you must not delete it. Step 7 explains why.
Uniwind reads global.css while Metro bundles the app and turns your class strings into React Native style objects. This happens at build time, so a long className string doesn't slow down your app.
Run the App
Start the development server:
pnpm dev Press w to open it in the browser, i for the iOS simulator, or a for Android. The starter screen has a theme toggle in the header, which shows that the theme setup is working.
You can also run a diagnostic command at any point if something seems wrong:
pnpm dlx @react-native-reusables/cli@latest doctor It checks your setup and reports which styling library it detects. For this project, the output should be:
ℹ Styling Library: Uniwind ✔ All checks passed. If it says Nativewind, fix that before you add any components.
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:
Step 2: Understand the Theme File
Now that the app is running, let's look at its theme setup. The global.css file is where we'll define the colors used by the components. If you've previously kept these values in a colors.ts or theme.ts file, this is the setup that replaces it.
@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 */ } } } There are two conventions in this file that you need to know.
First, the --color- prefix creates the class names. When you declare --color-primary, Tailwind automatically creates bg-primary, text-primary, and border-primary. If you declare --primary instead, you only get a CSS variable that nothing uses.
This is important because most shadcn theme generators produce the web format, where the variables are named --primary. If you paste that output without renaming the variables, nothing breaks, but nothing changes either. Step 12 shows you how to convert them.
Second, @variant light and @variant dark replace :root and .dark. On the web, dark mode works by adding a .dark class to the page. A phone has no page to add a class to, so Uniwind manages the switch. When you call Uniwind.setTheme, it swaps the active values. You'll use this in Step 11.
You may also notice that the file uses oklch() 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.
The Palette Also Exists in lib/theme.ts
If you open lib/theme.ts, 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.
Try It: Change One Color
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:
--color-primary: oklch(0.6 0.24 25); Save the file and look at the starter screen. The Browse the Docs button is now red, even though you didn't edit button.tsx:
Notice that every component using bg-primary 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.
Step 3: Read Three Core Components
The starter project already gives us three components: Text, Button, and Icon. 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.
The Text Component
In React Native, styles don't cascade the way they do in CSS. If you set a text color on a View, the text inside it doesn't inherit that color. This is a problem for a component such as <Button variant="destructive">, which needs its label to be white. The label is a Text component that the Button doesn't control, because it comes in through children. On the web, the Button would simply set a color and let it flow down to the label.
React Native Reusables solves this with React context. This is the code in components/ui/text.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} /> ); } A parent component, such as Button, puts a class string into the context. Every Text inside it reads that string and applies it. Your own className still wins because it comes last in the cn() call.
The Text component also has a variant prop with options like h1 to h4, p, code, lead, large, small, and muted. For example, <Text variant="muted"> gives you the mobile version of text-muted-foreground text-sm. The variants also set the role and aria-level props, so screen readers announce headings correctly without any extra work.
The Button Component
The Button in components/ui/button.tsx uses cva (class-variance-authority) the same way the web version does. Here's a shortened version:
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' }) ), /* ... */ }, }, } ); There are four differences from the web version:
flex-rowis written out: React Native lays out children in a column by default, which is the opposite of CSS. Every horizontal layout in this tutorial includesflex-rowfor that reason.active:replaceshover:: A finger can't really hover, soactive:is the pressed state on a phone.- Web-only classes are wrapped in
Platform.select: Classes such asfocus-visible:ring-[3px]mean nothing in React Native, so they're only included in the web build. - A
TextClassContext.Providerwraps thePressable: This uses the context from the previous section, and it's why<Button variant="destructive"><Text>Delete</Text></Button>has white text automatically.
The Icon Component
Icons from lucide-react-native take color and size as props, not as class names. The Icon component in components/ui/icon.tsx teaches Uniwind how to convert one into the other:
const StyledIcon = withUniwind(IconImpl, { size: { fromClassName: 'className', styleProperty: 'width' }, color: { fromClassName: 'className', styleProperty: 'color' }, }); This code reads the width from the class string and passes it as size, and it reads the color and passes it as color. That's why <Icon as={PlusIcon} className="size-4 text-muted-foreground" /> works the way you would expect. withUniwind is useful any time you use a third-party component that takes props where you want to use classes.
You don't need to run anything for this step. The important part is understanding what TextClassContext is doing. Once that clicks, the other components in the registry are much easier to follow.
Step 4: Add the Components You Need
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:
pnpm dlx @react-native-reusables/cli@latest add card separator input label select switch badge alert-dialog avatar The CLI detects that you're using Uniwind and writes the matching versions. It also installs any required @rn-primitives packages and adds a file called native-only-animated-view.tsx, which Select and AlertDialog use for their animations.
The output looks like this:
ℹ 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 The CLI created ten files, which is more than the nine you asked for, because native-only-animated-view.tsx 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.
Run pnpm dev again to make sure the app still starts. The components/ui/ folder should now contain thirteen files.
Step 5: Set Up the Data and the Screen
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 $4.7499999.
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.
Create lib/menu.ts:
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 }; } The PICKUP_TIMES list is in this file instead of inside a component because two different components need it. You'll see why in Step 7.
The Screen Layout
This is basically how app/index.tsx looks. The parts that don't exist yet are left as comments, and you'll write the full file later in Step 10.
<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> There are three details in this layout that you should examine more closely:
- First,
classNameon aScrollViewstyles the scroll container, not its content. This is the class name version of thestyleandcontentContainerStylesplit in React Native. If you put padding inclassName, it gets clipped, andgaphas no effect. UsecontentContainerClassNamefor anything that should apply to the content. Uniwind adds this prop toScrollView. - There's no
space-y-4. In CSS,space-y-*works by selecting siblings, and React Native has no selectors. Usegap-4on the parent instead, which React Native supports on iOS, Android, and the web. keyboardShouldPersistTaps="handled"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.automaticallyAdjustKeyboardInsetsis an iOS feature that scrolls the content so the focused input isn't hidden behind the keyboard.
At this stage, you should see only a header with the new title and an empty page below it:
Step 6: Build the Order Rows
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.
Create components/cart/order-row.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> ); } A few details in this component deserve a closer look.
First, Avatar requires the alt prop. TypeScript won't compile without it. The value becomes the accessibility label, so a screen reader says "Flat white" instead of just "image".
The radius is repeated on the avatar fallback. Avatar and AvatarFallback are each rounded-full by default. If you only set rounded-2xl on Avatar, the emoji background stays a circle and pokes out of the rounded square.
You should also use numberOfLines={1} instead of truncate. Tailwind's truncate relies on text-overflow: ellipsis, 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 numberOfLines prop is the React Native way to do this, and it also works in the web build.
The name has shrink and its column has flex-1. Without shrink, 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.
hitSlop={8} makes small buttons easier to tap. Apple recommends touch targets of at least 44 points and Android recommends 48dp, but a size-7 button is only 28 points wide. The hitSlop prop makes the touchable area larger without changing how the button looks. There's no CSS equivalent.
Finally, tabular-nums keeps digits the same width. In most fonts, the digit 1 is narrower than 8, so prices would shift sideways every time the quantity changes. Uniwind converts tabular-nums to React Native's fontVariant, so it works on iOS, Android, and the web.
After adding this component and rendering it inside a Card (which you'll do in Step 10), you should see three rows:
Tapping plus on the flat white should change its price from $9.50 to $14.25. 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.
Step 7: Build the Pickup Options
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.
Create components/cart/pickup-card.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> ); } Problem 1: Portals and the PortalHost
When a dropdown opens, it needs to appear above the other content on the screen. For that reason, SelectContent isn't rendered in the same place where you declare it in the component tree. It uses @rn-primitives/portal to render the content in another part of the app. This technique is called a portal.
On the web, a portal renders into document.body, 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 PortalHost reads that list and displays it. This is a simplified version of that code from node_modules/@rn-primitives/portal/dist/index.js:
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 */ } If no PortalHost is mounted, the content is saved but never displayed. The dropdown opens into nothing, and you get no error or warning.
The template already includes a PortalHost at the bottom of the root layout:
<ThemeProvider value={NAV_THEME[theme ?? 'light']}> <StatusBar style={theme === 'dark' ? 'light' : 'dark'} /> <Stack /> <PortalHost /> </ThemeProvider> It looks like unnecessary code, but you need to keep it. The AlertDialog in Step 10 uses it too.
Problem 2: Safe Area Insets
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.
To avoid this, pass the safe area insets to the content:
const insets = useSafeAreaInsets(); const contentInsets = { top: insets.top, bottom: insets.bottom, left: 16, right: 16 }; <SelectContent insets={contentInsets} className="w-full"> The insets prop is part of PositionedContentProps in @rn-primitives/types, 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.
Problem 3: The Select Value Is an Object
One more thing to watch out for is the value returned by onValueChange. It may look like you're selecting a string, but the callback returns an Option object:
type Option = { value: string; label: string } | undefined; This causes a bug that only appears on one platform. If you store the whole Option in state and show option.label later, such as in a confirmation dialog, it displays correctly on a phone ("In 30 minutes") but incorrectly in the browser ("30").
The reason is in the primitive's source code. This is the native version, from select.js:
onValueChange({ value: itemValue, label }); And this is the web version, from select.web.js. It uses Radix, which only returns a value:
onValueChange({ value: val, label: val }); So the label is correct on native, but o...