
Expo Design System
- 77 installs
- 191 repo stars
- Updated July 24, 2026
- pproenca/dot-skills
expo-design-system is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
Key points
- expo-design-system
- AI & Agent Building
- AI-coding skill
Expo Design System by the numbers
- 77 all-time installs (skills.sh)
- +6 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #5,358 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/pproenca/dot-skills --skill expo-design-systemAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 77 |
|---|---|
| repo stars | ★ 191 |
| Last updated | July 24, 2026 |
| Repository | pproenca/dot-skills ↗ |
How do I helps with ai & agent building tasks during ai-assisted development?
Helps with ai & agent building tasks during AI-assisted development.
Who is it for?
Best when you're working on ai & agent building and need structured help with expo-design-system.
Skip if: Teams with no ai & agent building needs, or anyone wanting a generic chat assistant without this specific workflow.
When should I use this skill?
When you need to helps with ai & agent building tasks during ai-assisted development, or when expo-design-system is a claude code skill for ai & agent building. it helps solo builders move faster with ai-assisted coding
What you get
Structured output aligned to expo-design-system: expo-design-system; AI & Agent Building; AI-coding skill.
Files
Airbnb DLS Expo React Native Design System Best Practices
Opinionated, strict design system engineering for Expo / React Native apps on the New Architecture, targeting both web and native iOS. Contains 63 rules across 11 categories, prioritized by impact. Derived from Airbnb's Design Language System (DLS), the Unistyles v3 documentation, and the React Native ecosystem (Reanimated, Gesture Handler, Skia, FlashList, Expo SDK). The styling engine is Unistyles v3; the component API follows the Airbnb DLS pattern of variant props over style escape hatches.
Mandated Architecture Alignment
This skill is the infrastructure layer — it teaches how to BUILD the design system itself, the React Native counterpart to ios-design-system. All code examples follow the same non-negotiable constraints:
- Feature modules import
@clinic/design-system+domain; never another feature's internals - The design system owns the Unistyles theme (tokens, breakpoints) and exports a curated public surface
- Reuse before you build: read the index, extend a variant, promote on the second use — never a parallel local component
- Components expose variant and slot props (Airbnb DLS); no raw
styleescape hatch - Animation and gestures run on the UI thread (Reanimated worklets + Gesture Handler)
- Lists use FlashList; the body-chart drawing surface uses React Native Skia
- One source feels native on both web and iOS: Unistyles
_webpseudo-states andPlatformsplits, never a forked web stylesheet - Targets the New Architecture (Fabric/JSI), Expo SDK 53+ / React Native 0.81+
Scope & Relationship to Sibling Skills
| Sibling Skill | Its Focus | This Skill's Focus |
|---|---|---|
ios-design-system | The SwiftUI design system for the same clinic app | The Expo / React Native counterpart |
expo-react-native-coder | Feature development (screens, navigation, data fetching) | Design system infrastructure (tokens, components, theming) |
expo-react-native-performance | App-wide performance optimization | Native-feel performance inside the design system |
react | General React patterns | Expo / React Native design system specifics |
expo-ios-hig | iOS native-feel decisions (navigation, system controls, Liquid Glass) | Cross-platform token/component architecture + web/iOS parity |
Clinic Architecture Contract (Expo / React Native)
All guidance in this skill assumes the clinic modular architecture:
- Feature modules depend on
@clinic/design-system+domainonly; the app target wires navigation (Expo Router) and dependency injection - The design system is a local package with a single public entry; raw token layers stay private
- Token source of truth is the Unistyles theme; features never define local tokens
- Server state uses TanStack Query: reads default to stale-while-revalidate, writes are optimistic and queued for offline sync
- The New Architecture is required for Unistyles v3, Reanimated worklets, Skia, and FlashList v2
When to Apply
Reference these guidelines when:
- Setting up a design system for a new Expo / React Native app
- Building token architecture (colors, typography, spacing, radius, elevation) in the Unistyles theme
- Designing component APIs — variants, slots, controlled/uncontrolled state, refs, accessibility
- Authoring component styles with Unistyles StyleSheet, variants, and dynamic functions
- Building the calendar, treatment-note editor, or Skia body-chart drawing surfaces
- Migrating ad-hoc styles to a governed token system
- Reviewing PRs for raw colors, inline styles, leaked
styleprops, or feature-local tokens - Tuning native feel — list virtualization, UI-thread animation, gestures, haptics, safe areas
- Making a component render and feel native on both web and iOS — hover/focus/cursor,
Platformsplits, and safe-area/haptics divergences - Deciding whether to build new or reuse — checking the design system index, extending vs forking, or using a native/
@expo/uicontrol instead of reimplementing one
Rule Categories by Priority
| Priority | Category | Impact | Prefix | Rules |
|---|---|---|---|---|
| 1 | Token Architecture | CRITICAL | token- | 6 |
| 2 | Theming & Adaptivity | CRITICAL | theme- | 5 |
| 3 | Component API Contracts | CRITICAL | api- | 8 |
| 4 | Cross-Platform Parity | CRITICAL | platform- | 5 |
| 5 | Reuse & System Fit | HIGH | reuse- | 4 |
| 6 | Styling Engine (Unistyles) | HIGH | style- | 6 |
| 7 | Typography & Iconography | HIGH | type- | 5 |
| 8 | Spacing, Layout & Safe Areas | HIGH | space- | 5 |
| 9 | Native-Feel & Performance | HIGH | perf- | 7 |
| 10 | Complex Domain Components | MEDIUM-HIGH | domain- | 6 |
| 11 | Governance & Consistency | MEDIUM | govern- | 6 |
Quick Reference
1. Token Architecture (CRITICAL)
- `token-three-layer-scale` - Layer tokens as raw, semantic, and component scales
- `token-define-in-unistyles-theme` - Keep the Unistyles theme as the single token source
- `token-no-raw-values-in-components` - Route every color and size through tokens
- `token-semantic-naming` - Name tokens by role, not by value
- `token-elevation-pairs` - Tokenize elevation as surface and shadow pairs
- `token-avoid-over-abstraction` - Stop at three token layers
2. Theming & Adaptivity (CRITICAL)
- `theme-runtime-not-rerender` - Switch themes via the runtime, no re-render
- `theme-stylesheet-theme-arg` - Read theme from the StyleSheet argument
- `theme-adaptive-system` - Follow the system color scheme by default
- `theme-breakpoints-responsive` - Use breakpoints, not Dimensions checks
- `theme-config-single-module` - One typed config module for themes and breakpoints
3. Component API Contracts (CRITICAL)
- `api-variants-over-style-prop` - Express visual options as variant props
- `api-no-style-escape-hatch` - No raw style prop on components
- `api-compound-variants` - Compound variants over per-combination components
- `api-slots-for-composition` - Slot props over a prop per element
- `api-controlled-uncontrolled` - Support controlled and uncontrolled state
- `api-forward-ref` - Forward refs from leaf components
- `api-accessibility-in-contract` - Require accessibility props in the contract
- `api-aschild-polymorphism` - Offer asChild instead of wrapper nesting
4. Cross-Platform Parity (CRITICAL)
- `platform-web-pseudo-states` - Add web hover, focus, and cursor to interactive components
- `platform-guard-native-only` - Guard native-only APIs behind Platform checks with web fallbacks
- `platform-divergence-split` - Isolate platform differences behind one component API
- `platform-input-model` - Design for pointer and touch, never hover-only
- `platform-shared-theme-parity` - One theme for web and native, with a known divergence map
5. Reuse & System Fit (HIGH)
- `reuse-inventory-first` - Read the design system index before writing any style
- `reuse-extend-not-fork` - Extend a shared component with a variant, don't fork a local one
- `reuse-promote-on-second-use` - Promote a pattern to the system on its second use
- `reuse-platform-component-first` - Reach for a native control before reimplementing one
6. Styling Engine (Unistyles) (HIGH)
- `style-stylesheet-create` - StyleSheet.create over inline objects
- `style-variants-api` - Variants over ternary style arrays
- `style-dynamic-functions` - Dynamic functions for prop-driven values
- `style-no-inline-array-merge` - No inline array merges in lists
- `style-withunistyles-third-party` - Theme third-party components with withUnistyles
- `style-press-states-from-variants` - Press and disabled states as variants
7. Typography & Iconography (HIGH)
- `type-scale-tokens` - Define a named typography scale
- `type-respect-font-scaling` - Respect OS font scaling
- `type-text-component-wrapper` - Route text through one typed component
- `type-font-loading-expo-font` - Load fonts before first paint
- `type-icon-registry` - Centralize icons in a typed registry
8. Spacing, Layout & Safe Areas (HIGH)
- `space-spacing-scale` - Use a spacing scale on a 4pt grid
- `space-safe-area-insets` - Apply safe-area insets at screen edges
- `space-touch-targets` - Size touch targets to at least 44 points
- `space-gap-over-margins` - Use gap over per-child margins
- `space-radius-tokens` - Tokenize corner radius by role
9. Native-Feel & Performance (HIGH)
- `perf-flashlist-for-lists` - FlashList over ScrollView lists
- `perf-reanimated-ui-thread` - Animate on the UI thread
- `perf-gesture-handler` - Gesture Handler over PanResponder
- `perf-expo-image` - Load images with expo-image and caching
- `perf-memoize-list-items` - Memoize rows and callbacks
- `perf-haptics-key-actions` - Add haptics on confirmations and toggles
- `perf-defer-offscreen-work` - Defer work past transitions
10. Complex Domain Components (MEDIUM-HIGH)
- `domain-calendar-virtualization` - Virtualize the appointment calendar by day
- `domain-note-editor-autosave` - Offline-first debounced note autosave
- `domain-bodychart-skia-canvas` - Draw body charts on a Skia canvas
- `domain-bodychart-gesture-paths` - Capture strokes via gestures and shared values
- `domain-compose-from-primitives` - Build domain components from primitives
- `domain-optimistic-writes` - Render optimistic UI for writes
11. Governance & Consistency (MEDIUM)
- `govern-design-system-package` - Package the design system with one entry
- `govern-lint-no-raw-values` - Lint against raw colors and inline styles
- `govern-prevent-local-tokens` - Prevent feature-local tokens
- `govern-storybook-catalog` - Catalog component variants in Storybook
- `govern-naming-conventions` - Enforce one naming convention
- `govern-incremental-migration` - Migrate to tokens incrementally
How to Use
Read individual reference files for detailed explanations and code examples:
- Section definitions - Category structure and impact levels
- Rule template - Template for adding new rules
Reference Files
| File | Description |
|---|---|
| references/_sections.md | Category definitions and ordering |
| assets/templates/_template.md | Template for new rules |
| metadata.json | Version and reference information |
Expo React Native design system
Version 0.3.0 Airbnb DLS / React Native Community May 2026
Note:
This document is mainly for agents and LLMs maintaining, generating, or reviewing Expo React Native design system code. Humans may also find it useful,
but guidance here is optimized for automation and consistency by AI-assisted workflows.
---
Abstract
Airbnb-DLS-aligned design system engineering for Expo / React Native apps targeting both web and native iOS, built on Unistyles v3, Reanimated, Skia, and FlashList. Contains 63 rules across 11 categories, prioritized by impact from critical (token architecture, theming, component API contracts, cross-platform web/iOS parity) through a reuse-first procedure that biases agents toward the shared system over local styling, to incremental (governance). Each rule pairs an incorrect and correct TSX example with quantified impact to guide design system code generation and review; examples use a clinic management app with calendar, treatment-note, and body-chart drawing surfaces.
---
Table of Contents
1. Token Architecture — CRITICAL
- 1.1 Avoid Abstracting Tokens Beyond Three Layers — MEDIUM (reduces indirection that slows onboarding and review)
- 1.2 Avoid Raw Color and Size Literals in Components — CRITICAL (eliminates ungoverned values that drift across screens)
- 1.3 Define Every Token in the Unistyles Theme — CRITICAL (eliminates duplicated token sources across the app)
- 1.4 Layer Tokens as Raw, Semantic, and Component Scales — CRITICAL (prevents palette-wide rebrands from touching component code)
- 1.5 Name Tokens by Role, Not by Value — CRITICAL (prevents rename churn when brand values change)
- 1.6 Tokenize Elevation as Surface and Shadow Pairs — HIGH (prevents inconsistent depth across light and dark themes)
2. Theming & Adaptivity — CRITICAL
- 2.1 Drive Tablet Layouts With Breakpoints, Not Dimensions Checks — HIGH (eliminates manual Dimensions branching that breaks on rotation)
- 2.2 Follow the System Color Scheme by Default — HIGH (eliminates manual light and dark branching in views)
- 2.3 Read Theme Values From the StyleSheet Argument — CRITICAL (prevents per-render theme subscriptions and inline style objects)
- 2.4 Register Themes and Breakpoints in One Typed Module — HIGH (prevents divergent theme definitions and untyped token access)
- 2.5 Switch Themes Through the Unistyles Runtime — CRITICAL (prevents a full JavaScript re-render on every theme change)
3. Component API Contracts — CRITICAL
- 3.1 Accept Slot Props for Flexible Composition — HIGH (eliminates a new boolean prop for every content permutation)
- 3.2 Avoid Exposing a Raw style Prop on Components — CRITICAL (prevents one-off overrides that bypass design tokens)
- 3.3 Bake Accessibility Into the Component Contract — HIGH (prevents inaccessible variants from shipping to clinicians)
- 3.4 Combine Variant Dimensions With Compound Variants — CRITICAL (reduces an N by M variant matrix to one component definition)
- 3.5 Express Visual Options as Variant Props — CRITICAL (prevents unbounded style drift on shared components)
- 3.6 Forward Refs From Every Leaf Component — HIGH (preserves focus and measurement access for callers)
- 3.7 Offer asChild Polymorphism Instead of Wrapper Nesting — MEDIUM (eliminates redundant wrapper nodes and duplicate press targets)
- 3.8 Support Both Controlled and Uncontrolled State — HIGH (prevents duplicate state wiring at every call site)
4. Cross-Platform Parity — CRITICAL
- 4.1 Add Web Hover, Focus, and Cursor to Interactive Components — CRITICAL (prevents interactive components from rendering inert on web (no hover, focus, or cursor))
- 4.2 Design for Pointer and Touch, Never Hover-Only — HIGH (prevents hover-only actions from being unreachable on touch)
- 4.3 Drive Web and Native From One Theme, With a Known Divergence Map — HIGH (prevents web and native styling from drifting through a forked theme)
- 4.4 Guard Native-Only APIs Behind Platform Checks With Web Fallbacks — CRITICAL (prevents native-only calls from silently no-opping with no feedback on web)
- 4.5 Isolate Platform Differences Behind One Component API — HIGH (eliminates duplicated platform branches scattered across call sites)
5. Reuse & System Fit — HIGH
- 5.1 Extend a Shared Component With a Variant, Don't Fork a Local One — HIGH (prevents near-duplicate components from diverging across features)
- 5.2 Promote a Pattern to the System on Its Second Use — MEDIUM-HIGH (prevents copy-pasted patterns from drifting across features)
- 5.3 Reach for a Native Control Before Reimplementing One in JavaScript — HIGH (prevents hand-built controls that lose native behavior and accessibility)
- 5.4 Read the Design System Index Before Writing Any Style — HIGH (prevents duplicate components and tokens that fragment the system)
6. Styling Engine — Unistyles — HIGH
- 6.1 Avoid Merging Styles With Inline Arrays in Lists — HIGH (prevents per-row array and object allocation in long lists)
- 6.2 Define Styles With StyleSheet.create, Not Inline Objects — HIGH (prevents a new style object on every render)
- 6.3 Drive Press and Disabled States From Variants — MEDIUM (eliminates duplicated Pressable style callbacks across buttons)
- 6.4 Implement Component Variants With the Variants API — HIGH (reduces conditional style branching to declarative variants)
- 6.5 Use Dynamic Functions for Per-Instance Style Values — HIGH (avoids inline style objects for prop-driven values)
- 6.6 Wrap Third-Party Components With withUnistyles — MEDIUM (preserves token theming for external components on theme change)
7. Typography & Iconography — HIGH
- 7.1 Centralize Icons in a Typed Icon Registry — MEDIUM (prevents inconsistent icon glyphs, sizes, and colors)
- 7.2 Define a Typography Scale as Named Tokens — HIGH (prevents arbitrary fontSize values across screens)
- 7.3 Load Custom Fonts With expo-font Before First Paint — MEDIUM (prevents a font flash on first render)
- 7.4 Respect OS Font Scaling in the Type Scale — HIGH (prevents clipped text at large accessibility font sizes)
- 7.5 Route All Text Through One Typed Text Component — HIGH (eliminates raw Text styling at call sites)
8. Spacing, Layout & Safe Areas — HIGH
- 8.1 Apply Safe-Area Insets at Screen Boundaries — HIGH (prevents content under notches and the home indicator)
- 8.2 Lay Out Stacks With Gap, Not Per-Child Margins — MEDIUM (eliminates stray trailing space from the last child in a stack)
- 8.3 Size Interactive Targets to at Least 44 Points — HIGH (prevents mis-taps on undersized controls)
- 8.4 Tokenize Corner Radius by Component Role — MEDIUM (prevents inconsistent rounding across surfaces)
- 8.5 Use a Spacing Scale Instead of Ad-Hoc Numbers — HIGH (eliminates ad-hoc padding values across screens)
9. Native-Feel & Performance — HIGH
- 9.1 Add Haptic Feedback to Confirmations and Toggles — LOW-MEDIUM (maintains a native-feel response on consequential actions)
- 9.2 Animate on the UI Thread With Reanimated Worklets — HIGH (maintains 60-120fps during gestures and transitions)
- 9.3 Defer Off-Screen Work Until After Transitions — MEDIUM (prevents dropped frames during navigation transitions)
- 9.4 Handle Gestures With Gesture Handler, Not PanResponder — HIGH (prevents touch handling from blocking the JavaScript thread)
- 9.5 Load Remote Images With expo-image and Caching — MEDIUM (prevents redundant network fetches and decode jank)
- 9.6 Memoize List Item Components and Callbacks — HIGH (prevents re-rendering every visible row on parent updates)
- 9.7 Render Long Lists With FlashList, Not ScrollView — HIGH (prevents mounting every off-screen row at once)
10. Complex Domain Components — MEDIUM-HIGH
- 10.1 Capture Drawing Strokes With Gestures and Shared Values — MEDIUM (avoids a React state update per touch point)
- 10.2 Compose Domain Components From Design System Primitives — MEDIUM (prevents domain screens from re-implementing tokens)
- 10.3 Draw Body-Chart Annotations on a Skia Canvas — MEDIUM-HIGH (maintains 60fps freehand drawing off the JavaScript thread)
- 10.4 Persist Treatment-Note Edits Offline-First With Debounce — MEDIUM-HIGH (prevents data loss when the app is suspended mid-note)
- 10.5 Render Optimistic UI for Appointment and Note Writes — MEDIUM (maintains responsiveness while a write syncs to the server)
- 10.6 Virtualize the Appointment Calendar by Day — MEDIUM-HIGH (prevents rendering a full month of time slots at once)
11. Governance & Consistency — MEDIUM
- 11.1 Catalog Every Component Variant in Storybook — MEDIUM (prevents undocumented variants from drifting unnoticed)
- 11.2 Enforce One Naming Convention for Tokens and Components — MEDIUM (reduces ambiguity and guesswork when looking up tokens)
- 11.3 Isolate the Design System as Its Own Package — MEDIUM (prevents feature code from importing private internals)
- 11.4 Lint Against Raw Colors and Inline Styles — MEDIUM (prevents ungoverned values from merging into the codebase)
- 11.5 Migrate Ad-Hoc Styles to Tokens Incrementally — MEDIUM (prevents a big-bang refactor that stalls feature work)
- 11.6 Prevent Feature Modules From Defining Local Tokens — MEDIUM (eliminates shadow token systems inside features)
---
References
1. https://www.unistyl.es/v3/start/introduction 2. https://www.unistyl.es/v3/guides/theming/ 3. https://www.unistyl.es/v3/references/web-only/ 4. https://www.infoq.com/news/2020/02/airbnb-design-system-react-conf/ 5. https://docs.swmansion.com/react-native-reanimated/docs/guides/performance/ 6. https://docs.swmansion.com/react-native-gesture-handler/ 7. https://shopify.github.io/react-native-skia/ 8. https://shopify.github.io/flash-list/ 9. https://docs.expo.dev/router/introduction/ 10. https://docs.expo.dev/versions/latest/sdk/image/ 11. https://reactnative.dev/docs/platform-specific-code 12. https://necolas.github.io/react-native-web/docs/interactions/ 13. https://docs.expo.dev/versions/latest/sdk/ui/ 14. https://reactnative.dev/docs/accessibility
---
Source Files
This document was compiled from individual reference files. For detailed editing or extension:
| File | Description |
|---|---|
| references/_sections.md | Category definitions and impact ordering |
| assets/templates/_template.md | Template for creating new rules |
| SKILL.md | Quick reference entry point |
| metadata.json | Version and reference URLs |
{Rule Title}
{1-3 sentences explaining WHY this matters for the Expo / React Native design system — what drift, re-render, or native-feel problem occurs without this pattern. Explain the reasoning so the model can generalize, rather than dictating a rule.}
Incorrect ({specific problem}):
{Bad code — production-realistic, clinic-domain names (Patient, Appointment, TreatmentNote)}
{// Comments explaining the cost}Correct ({specific benefit}):
{Good code — minimal diff from incorrect, using Unistyles StyleSheet and theme tokens}
{// Comments explaining the benefit}{Optional sections as needed:}
When NOT to use this pattern:
- {Exception 1}
- {Exception 2}
Benefits:
- {Benefit 1}
- {Benefit 2}
Reference: [{Reference Title}]({Reference URL})
{
"version": "0.3.0",
"organization": "Airbnb DLS / React Native Community",
"technology": "Expo React Native design system",
"discipline": "distillation",
"type": "code-quality",
"date": "May 2026",
"abstract": "Airbnb-DLS-aligned design system engineering for Expo / React Native apps targeting both web and native iOS, built on Unistyles v3, Reanimated, Skia, and FlashList. Contains 63 rules across 11 categories, prioritized by impact from critical (token architecture, theming, component API contracts, cross-platform web/iOS parity) through a reuse-first procedure that biases agents toward the shared system over local styling, to incremental (governance). Each rule pairs an incorrect and correct TSX example with quantified impact to guide design system code generation and review; examples use a clinic management app with calendar, treatment-note, and body-chart drawing surfaces.",
"references": [
"https://www.unistyl.es/v3/start/introduction",
"https://www.unistyl.es/v3/guides/theming/",
"https://www.unistyl.es/v3/references/web-only/",
"https://www.infoq.com/news/2020/02/airbnb-design-system-react-conf/",
"https://docs.swmansion.com/react-native-reanimated/docs/guides/performance/",
"https://docs.swmansion.com/react-native-gesture-handler/",
"https://shopify.github.io/react-native-skia/",
"https://shopify.github.io/flash-list/",
"https://docs.expo.dev/router/introduction/",
"https://docs.expo.dev/versions/latest/sdk/image/",
"https://reactnative.dev/docs/platform-specific-code",
"https://necolas.github.io/react-native-web/docs/interactions/",
"https://docs.expo.dev/versions/latest/sdk/ui/",
"https://reactnative.dev/docs/accessibility"
]
}
Sections
This file defines all sections, their ordering, impact levels, and descriptions. The section ID (in parentheses) is the filename prefix used to group rules.
---
1. Token Architecture (token)
Clinic architecture alignment: Feature modules import the design-system package + domain, never another feature's internals. Tokens live in the Unistyles theme owned by design-system; features consume them and never define their own.
Impact: CRITICAL Description: The foundation layer — how tokens are defined and layered (raw to semantic to component) inside the Unistyles theme determines whether the whole system stays consistent or drifts into ad-hoc values that every screen copies.
2. Theming & Adaptivity (theme)
Impact: CRITICAL Description: How themes switch and adapt to light/dark and screen size at the Unistyles runtime layer affects every styled component and decides whether a theme change costs a full JavaScript re-render or none at all.
3. Component API Contracts (api)
Impact: CRITICAL Description: The public prop interface of each component is the design system's contract; variant-driven APIs (the Airbnb DLS pattern) keep usage consistent, while a leaked style prop lets any screen bypass tokens and break the system.
4. Cross-Platform Parity (platform)
Impact: CRITICAL Description: An Expo app ships two frontends — web and native iOS — from one codebase, and the common failure is writing web-React styling that discards native intricacies, or native code that leaves web inert. Because Unistyles v3 is a first-class web engine, the same component can feel native on both: _web pseudo-classes (hover, focus, cursor) for pointer users, Platform.OS guards and .web.tsx/.ios.tsx splits where behavior must genuinely differ, an input model that serves touch and pointer alike, and one shared theme with a known map of where web and iOS legitimately diverge (safe-area insets, haptics). For iOS-specific native-feel decisions beyond styling — native navigation, system controls, Liquid Glass — pair this with the expo-ios-hig skill.
5. Reuse & System Fit (reuse)
Impact: HIGH Description: Agents default to the local optimum — a fresh style or a forked component that makes one screen look right — because at authoring time they do not see what the system already provides. These rules make reuse the first decision: read the design system index before styling, extend a shared component with a variant instead of forking it, promote a pattern to the package on its second use, and reach for a native control (React Native's own, or @expo/ui on iOS) before reimplementing one. This is what turns many local maxima into one coherent global system.
6. Styling Engine — Unistyles (style)
Impact: HIGH Description: How styles are authored with Unistyles StyleSheet, variants, and dynamic functions determines per-render allocation cost and whether token theming reaches every component including third-party ones.
7. Typography & Iconography (type)
Impact: HIGH Description: A shared type scale and a typed icon registry drive visual hierarchy and accessibility; without them, raw fontSize values and ad-hoc icon imports proliferate across every screen.
8. Spacing, Layout & Safe Areas (space)
Impact: HIGH Description: A spacing scale, safe-area handling, and minimum touch targets create native rhythm and prevent layouts that feel off or collide with notches, status bars, and the home indicator.
9. Native-Feel & Performance (perf)
Impact: HIGH Description: List virtualization, UI-thread animation, gesture handling, and image loading decide whether the app feels native at 60-120fps or drops frames under the load of clinic data.
10. Complex Domain Components (domain)
Impact: MEDIUM-HIGH Description: Clinic surfaces like the appointment calendar, treatment-note editor, and Skia body-chart drawing must compose from design system primitives and stay responsive with large datasets and offline writes.
11. Governance & Consistency (govern)
Impact: MEDIUM Description: Package boundaries, lint rules, and a component catalog keep the system from decaying as many contributors add features on top of it over time.
Bake Accessibility Into the Component Contract
When accessibility is left to each call site, the icon-only delete button ships with no screen-reader label because someone forgot. Making the label a required prop and setting the role inside the component means an inaccessible instance cannot compile, so accessibility scales with usage instead of decaying.
Incorrect (accessibility left to the caller, then forgotten):
type IconButtonProps = { icon: ReactNode; onPress: () => void }
function IconButton({ icon, onPress }: IconButtonProps) {
return <Pressable onPress={onPress}>{icon}</Pressable>
}
// VoiceOver announces nothing; the caller was supposed to remember a label:
<IconButton icon={<TrashIcon />} onPress={deleteNote} /> // unusable with a screen readerCorrect (the contract requires an accessibility label):
type IconButtonProps = { icon: ReactNode; onPress: () => void; accessibilityLabel: string }
function IconButton({ icon, onPress, accessibilityLabel }: IconButtonProps) {
return (
<Pressable
onPress={onPress}
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
hitSlop={8}
>
{icon}
</Pressable>
)
}
// accessibilityLabel is required, so a delete button cannot ship without an announcement:
<IconButton icon={<TrashIcon />} onPress={deleteNote} accessibilityLabel="Delete note" />Reference: React Native accessibility
Offer asChild Polymorphism Instead of Wrapper Nesting
Wrapping a navigation link inside a button produces two interactive nodes fighting for the same touch and an extra view layer per usage. An asChild prop merges the component's styling and behavior onto the child element, so the link itself becomes the styled button — one node, one press target.
Incorrect (nesting a link inside a button):
// Pressable wrapping Link yields two press targets and an extra View per usage
<AppButton onPress={() => {}}>
<Link href="/appointments/new">
<Text>New appointment</Text>
</Link>
</AppButton>
// The outer Pressable and inner Link both capture taps, and the tree gains a wrapper.Correct (asChild merges props onto the child):
type ButtonProps = PropsWithChildren<{ asChild?: boolean; onPress?: () => void }>
function AppButton({ asChild, children, ...rest }: ButtonProps) {
styles.useVariants({ variant: 'primary' })
if (asChild && isValidElement(children)) {
return cloneElement(children, { style: styles.base, ...rest })
}
return <Pressable style={styles.base} {...rest}>{children}</Pressable>
}
// The Link becomes the styled button itself — a single node and a single press target:
<AppButton asChild>
<Link href="/appointments/new"><Text>New appointment</Text></Link>
</AppButton>Reference: Radix asChild pattern
Combine Variant Dimensions With Compound Variants
When a component varies along two axes (variant and size), writing one component per combination explodes: adding an axis multiplies the file count. Declaring each axis once and using compound variants only for the genuine exceptions keeps a single component that scales additively, not multiplicatively.
Incorrect (one component per combination):
// a separate component for every (variant x size) pair
function PrimaryLargeButton() { /* ... */ }
function PrimarySmallButton() { /* ... */ }
function DangerLargeButton() { /* ... */ }
function DangerSmallButton() { /* ... */ }
// Adding a "ghost" variant or an "xl" size forces several new components at once.Correct (one component, compound variants for exceptions):
const styles = StyleSheet.create((theme) => ({
button: {
variants: {
variant: { primary: { backgroundColor: theme.colors.accent },
danger: { backgroundColor: theme.colors.danger } },
size: { sm: { paddingVertical: theme.space.xs },
lg: { paddingVertical: theme.space.md } },
},
compoundVariants: [
// only the special case is listed: a large danger button gets a stronger border
{ variant: 'danger', size: 'lg',
styles: { borderWidth: 2, borderColor: theme.colors.dangerStrong } },
],
},
}))
function AppButton({ variant, size }: { variant: 'primary' | 'danger'; size: 'sm' | 'lg' }) {
styles.useVariants({ variant, size })
return <Pressable style={styles.button} />
}Reference: Unistyles compound variants
Support Both Controlled and Uncontrolled State
A controlled-only input forces every screen — even ones that never read the value — to declare state and a handler. Accepting an optional value with an internal fallback lets simple call sites stay terse while forms keep full control, matching the behavior of platform inputs.
Incorrect (controlled-only forces boilerplate everywhere):
type ToggleProps = { value: boolean; onValueChange: (next: boolean) => void }
function ReminderToggle({ value, onValueChange }: ToggleProps) {
return <Switch value={value} onValueChange={onValueChange} />
}
// Even a screen that does not observe the value must own state for it:
const [on, setOn] = useState(false)
<ReminderToggle value={on} onValueChange={setOn} />Correct (optional control with an internal fallback):
type ToggleProps = { value?: boolean; defaultValue?: boolean; onValueChange?: (next: boolean) => void }
function ReminderToggle({ value, defaultValue = false, onValueChange }: ToggleProps) {
const [internal, setInternal] = useState(defaultValue)
const isControlled = value !== undefined
const current = isControlled ? value : internal
const handle = (next: boolean) => {
if (!isControlled) setInternal(next)
onValueChange?.(next)
}
return <Switch value={current} onValueChange={handle} />
}
// Simple screens write <ReminderToggle defaultValue />; a form still controls it fully.Reference: React controlled components
Forward Refs From Every Leaf Component
A wrapper that swallows the ref leaves callers unable to focus, scroll to, or measure the underlying native view — breaking multi-field forms and scroll-to-error flows. Forwarding the ref to the leaf native element keeps those imperative capabilities available without exposing internals.
Incorrect (no ref forwarding — focus never reaches the input):
function AppTextInput({ label, ...props }: AppTextInputProps) {
return <TextInput style={styles.input} {...props} />
}
// A form wants to advance focus after submit, but there is nothing to call:
const dosageRef = useRef<TextInput>(null)
<AppTextInput ref={dosageRef} label="Dosage" /> // ref attaches to nothing
dosageRef.current?.focus() // always null — focus cannot moveCorrect (accept ref as a prop and pass it to the leaf):
// React 19 (React Native 0.81+): ref is a regular prop, so no forwardRef wrapper is needed
type AppTextInputProps = TextInputProps & { label: string; ref?: Ref<TextInput> }
function AppTextInput({ label, ref, ...props }: AppTextInputProps) {
return <TextInput ref={ref} style={styles.input} {...props} />
}
const dosageRef = useRef<TextInput>(null)
<AppTextInput ref={dosageRef} label="Dosage" />
// onSubmitEditing of the previous field can now call dosageRef.current?.focus()On React 18 and earlier, wrap the component in forwardRef to achieve the same result.
Reference: Passing refs as props in React 19
Avoid Exposing a Raw style Prop on Components
A style prop that gets spread onto the root view hands every caller permission to override padding, color, and radius — bypassing the token system the design system exists to enforce. Replacing it with intent props (tone, inset) keeps the surface area closed while still covering the real customization needs.
Incorrect (spreading an arbitrary style prop):
type CardProps = PropsWithChildren<{ style?: StyleProp<ViewStyle> }>
function PatientCard({ style, children }: CardProps) {
return <View style={[styles.card, style]}>{children}</View>
}
// A screen passes style={{ padding: 3, backgroundColor: '#abc' }} and quietly breaks
// the card's spacing and theming — the design system cannot reject it.Correct (expose intent props, keep the style internal):
type CardProps = PropsWithChildren<{ tone?: 'default' | 'alert'; inset?: 'comfortable' | 'compact' }>
const styles = StyleSheet.create((theme) => ({
card: {
variants: {
tone: { default: { backgroundColor: theme.colors.surface },
alert: { backgroundColor: theme.colors.surfaceAlert } },
inset: { comfortable: { padding: theme.space.lg },
compact: { padding: theme.space.sm } },
},
},
}))
function PatientCard({ tone = 'default', inset = 'comfortable', children }: CardProps) {
styles.useVariants({ tone, inset })
return <View style={styles.card}>{children}</View>
}
// Callers choose from sanctioned options; padding and color stay token-driven.When NOT to use this pattern:
- Layout-only props on a composition wrapper (a
flex,gap, orwidthto fit the parent) are fine to expose. What stays closed is visual styling — color, padding, radius, typography — that the tokens own.
Reference: Building the Airbnb Design System
Accept Slot Props for Flexible Composition
A component that adds a show* boolean and a payload prop for every optional element grows an unbounded API and still cannot express combinations its author did not predict. Named slots that accept any ReactNode let callers compose arbitrary content — an avatar, a badge, an icon — without the component knowing about each case.
Incorrect (a prop pair per content variation):
type RowProps = {
title: string
showAvatar?: boolean; avatarUri?: string
showBadge?: boolean; badgeText?: string
showChevron?: boolean
}
// Every new trailing element adds two more props; the surface keeps growing.
function ListRow(props: RowProps) { /* conditionally renders each optional piece */ }Correct (named slots accept any node):
type RowProps = PropsWithChildren<{ leading?: ReactNode; trailing?: ReactNode }>
const styles = StyleSheet.create((theme) => ({
row: { flexDirection: 'row', alignItems: 'center', gap: theme.space.sm, padding: theme.space.md },
}))
function ListRow({ leading, trailing, children }: RowProps) {
return (
<View style={styles.row}>
{leading}
<View style={{ flex: 1 }}>{children}</View>
{trailing}
</View>
)
}
// A caller drops <PatientAvatar/> into leading and <StatusBadge/> into trailing — no new props.Reference: Building the Airbnb Design System
Express Visual Options as Variant Props
Airbnb's Design Language System makes every visual choice a named prop — a button gets a variant prop, not a free style prop — so the same intent always renders the same way. A style escape hatch lets each call site invent its own look, and within weeks no two "primary" buttons match. Variants encode a closed, reviewable set of options.
Incorrect (a style prop turns one component into many looks):
type ButtonProps = { title: string; style?: ViewStyle; onPress: () => void }
function AppButton({ title, style, onPress }: ButtonProps) {
return <Pressable style={[styles.base, style]} onPress={onPress}><Text>{title}</Text></Pressable>
}
// Each caller passes a bespoke style, so "primary" means something different everywhere:
<AppButton title="Book" style={{ backgroundColor: '#0F766E', borderRadius: 6 }} onPress={book} />
<AppButton title="Save" style={{ backgroundColor: 'teal', borderRadius: 10 }} onPress={save} />Correct (a closed set of variant props, the DLS pattern):
type ButtonProps = { title: string; variant?: 'primary' | 'secondary'; onPress: () => void }
const styles = StyleSheet.create((theme) => ({
base: {
borderRadius: theme.radius.md,
variants: {
variant: {
primary: { backgroundColor: theme.colors.accent },
secondary: { backgroundColor: theme.colors.surfaceMuted },
},
},
},
}))
function AppButton({ title, variant = 'primary', onPress }: ButtonProps) {
styles.useVariants({ variant })
return <Pressable style={styles.base} onPress={onPress}><Text>{title}</Text></Pressable>
}
// "primary" renders identically everywhere because callers pick a variant, not a style.When NOT to use this pattern:
- A genuinely one-off surface that will never be reused (a single marketing splash) can take a local style. The moment a second instance appears, promote it to a variant.
Reference: Building the Airbnb Design System, Unistyles variants
Capture Drawing Strokes With Gestures and Shared Values
Accumulating drawn points into React state copies the whole array on every move event, so a long body-chart stroke drops frames as the array grows. Mutating a Skia path held in a Reanimated shared value lets points accumulate on the UI thread with no React render mid-stroke.
Incorrect (state update and array copy per move):
const [points, setPoints] = useState<Point[]>([])
const pan = Gesture.Pan().onChange((e) => setPoints((prev) => [...prev, { x: e.x, y: e.y }]))
// Each move triggers a state update and a full array copy; long strokes drop frames.Correct (accumulate into a Skia path on the UI thread):
import { Skia, notifyChange } from '@shopify/react-native-skia'
import { useSharedValue } from 'react-native-reanimated'
import { Gesture } from 'react-native-gesture-handler'
function useStroke() {
const path = useSharedValue(Skia.Path.Make())
const pan = Gesture.Pan()
.onStart((e) => { path.value.moveTo(e.x, e.y); notifyChange(path.value) })
.onChange((e) => { path.value.lineTo(e.x, e.y); notifyChange(path.value) }) // repaint on UI thread
return { path, pan }
}
// Points accumulate into the path on the UI thread; notifyChange repaints the
// Skia <Path> without a React render. (In-place mutation alone does not repaint.)Reference: React Native Skia, Gesture Handler
Draw Body-Chart Annotations on a Skia Canvas
Rendering each touch point of a body-chart annotation as an absolutely positioned View creates hundreds of nodes per stroke, so the chart stutters and memory climbs. A Skia Canvas renders the whole stroke as a single GPU-accelerated vector path, independent of the JS thread.
Incorrect (one View per drawn point):
// each touch point becomes an absolutely positioned dot
{points.map((point, index) => (
<View key={index}
style={{ position: 'absolute', left: point.x, top: point.y, width: 4, height: 4 }} />
))}
// A single stroke creates hundreds of Views; the body chart stutters and memory grows.Correct (a Skia Path on a Canvas):
import { Canvas, Path } from '@shopify/react-native-skia'
import type { SkPath } from '@shopify/react-native-skia'
import { StyleSheet, useUnistyles } from 'react-native-unistyles'
const styles = StyleSheet.create(() => ({ canvas: { flex: 1 } }))
function BodyChartLayer({ strokePath }: { strokePath: SkPath }) {
const { theme } = useUnistyles()
return (
<Canvas style={styles.canvas}>
<Path path={strokePath} style="stroke" strokeWidth={3} color={theme.colors.danger} />
</Canvas>
)
}
// One Skia Path renders the whole stroke on the GPU, off the JS thread; the
// stroke color resolves from a theme token instead of a hardcoded hex.Reference: React Native Skia
Virtualize the Appointment Calendar by Day
A month grid rendered eagerly mounts every day times every time slot — over a thousand cells — before the first is visible, so the calendar opens slowly and scrolls sluggishly. Flattening the schedule into a sectioned agenda and virtualizing it with FlashList mounts only the visible days and recycles headers and rows separately.
Incorrect (eager month grid in a ScrollView):
// 30 days x 48 half-hour slots = ~1440 cells mounted up front
<ScrollView>
{monthDays.map((day) => (
<View key={day.iso}>
{day.slots.map((slot) => <SlotCell key={slot.id} slot={slot} />)}
</View>
))}
</ScrollView>
// Opening the month view mounts every cell and scrolls sluggishly.Correct (a sectioned, virtualized agenda):
import { FlashList } from '@shopify/flash-list'
// agendaItems is flattened: [{ kind: 'day' }, { kind: 'appointment' }, ...]
<FlashList
data={agendaItems}
renderItem={({ item }) =>
item.kind === 'day' ? <DayHeader date={item.date} /> : <AppointmentRow item={item} />
}
keyExtractor={(item) => item.id}
stickyHeaderIndices={dayHeaderIndices}
getItemType={(item) => item.kind}
/>
// Only visible days mount; getItemType lets FlashList recycle headers and rows separately.Reference: FlashList
Compose Domain Components From Design System Primitives
When a domain component like AppointmentCard styles itself with raw values, it duplicates card and text styling and drifts from the system the next time tokens change. Building domain components by composing design system primitives (Card, AppText, StatusPill) makes them inherit tokens, theming, and accessibility for free.
Incorrect (the domain card re-implements styling):
function AppointmentCard({ appointment }: { appointment: Appointment }) {
return (
<View style={{ padding: 16, borderRadius: 12, backgroundColor: '#FFFFFF' }}>
<Text style={{ fontSize: 16, fontWeight: '600', color: '#111827' }}>{appointment.patientName}</Text>
<Text style={{ fontSize: 13, color: '#6B7280' }}>{appointment.startTime}</Text>
</View>
)
}
// The card duplicates card and text styling and drifts from the design system.Correct (compose Card, AppText, and StatusPill):
function AppointmentCard({ appointment }: { appointment: Appointment }) {
return (
<Card tone="default" inset="comfortable">
<AppText variant="title">{appointment.patientName}</AppText>
<AppText variant="caption" tone="muted">{appointment.startTime}</AppText>
<StatusPill status={appointment.status} />
</Card>
)
}
// The card inherits tokens, theming, and accessibility from the primitives it composes.Reference: Building the Airbnb Design System
Persist Treatment-Note Edits Offline-First With Debounce
A treatment note that only saves on a Save button is lost when a clinician switches apps to check a result and the OS suspends the screen. Debounced autosave writes drafts to a local store within a second and syncs them in the background, so an interruption never costs work.
Incorrect (save only on an explicit button):
function NoteEditor({ noteId }: { noteId: string }) {
const [text, setText] = useState('')
return (
<>
<AppTextArea value={text} onChangeText={setText} />
<AppButton title="Save" onPress={() => saveNote(noteId, text)} />
</>
)
}
// If the clinician switches apps before tapping Save, the draft is lost.Correct (debounced autosave to a local store):
function NoteEditor({ noteId }: { noteId: string }) {
const [text, setText] = useState(() => noteStore.getDraft(noteId))
const persist = useMemo(
() => debounce((value: string) => noteStore.saveDraft(noteId, value), 800),
[noteId],
)
const onChangeText = (value: string) => { setText(value); persist(value) }
useEffect(() => () => persist.flush(), [persist]) // flush any pending draft on unmount
return <AppTextArea value={text} onChangeText={onChangeText} />
}
// Drafts land in the local store within 800ms and sync to the server in the background.Reference: Expo AsyncStorage
Render Optimistic UI for Appointment and Note Writes
Awaiting the server before reflecting a change leaves the clinician staring at a spinner for the length of a round-trip — painful on a weak clinic connection. Applying the change to the local cache immediately and reconciling when the request settles keeps the UI responsive, with a rollback if the write fails.
Incorrect (await the server before showing the change):
async function onConfirm(id: string) {
setLoading(true)
await api.confirmAppointment(id) // UI is frozen behind a spinner for the whole round-trip
setLoading(false)
refetch()
}
// On a slow connection the screen shows a spinner for seconds after each tap.Correct (optimistic update with rollback):
const mutation = useMutation({
mutationFn: (id: string) => api.confirmAppointment(id),
onMutate: async (id) => {
await queryClient.cancelQueries({ queryKey: ['appointments'] })
const previous = queryClient.getQueryData(['appointments'])
queryClient.setQueryData(['appointments'], (list) => markConfirmed(list, id)) // instant
return { previous }
},
onError: (_err, _id, ctx) => queryClient.setQueryData(['appointments'], ctx?.previous),
})
// The status flips immediately; a failed sync rolls back to the previous state.Reference: TanStack Query optimistic updates
Isolate the Design System as Its Own Package
When features reach into the design system with deep relative imports, they couple to its folder layout and to raw tokens that should stay private — so any refactor of the internals breaks features. Packaging the design system with a single curated entry point exposes only the public surface and keeps internals free to change.
Incorrect (deep relative imports into internals):
import { Button } from '../../../design-system/src/components/Button/Button'
import { palette } from '../../../design-system/src/tokens/raw'
// Features depend on the internal folder structure and on raw tokens meant to be private.Correct (a package with a curated public entry):
// design-system/package.json → { "name": "@clinic/design-system", "exports": { ".": "./src/index.ts" } }
// design-system/src/index.ts — the only public surface
export { Button, Card, AppText } from './components'
export type { AppTheme } from './theme'
// a feature imports the package entry; raw tokens stay private
import { Button, Card, AppText } from '@clinic/design-system'Reference: Unistyles configuration
Migrate Ad-Hoc Styles to Tokens Incrementally
A single pull request that rewrites every screen to tokens conflicts with every in-flight branch and is too large to review, so it sits open for weeks and blocks adoption. Migrating one surface per pull request behind a lint ratchet keeps the codebase shippable while the system spreads.
Incorrect (one giant rewrite all at once):
// PR #482 "Adopt design tokens" — touches 137 files in a single commit
// - rewrites every StyleSheet across billing, scheduling, notes, and charts
// - conflicts with all six in-flight feature branches
// - too large to review, so it stays open for weeks and blocks token adoptionCorrect (one surface per PR behind a lint ratchet):
// 1. Add the token ESLint rules as "warn", so nothing breaks today.
// 2. Migrate one feature per PR: scheduling first, then notes, then charts.
const styles = StyleSheet.create((theme) => ({
slot: { padding: theme.space.sm, backgroundColor: theme.colors.surface }, // migrated
}))
// 3. Flip the rule to "error" for a directory once it is clean, locking in the gain.Reference: Unistyles configuration
Lint Against Raw Colors and Inline Styles
Relying on code review to catch hex literals and inline style objects is inconsistent — reviewers miss them, and once merged they multiply. An ESLint rule that fails CI on color literals and inline style objects turns the convention into an automated gate that cannot be forgotten.
Incorrect (no lint gate — raw values slip through review):
const styles = StyleSheet.create(() => ({
banner: { backgroundColor: '#FEF3C7', borderColor: 'rgba(0,0,0,0.08)', color: 'teal', padding: 11 },
// hex, rgba, a named color, and an off-scale number — all ungoverned, all slip through review
}))Correct (an ESLint rule rejects literals and inline styles):
// .eslintrc.js — fail CI when a hex color or an inline style object appears in feature code
module.exports = {
rules: {
'no-restricted-syntax': ['error',
{ selector: "Literal[value=/^#([0-9a-fA-F]{3,8})$/]",
message: 'Use a theme color token, not a hex literal.' },
{ selector: "Literal[value=/^(rgba?|hsla?)\\(/]",
message: 'Use a theme color token, not an rgb()/hsl() literal.' },
{ selector: "Literal[value=/^(red|green|blue|teal|gray|grey|black|white|orange|purple)$/]",
message: 'Use a theme color token, not a named CSS color.' },
{ selector: "JSXAttribute[name.name='style'] ObjectExpression",
message: 'Use StyleSheet.create with tokens, not an inline style object.' },
],
},
}AST selectors cannot tell a spacing literal from any other number, so pair this with a numeric-scale check (a custom lint or a CI grep for off-scale padding/margin/fontSize values) and treat style={[...]} arrays the same as inline objects.
Reference: Unistyles theming
Enforce One Naming Convention for Tokens and Components
When tokens mix PascalCase, snake_case, and abbreviated prefixes in one map, every lookup becomes guesswork and autocomplete stops helping. A single convention — role-first camelCase — makes related tokens cluster predictably (surface, surfaceMuted, surfaceAlert) so the right name is obvious.
Incorrect (three conventions in one token map):
const lightTheme = {
colors: {
Accent: '#0F766E', // PascalCase
text_primary: '#111827', // snake_case
bgSurface: '#FFFFFF', // abbreviated, prefix-first
},
}
// Three conventions in one map; looking up a token name is guesswork.Correct (role-first camelCase everywhere):
const lightTheme = {
colors: {
accent: '#0F766E',
textPrimary: '#111827',
surface: '#FFFFFF',
surfaceMuted: '#F9FAFB',
surfaceAlert: '#FEF2F2',
},
}
// Role-first camelCase clusters related tokens, so surfaceMuted is easy to predict.Reference: Unistyles theming
Prevent Feature Modules From Defining Local Tokens
A feature that defines its own colors and spacing creates a shadow token system that drifts from the design system and never inherits dark mode or rebrands. Adding the role to the central theme keeps one source of truth, so the feature gets theming and consistency automatically.
Incorrect (a feature defines a parallel token set):
// features/billing/theme.ts — tokens nobody else knows about
export const billingColors = { accent: '#7C3AED', surface: '#F5F3FF' }
export const billingSpacing = { gutter: 14 }
// Billing screens drift from the design system and never get dark mode.Correct (extend the central theme instead):
// design-system/theme.ts — add the role to the one theme, available app-wide
const lightTheme = {
colors: { accent: '#0F766E', billingAccent: '#7C3AED', surface: '#FFFFFF' },
space: { xs: 4, sm: 8, md: 16, gutter: 16 },
}
// features/billing reads it like any other token, so dark mode comes for free
const styles = StyleSheet.create((theme) => ({ total: { color: theme.colors.billingAccent } }))Reference: Unistyles theming
Catalog Every Component Variant in Storybook
When the only place a button's variants render is deep inside a booking flow, reviewers never see primary, secondary, and danger side by side, so a regression in one variant ships unnoticed. A story that renders every variant in one view makes the component's full surface reviewable and turns visual drift into a caught diff.
Incorrect (variants only exist inside feature flows):
// the only render of AppButton's variants is buried in a screen
export function BookingScreen() {
return <AppButton variant="primary" title="Book appointment" onPress={book} />
}
// Reviewers cannot compare variants, so a regression in "danger" slips through.Correct (a story renders the full variant set):
// AppButton.stories.tsx
export default { title: 'DesignSystem/AppButton', component: AppButton }
export const AllVariants = () => (
<Card inset="comfortable">
{(['primary', 'secondary', 'danger'] as const).map((variant) => (
<AppButton key={variant} variant={variant} title={variant} onPress={() => {}} />
))}
</Card>
)
// Every variant renders side by side, so visual regressions surface in review.Reference: Building the Airbnb Design System
Defer Off-Screen Work Until After Transitions
Running an expensive computation in a screen's mount effect makes it compete with the push animation, dropping frames exactly when the user is watching the transition. InteractionManager.runAfterInteractions schedules the work for after animations settle, so the screen slides in smoothly and the heavy build happens once it is on screen.
Incorrect (heavy work during the navigation animation):
function PatientScreen({ patient }: { patient: Patient }) {
const [report, setReport] = useState<VisitReport>()
useEffect(() => {
setReport(buildVisitReport(patient)) // synchronous and heavy, runs during the push animation
}, [])
return <ReportView report={report} />
}
// The expensive build competes with the transition and drops frames on entry.Correct (defer until interactions settle):
import { InteractionManager } from 'react-native'
function PatientScreen({ patient }: { patient: Patient }) {
const [report, setReport] = useState<VisitReport>()
useEffect(() => {
const task = InteractionManager.runAfterInteractions(() => setReport(buildVisitReport(patient)))
return () => task.cancel()
}, [])
return <ReportView report={report} />
}
// The screen animates in first, then the report builds once the transition completes.Reference: Reanimated performance
Load Remote Images With expo-image and Caching
React Native's Image re-fetches and re-decodes a remote source on each mount and decodes large originals on the UI thread, so scrolling a patient list with avatars janks. expo-image caches decoded frames to memory and disk and decodes off the main thread, so revisiting the list is instant.
Incorrect (RN Image without a cache policy):
import { Image } from 'react-native'
<Image source={{ uri: patient.avatarUrl }} style={{ width: 48, height: 48 }} />
// Re-fetches and re-decodes on every mount; large originals decode on the UI thread.Correct (expo-image with caching and a placeholder):
import { Image } from 'expo-image'
<Image
source={patient.avatarUrl}
style={{ width: 48, height: 48, borderRadius: 24 }}
cachePolicy="memory-disk"
transition={150}
placeholder={require('./assets/avatar-blur.png')}
/>
// Decoded frames are cached on memory and disk, so revisiting the list avoids refetching.Reference: expo-image
Render Long Lists With FlashList, Not ScrollView
Mapping data inside a ScrollView mounts every row up front, so a full day of appointments mounts hundreds of components before the first is visible — spiking memory and time-to-interactive. FlashList virtualizes the list, mounting only what fits on screen and recycling rows as the clinician scrolls.
Incorrect (map inside a ScrollView):
<ScrollView>
{appointments.map((a) => <AppointmentRow key={a.id} item={a} />)}
</ScrollView>
// A 300-appointment day mounts 300 rows immediately, spiking memory and TTI.Correct (FlashList virtualizes to visible rows):
import { FlashList } from '@shopify/flash-list'
<FlashList
data={appointments}
renderItem={({ item }) => <AppointmentRow item={item} />}
keyExtractor={(item) => item.id}
/>
// FlashList mounts only visible rows and recycles them during scroll.Reference: FlashList
Handle Gestures With Gesture Handler, Not PanResponder
PanResponder delivers every move event to the JS thread, so calling setState per move makes dragging a body-chart marker lag whenever JS is busy. React Native Gesture Handler processes gestures natively and pairs with Reanimated shared values, so the drag stays smooth on the UI thread.
Incorrect (PanResponder updates state on the JS thread):
const [x, setX] = useState(0)
const responder = PanResponder.create({
onMoveShouldSetPanResponder: () => true,
onPanResponderMove: (_, g) => setX(g.dx), // setState per move event, on the JS thread
})
return <View {...responder.panHandlers} style={{ transform: [{ translateX: x }] }} />
// Dragging a marker lags because each move crosses to JS and re-renders.Correct (Gesture Handler plus Reanimated on the UI thread):
import { Gesture, GestureDetector } from 'react-native-gesture-handler'
import Animated, { useSharedValue, useAnimatedStyle } from 'react-native-reanimated'
function DraggableMarker() {
const x = useSharedValue(0)
const pan = Gesture.Pan().onChange((e) => { x.value += e.changeX }) // runs on the UI thread
const style = useAnimatedStyle(() => ({ transform: [{ translateX: x.value }] }))
return <GestureDetector gesture={pan}><Animated.View style={style} /></GestureDetector>
}Web: Gesture Handler maps to pointer events, so the same Gesture.Pan() works on web — but pointer users also expect hover and a cursor, which gestures alone don't provide. See `platform-web-pseudo-states`.
Reference: React Native Gesture Handler
Add Haptic Feedback to Confirmations and Toggles
On native iOS and Android, consequential actions answer with a tactile pulse; without it, cancelling an appointment feels identical to a no-op tap. A short expo-haptics impact tied to confirmations and toggles restores that native-feel response and signals that something important happened.
Incorrect (no tactile feedback on a destructive confirm):
function confirmCancel(id: string) {
cancelAppointment(id) // the tap produces no physical signal
}
<AppButton title="Cancel appointment" variant="danger" onPress={() => confirmCancel(id)} />Correct (impact feedback tied to the action):
import * as Haptics from 'expo-haptics'
async function confirmCancel(id: string) {
await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning)
cancelAppointment(id)
}
<AppButton title="Cancel appointment" variant="danger" onPress={() => confirmCancel(id)} />
// The warning haptic marks a consequential action, matching native platform behavior.Web: expo-haptics is a no-op on web, so this feedback silently disappears there — pair it with a visible cue. See `platform-guard-native-only`.
Reference: expo-haptics
Memoize List Item Components and Callbacks
An inline renderItem that creates a new onPress closure per row hands every row a fresh prop on each parent render, so any unrelated state change re-renders all visible rows. Memoizing the row component and stabilizing its handler with useCallback lets unchanged rows skip rendering entirely.
Incorrect (inline renderItem with a new closure per row):
<FlashList
data={notes}
renderItem={({ item }) => (
<NoteRow note={item} onPress={() => openNote(item.id)} /> // new closure per row, per render
)}
/>
// Any parent state change re-creates every row's onPress and re-renders visible rows.Correct (memoized row plus a stable handler):
const NoteRow = memo(({ note, onPress }: NoteRowProps) => (
<Pressable onPress={() => onPress(note.id)}><AppText>{note.title}</AppText></Pressable>
))
function NotesList({ notes }: { notes: Note[] }) {
const openNote = useCallback((id: string) => router.push(`/notes/${id}`), [])
return (
<FlashList data={notes} keyExtractor={(item) => item.id}
renderItem={({ item }) => <NoteRow note={item} onPress={openNote} />} />
)
}
// memo skips rows with unchanged props; useCallback keeps onPress stable across renders.Reference: Reanimated performance
Animate on the UI Thread With Reanimated Worklets
Driving an animation through React state re-renders the component on every frame, so under any JS-thread load the motion stutters. Reanimated runs the animation as a worklet on the UI thread using shared values, so it holds frame rate independently of React rendering.
Incorrect (animating via React state):
const [offset, setOffset] = useState(-40)
useEffect(() => {
const id = setInterval(() => setOffset((o) => Math.min(o + 2, 0)), 16) // re-render per frame
return () => clearInterval(id)
}, [])
return <Animated.View style={{ transform: [{ translateX: offset }] }} />
// Each frame round-trips through React state; the slide stutters under load.Correct (a shared value on the UI thread):
import Animated, { useSharedValue, useAnimatedStyle, withTiming } from 'react-native-reanimated'
function SlideIn({ children }: PropsWithChildren) {
const x = useSharedValue(-40)
useEffect(() => { x.value = withTiming(0, { duration: 200 }) }, [])
const style = useAnimatedStyle(() => ({ transform: [{ translateX: x.value }] }))
return <Animated.View style={style}>{children}</Animated.View>
}
// The worklet animates on the UI thread, holding frame rate without React renders.Reference: Reanimated performance
Isolate Platform Differences Behind One Component API
When a primitive must render differently on web and iOS, scattering Platform.OS === 'web' ternaries through call sites duplicates the branch and lets the two platforms drift apart. Resolve the difference once — with a .ios.tsx / .web.tsx file pair or a single Platform.select inside the component — so every feature imports one <DatePicker> and never knows there are two implementations.
Incorrect (every screen re-branches on platform):
{Platform.OS === 'web'
? <input type="date" value={value} onChange={(e) => onChange(e.target.value)} />
: <DateTimePicker value={date} mode="date" onChange={onPickerChange} />}
// The branch is copy-pasted into every form; the web and native inputs drift apart.Correct (one import, platform resolved by the bundler):
// design-system/DatePicker.web.tsx → renders <input type="date">
// design-system/DatePicker.ios.tsx → renders the native DateTimePicker
// design-system/DatePicker.tsx → shared prop types only
import { DatePicker } from '@clinic/design-system'
<DatePicker value={date} onChange={onChange} /> // identical call on web and iOSWhen NOT to use this pattern:
- A one-property style difference — prefer a
_webblock in the same StyleSheet over a whole-file split.
Reference: Platform-specific extensions (React Native)
Guard Native-Only APIs Behind Platform Checks With Web Fallbacks
expo-haptics, blur views, and other native modules silently no-op on web, so feedback the design relies on simply vanishes there. Routing native-only effects through one design-system helper that branches on Platform.OS keeps a single call site while giving web an equivalent cue — a toast or an aria-live announcement — instead of nothing.
Incorrect (haptic is the only confirmation — gone on web):
import * as Haptics from 'expo-haptics'
async function confirmCancel(id: string) {
await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning)
cancelAppointment(id)
}
// On web the haptic no-ops, so a cancelled appointment gives the user no feedback at all.Correct (one helper, platform-appropriate feedback):
import { Platform } from 'react-native'
import * as Haptics from 'expo-haptics'
async function signalWarning() {
if (Platform.OS === 'web') return announce('Appointment cancelled') // design-system aria-live cue
await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning)
}
async function confirmCancel(id: string) {
await signalWarning()
cancelAppointment(id)
}Reference: Platform-specific code (React Native)
Design for Pointer and Touch, Never Hover-Only
Revealing actions on hover is a web habit that strands touch users: a phone has no hover, so a row whose delete button only appears on _hover can never be triggered there. Treat hover as a progressive enhancement layered on an affordance that already works by tap — a visible control, a swipe action, or a long press — and keep the 44pt touch target on every platform.
Incorrect (delete only reachable by hover):
const styles = StyleSheet.create((theme) => ({
rowDelete: { opacity: 0, _web: { _hover: { opacity: 1 } } },
}))
// Invisible and unreachable on touch — the action effectively does not exist on a phone.Correct (always-present affordance, hover as enhancement):
const styles = StyleSheet.create((theme) => ({
rowDelete: {
opacity: 1, // tappable on touch; pair with swipe-to-delete on native
minWidth: theme.space.touchTarget,
minHeight: theme.space.touchTarget,
_web: { opacity: 0.6, _hover: { opacity: 1 } }, // subtler until hover, but still clickable
},
}))Reference: React Native for Web — Interactions
Drive Web and Native From One Theme, With a Known Divergence Map
The point of Unistyles is that one theme renders on web and native, so forking a separate web stylesheet reintroduces the drift tokens exist to prevent. Keep a single theme, and handle the few places platforms legitimately differ explicitly — chiefly safe-area insets, which are real on iOS but 0 on web, so spacing that leans on them alone collapses on web.
Incorrect (header spacing depends on insets — flush to the top on web):
const styles = StyleSheet.create((theme, rt) => ({
header: { paddingTop: rt.insets.top }, // rt.insets.top is 0 on web → the header jams against the edge
}))Correct (a token floor under the inset):
const styles = StyleSheet.create((theme, rt) => ({
header: { paddingTop: Math.max(rt.insets.top, theme.space.md) }, // the device notch on iOS, token spacing on web
}))Known divergences to design for: safe-area insets (0 on web), haptics (no-op on web — see `platform-guard-native-only`), and hover/cursor (web-only — see `platform-web-pseudo-states`). For iOS-specific native-feel decisions beyond styling — native navigation, system controls, Liquid Glass — use the expo-ios-hig skill.
Reference: Unistyles mini runtime (insets)
Add Web Hover, Focus, and Cursor to Interactive Components
A button whose only feedback is an onPressIn opacity change works on iOS but reads as a dead element on web: the pointer never becomes a hand, hovering does nothing, and keyboard focus shows no ring. Unistyles v3 compiles styles to real CSS classes, so a _web block with _hover, _focus, and cursor gives the same component native-feeling pointer affordances on web while leaving the native style untouched.
Incorrect (touch-only feedback — inert on web):
const styles = StyleSheet.create((theme) => ({
bookButton: {
backgroundColor: theme.colors.accent,
borderRadius: theme.radius.md,
variants: { pressed: { true: { opacity: 0.7 } } },
},
}))
// On web the cursor stays an arrow, hover is dead, and Tab focus is invisible.Correct (web pseudo-states alongside the native style):
const styles = StyleSheet.create((theme) => ({
bookButton: {
backgroundColor: theme.colors.accent,
borderRadius: theme.radius.md,
variants: { pressed: { true: { opacity: 0.7 } } },
_web: {
cursor: 'pointer',
_hover: { opacity: 0.92 },
_focus: { outlineColor: theme.colors.accent, outlineStyle: 'solid', outlineWidth: 2 },
},
},
}))
// One component now feels native on both: press feedback on iOS, hover/focus/cursor on web.When NOT to use this pattern:
- Non-interactive surfaces (cards, non-pressable list rows) — a
cursor: 'pointer'there misleads web users into clicking.
Reference: Unistyles web-only features
Extend a Shared Component With a Variant, Don't Fork a Local One
When a shared component is a near-match but missing one look, the local-optimum move is to copy it into the feature and tweak it. That fork drifts the moment either copy changes. Add the missing option as a variant on the shared component instead: the change lands once, every feature inherits it, and the system stays single-sourced.
Incorrect (forked the whole component to get a red background):
// features/billing/PayButton.tsx — a second button re-implementing press, layout, and
// accessibility just to change one color; it drifts from AppButton the moment either changes.
function PayButton({ title, onPress }: { title: string; onPress: () => void }) {
const [pressed, setPressed] = useState(false)
return (
<Pressable style={styles.payButton} onPress={onPress} accessibilityRole="button"
onPressIn={() => setPressed(true)} onPressOut={() => setPressed(false)}>
<Text style={styles.label}>{title}</Text>
</Pressable>
)
}Correct (add the variant to the shared component, then use it):
// @clinic/design-system AppButton — one new variant value, available everywhere
variants: {
variant: {
primary: { backgroundColor: theme.colors.accent },
secondary: { backgroundColor: theme.colors.surfaceMuted },
danger: { backgroundColor: theme.colors.danger },
},
}
// feature call site:
<AppButton title="Pay" variant="danger" onPress={pay} />This rule is about not forking the whole component file; for why the option is a variant prop rather than a style prop, see `api-variants-over-style-prop`.
Reference: Unistyles variants
Read the Design System Index Before Writing Any Style
The default failure is reaching for a fresh StyleSheet.create because it is the path of least resistance — without checking what already exists. Before styling anything, read the design system's single index (packages/design-system/src/index.ts and the theme tokens): reuse or extend an existing primitive, variant, or token, and create something new only — in the shared package — when nothing fits. The local maximum (a bespoke style that makes one screen look right) is a system loss (a near-duplicate that drifts).
Incorrect (bespoke card built without checking the system):
// app/appointments/[id].tsx — re-implements a surface the design system already exports
const styles = StyleSheet.create((theme) => ({
card: { backgroundColor: theme.colors.surfaceRaised, borderRadius: theme.radius.md, padding: theme.space.lg },
}))
function AppointmentDetail({ title }: { title: string }) {
return <View style={styles.card}><AppText variant="title">{title}</AppText></View>
}Correct (reuse the indexed primitive):
// The index lists AppCard with tone/inset variants — it already covers this.
import { AppCard, AppText } from '@clinic/design-system'
function AppointmentDetail({ title }: { title: string }) {
return <AppCard tone="default" inset="comfortable"><AppText variant="title">{title}</AppText></AppCard>
}Reference: Building the Airbnb Design System
Reach for a Native Control Before Reimplementing One in JavaScript
Rebuilding a switch, picker, segmented control, or menu out of Pressable and animated Views is the classic local optimum — it looks right in isolation while silently losing platform animation, haptics, accessibility, and dark-mode behavior. Reach for the existing control first: React Native's built-in Switch for cross-platform cases, and @expo/ui/swift-ui for richer iOS-native controls (segmented control, native picker, date picker) behind a platform split.
Incorrect (hand-rolled toggle):
// loses the native animation, haptics, VoiceOver state, and dark-mode track color
function Toggle({ on, onChange }: { on: boolean; onChange: (v: boolean) => void }) {
return (
<Pressable onPress={() => onChange(!on)} style={[styles.track, on && styles.trackOn]}>
<View style={[styles.thumb, on && styles.thumbOn]} />
</Pressable>
)
}Correct (use the platform's own control):
import { Switch } from 'react-native' // native on iOS/Android, real control on web — a11y for free
<Switch value={on} onValueChange={onChange} />For controls React Native lacks, use @expo/ui/swift-ui on iOS behind a .ios.tsx split (see `platform-divergence-split`) rather than a JavaScript reimplementation; the expo-ui skill documents that component API.
Reference: @expo/ui SDK
Promote a Pattern to the System on Its Second Use
A pattern's first appearance can live in the feature that needs it. Its second appearance is the signal to promote it into the design system package — before a third copy exists. Waiting until "later" means three subtly different versions ship and none can be fixed in one place.
Incorrect (the same badge inlined in two screens):
// appointments/Row.tsx AND patients/Row.tsx both inline this — two StyleSheets that will drift
const styles = StyleSheet.create((theme) => ({
badge: { backgroundColor: theme.colors.surfaceMuted, borderRadius: theme.radius.sm, paddingHorizontal: theme.space.sm },
}))Correct (promote on the second occurrence, then import):
// @clinic/design-system/StatusBadge.tsx — extracted once; both screens consume it
import { StatusBadge } from '@clinic/design-system'
<StatusBadge status={appointment.status} />This makes the second instance the trigger — elevating the "when a second instance appears, promote it" aside in `api-variants-over-style-prop` into a standing rule.
Reference: Building the Airbnb Design System
Lay Out Stacks With Gap, Not Per-Child Margins
Putting marginBottom on every child also adds a margin after the last one, leaving stray space before the next section that someone later "fixes" with a negative margin. Setting gap on the container spaces children evenly and adds nothing after the last item.
Incorrect (marginBottom on each child):
{medications.map((m) => (
<View key={m.id} style={{ marginBottom: 12 }}>
<MedicationRow medication={m} />
</View>
))}
// The last child also gets a 12pt bottom margin, adding stray space before the next section.Correct (gap on the container):
const styles = StyleSheet.create((theme) => ({
list: { gap: theme.space.sm }, // even spacing between children, none after the last
}))
<View style={styles.list}>
{medications.map((m) => <MedicationRow key={m.id} medication={m} />)}
</View>Reference: Unistyles theming
Tokenize Corner Radius by Component Role
When each surface picks its own borderRadius, cards, sheets, buttons, and inputs end up with four unrelated radii and the UI feels subtly off without anyone able to say why. Radius tokens keyed by role collapse those into a small, intentional set that every surface shares.
Incorrect (assorted radii per component):
const styles = StyleSheet.create(() => ({
card: { borderRadius: 12 },
sheet: { borderRadius: 16 },
button: { borderRadius: 10 },
input: { borderRadius: 8 },
}))
// Four surfaces, four unrelated radii — the UI looks slightly inconsistent.Correct (radius tokens by role):
// theme.ts
radius: { sm: 8, md: 12, lg: 20, pill: 999 }
const styles = StyleSheet.create((theme) => ({
card: { borderRadius: theme.radius.md },
sheet: { borderRadius: theme.radius.lg },
button: { borderRadius: theme.radius.md },
chip: { borderRadius: theme.radius.pill },
}))
// Surfaces share a small set of radii, so rounding reads as intentional.Reference: Unistyles theming
Apply Safe-Area Insets at Screen Boundaries
A hardcoded paddingTop: 44 is wrong on devices without a notch and ignores the bottom home indicator entirely, so content collides with system UI. The Unistyles runtime exposes live insets, so a screen pads itself correctly on every device and updates if the safe area changes.
Incorrect (hardcoded inset for the status bar):
<View style={{ paddingTop: 44, paddingBottom: 0 }}>
<ScheduleHeader />
</View>
// 44 is wrong on devices without a notch, and the bottom content sits under the home indicator.Correct (runtime insets from Unistyles):
const styles = StyleSheet.create((theme, rt) => ({
screen: {
paddingTop: rt.insets.top,
paddingBottom: rt.insets.bottom,
paddingHorizontal: theme.space.md,
},
}))
function ScheduleScreen() {
return <View style={styles.screen}><ScheduleHeader /></View>
}
// rt.insets adapts per device and re-resolves natively if the safe area changes.Web: rt.insets are 0 on web (no notch or home indicator), so floor inset padding with a token — Math.max(rt.insets.top, theme.space.md). See `platform-shared-theme-parity`.
Reference: Unistyles runtime insets
Use a Spacing Scale Instead of Ad-Hoc Numbers
Padding and margin values like 9, 13, and 18 appear once and nowhere else, so the layout has no shared rhythm and small inconsistencies accumulate. A spacing scale on a consistent grid (4pt) gives every gap a named step, so spacing reads as deliberate and a reviewer can spot an off-scale value instantly.
Incorrect (one-off spacing values):
const styles = StyleSheet.create(() => ({
panel: { padding: 12, marginBottom: 18, gap: 9 },
header: { marginTop: 13 },
}))
// Values like 9, 13, 18 appear nowhere else; layout rhythm is accidental.Correct (a spacing scale on a 4pt grid):
// theme.ts
space: { xs: 4, sm: 8, md: 16, lg: 24, xl: 32 }
const styles = StyleSheet.create((theme) => ({
panel: { padding: theme.space.md, marginBottom: theme.space.lg, gap: theme.space.sm },
header: { marginTop: theme.space.md },
}))
// Every gap is a step on the scale, so screens share a consistent rhythm.Reference: Unistyles theming
Size Interactive Targets to at Least 44 Points
An icon rendered at 16pt has a 16pt tappable area — well below the 44pt minimum both platforms recommend — so clinicians tapping on the move keep missing it. A touch-target token sets a minimum hit area, and hitSlop extends the touch region without changing the visual size.
Incorrect (tap area equals the small glyph):
<Pressable onPress={removeMedication}>
<Icon name="delete" size="sm" /> {/* roughly a 16pt tappable area */}
</Pressable>
// A 16pt target is far below 44pt; the button is easy to miss.Correct (minimum target token plus hitSlop):
const styles = StyleSheet.create((theme) => ({
iconButton: {
minWidth: theme.space.touchTarget, // 44
minHeight: theme.space.touchTarget,
alignItems: 'center',
justifyContent: 'center',
},
}))
<Pressable style={styles.iconButton} hitSlop={8} onPress={removeMedication}
accessibilityRole="button" accessibilityLabel="Remove medication">
<Icon name="delete" size="sm" />
</Pressable>Reference: React Native accessibility
Use Dynamic Functions for Per-Instance Style Values
When a style depends on a runtime prop (a progress ratio, a chart height), the temptation is to merge an inline object — which reintroduces per-render allocation and raw values. A Unistyles dynamic function takes the prop as an argument while keeping the rest of the style token-driven and theme-aware.
Incorrect (inline object to inject a prop value):
function ProgressBar({ ratio }: { ratio: number }) {
// an inline object is recreated each render just to thread the ratio into width
return (
<View style={styles.track}>
<View style={[styles.fillBase, { width: `${ratio * 100}%`, backgroundColor: '#0F766E' }]} />
</View>
)
}Correct (a dynamic function inside the StyleSheet):
const styles = StyleSheet.create((theme) => ({
track: { height: theme.space.xs, borderRadius: theme.radius.sm,
backgroundColor: theme.colors.surfaceMuted },
fill: (ratio: number) => ({
width: `${Math.min(ratio, 1) * 100}%`,
height: theme.space.xs,
backgroundColor: theme.colors.accent,
}),
}))
function ProgressBar({ ratio }: { ratio: number }) {
return <View style={styles.track}><View style={styles.fill(ratio)} /></View>
}
// The function keeps width prop-driven while color and height stay token-driven.Reference: Unistyles dynamic functions
Avoid Merging Styles With Inline Arrays in Lists
A style={[base, { ... }]} merge inside renderItem allocates a new array and a new object for every visible row on every render — multiplied across a 500-row schedule, that is real frame-time pressure. Encoding the condition as a boolean variant lets Unistyles pick the row style with no per-row allocation.
Incorrect (inline array plus object per row):
const renderItem = ({ item }: { item: Appointment }) => (
<View style={[styles.row, { backgroundColor: item.urgent ? '#FEF2F2' : '#FFFFFF' }]}>
<AppText>{item.patientName}</AppText>
</View>
)
// For a full schedule this allocates a fresh array and object per row, per render.Correct (a boolean variant picks the row style):
const styles = StyleSheet.create((theme) => ({
row: {
padding: theme.space.md,
variants: { urgent: { true: { backgroundColor: theme.colors.surfaceAlert },
false: { backgroundColor: theme.colors.surface } } },
},
}))
const AppointmentRow = memo(({ item }: { item: Appointment }) => {
styles.useVariants({ urgent: item.urgent })
return <View style={styles.row}><AppText>{item.patientName}</AppText></View>
})Reference: Unistyles variants
Drive Press and Disabled States From Variants
The style={({ pressed }) => [...]} callback gets copy-pasted into every button with slightly different opacities, so press feedback drifts across the app. Modeling pressed and disabled as variants defines the feedback once inside the design system button, and call sites get consistent behavior for free.
Incorrect (per-button pressed/disabled callback):
<Pressable
style={({ pressed }) => [
styles.button,
pressed && { opacity: 0.7 },
disabled && { opacity: 0.4 },
]}
disabled={disabled}
/>
// Copied into every button with drifting opacity values; feedback is inconsistent.Correct (states modeled as variants in one component):
const styles = StyleSheet.create((theme) => ({
button: {
backgroundColor: theme.colors.accent,
variants: {
pressed: { true: { opacity: 0.7 } },
disabled: { true: { opacity: 0.4 } },
},
},
}))
function AppButton({ disabled }: { disabled?: boolean }) {
const [pressed, setPressed] = useState(false)
styles.useVariants({ pressed, disabled })
return (
<Pressable style={styles.button} disabled={disabled}
onPressIn={() => setPressed(true)} onPressOut={() => setPressed(false)} />
)
}Web: press variants give no hover or keyboard-focus state on web, so the button feels inert to pointer and Tab users — add a _web block with _hover/_focus/cursor. See `platform-web-pseudo-states`.
Reference: Unistyles variants
Define Styles With StyleSheet.create, Not Inline Objects
An inline style object is rebuilt on every render and cannot carry variants or theme tokens, so it both costs allocations and hardcodes values. StyleSheet.create returns stable references that Unistyles tracks natively and re-resolves on theme change.
Incorrect (inline object literal rebuilt each render):
function VitalCard({ status }: { status: 'normal' | 'high' }) {
return (
<View style={{ padding: 16, borderRadius: 12,
backgroundColor: status === 'high' ? '#FEE2E2' : '#FFFFFF' }}>
<Text style={{ fontSize: 15, color: '#111827' }}>Blood pressure</Text>
</View>
)
}
// A fresh object every render defeats native tracking and hardcodes raw values.Correct (StyleSheet.create with tokens and variants):
const styles = StyleSheet.create((theme) => ({
card: {
padding: theme.space.md,
borderRadius: theme.radius.lg,
variants: { status: { normal: { backgroundColor: theme.colors.surface },
high: { backgroundColor: theme.colors.surfaceAlert } } },
},
label: { fontSize: theme.typography.body.fontSize, color: theme.colors.textPrimary },
}))
function VitalCard({ status }: { status: 'normal' | 'high' }) {
styles.useVariants({ status })
return <View style={styles.card}><Text style={styles.label}>Blood pressure</Text></View>
}Reference: Unistyles StyleSheet
Implement Component Variants With the Variants API
Selecting styles with ternary-merged arrays means every new state adds another && branch and another StyleSheet entry, and the merge runs on each render. The Unistyles variants API declares the options once and resolves the active one by key, keeping the component body free of conditional style logic.
Incorrect (ternary-merged style arrays):
function StatusPill({ status }: { status: AppointmentStatus }) {
return (
<View style={[
styles.pill,
status === 'confirmed' && styles.confirmed,
status === 'cancelled' && styles.cancelled,
status === 'pending' && styles.pending,
]}>
<AppText variant="caption">{status}</AppText>
</View>
)
}
// Each new status needs another && branch and another StyleSheet entry.Correct (variants resolve the status by key):
const styles = StyleSheet.create((theme) => ({
pill: {
paddingHorizontal: theme.space.sm,
variants: {
status: {
confirmed: { backgroundColor: theme.colors.statusConfirmed },
cancelled: { backgroundColor: theme.colors.statusCancelled },
pending: { backgroundColor: theme.colors.statusPending },
},
},
},
}))
function StatusPill({ status }: { status: AppointmentStatus }) {
styles.useVariants({ status })
return <View style={styles.pill}><AppText variant="caption">{status}</AppText></View>
}Reference: Unistyles variants
Wrap Third-Party Components With withUnistyles
Third-party components (icon sets, charts, maps) take plain prop values like color, so hardcoding a hex skips the theme and freezes the value across light and dark. withUnistyles maps theme tokens onto those props and re-applies them natively when the theme switches.
Incorrect (hardcoding a theme value into a third-party prop):
import { Ionicons } from '@expo/vector-icons'
<Ionicons name="calendar" size={24} color="#0F766E" />
// On a theme switch this icon stays teal — it never reads the theme.Correct (withUnistyles binds props to theme tokens):
import { withUnistyles } from 'react-native-unistyles'
import { Ionicons } from '@expo/vector-icons'
const ThemedIcon = withUnistyles(Ionicons, (theme) => ({
color: theme.colors.icon,
}))
<ThemedIcon name="calendar" size={24} />
// The icon now follows the theme and updates natively when it switches.Reference: Unistyles withUnistyles
Follow the System Color Scheme by Default
Branching on useColorScheme in each component scatters dark-mode logic and lets every screen pick slightly different dark values. Enabling adaptive themes lets Unistyles resolve light or dark from the OS once, so components reference semantic tokens and never branch on the scheme.
Incorrect (manual scheme branching repeated per screen):
function AppointmentSummary() {
const scheme = useColorScheme()
const background = scheme === 'dark' ? '#0B1220' : '#FFFFFF'
const foreground = scheme === 'dark' ? '#E5E7EB' : '#111827'
return (
<View style={{ backgroundColor: background }}>
<Text style={{ color: foreground }}>Follow-up in 2 weeks</Text>
</View>
)
}
// Each screen repeats the branch and can choose inconsistent dark colors.Correct (adaptive themes resolve the scheme once):
StyleSheet.configure({
themes: { light: lightTheme, dark: darkTheme },
settings: { adaptiveThemes: true }, // follows the OS appearance automatically
})
const styles = StyleSheet.create((theme) => ({
card: { backgroundColor: theme.colors.surface },
title: { color: theme.colors.textPrimary },
}))
function AppointmentSummary() {
return <View style={styles.card}><Text style={styles.title}>Follow-up in 2 weeks</Text></View>
}Reference: Unistyles adaptive themes
Drive Tablet Layouts With Breakpoints, Not Dimensions Checks
Dimensions.get() captures a width once, so a clinician rotating an iPad keeps the phone layout until the screen remounts. Unistyles breakpoints are defined in the config and re-resolved natively on every size change, so responsive styles update without a manual resize listener.
Incorrect (Dimensions captured once — wrong after rotation):
const { width } = Dimensions.get('window')
const columns = width > 768 ? 2 : 1 // read at module load; never updates on rotate
const styles = StyleSheet.create(() => ({
scheduleGrid: { flexDirection: columns === 2 ? 'row' : 'column' },
}))Correct (breakpoints re-resolve automatically):
// design-system/unistyles.ts
StyleSheet.configure({ breakpoints: { phone: 0, tablet: 768 }, themes })
const styles = StyleSheet.create((theme) => ({
scheduleGrid: {
flexDirection: { phone: 'column', tablet: 'row' }, // resolved per active breakpoint
gap: theme.space.md,
},
}))
// Rotating an iPad re-resolves the breakpoint natively; no Dimensions listener needed.Reference: Unistyles breakpoints
Register Themes and Breakpoints in One Typed Module
Calling StyleSheet.configure in more than one place lets app and test setups disagree on token values, and skipping module augmentation leaves theme typed as any so typos compile. A single config module with TypeScript declaration merging gives one source of truth and full autocomplete on every token.
Incorrect (scattered configure, untyped theme):
// configured in App.tsx, then again in a test helper with different values
StyleSheet.configure({ themes: { light: { colors: { accent: '#0F766E' } } } })
const styles = StyleSheet.create((theme) => ({
link: { color: theme.colors.acent }, // typo in a token name compiles silently
}))Correct (one module plus type augmentation):
// design-system/unistyles.ts — imported exactly once at the app entry point
import { StyleSheet } from 'react-native-unistyles'
const lightTheme = { colors: { accent: '#0F766E' } } as const
type AppThemes = { light: typeof lightTheme }
declare module 'react-native-unistyles' {
export interface UnistylesThemes extends AppThemes {}
}
StyleSheet.configure({ themes: { light: lightTheme }, settings: { initialTheme: 'light' } })
// theme is now fully typed, so theme.colors.acent fails to compile.Reference: Unistyles TypeScript guide
Switch Themes Through the Unistyles Runtime
Storing the active theme in React context means every consumer re-renders when the theme toggles — on a clinic dashboard that is the entire screen. Unistyles holds the theme in its C++ layer and updates the native nodes directly, so a theme switch repaints without re-rendering the React tree.
Incorrect (theme in React state re-renders every consumer):
const ThemeContext = createContext(lightTheme)
function ThemeProvider({ children }: PropsWithChildren) {
const [theme, setTheme] = useState(lightTheme)
// toggling re-renders every component that reads this context — the whole app
return <ThemeContext.Provider value={theme}>{children}</ThemeContext.Provider>
}
function ScreenHeader() {
const theme = useContext(ThemeContext) // subscribes the header to theme re-renders
return <View style={{ backgroundColor: theme.colors.surface }} />
}Correct (the runtime updates native nodes without re-rendering):
import { StyleSheet, UnistylesRuntime } from 'react-native-unistyles'
const styles = StyleSheet.create((theme) => ({
header: { backgroundColor: theme.colors.surface },
}))
function ScreenHeader() {
return <View style={styles.header} /> // no hook, no subscription, no re-render
}
function toggleTheme() {
UnistylesRuntime.setTheme(UnistylesRuntime.themeName === 'light' ? 'dark' : 'light')
}Reference: Unistyles 3.0 selective updates
Read Theme Values From the StyleSheet Argument
Reading the theme through a hook inside render forces a subscription and rebuilds an inline style object on every pass. Unistyles passes the theme as the argument to StyleSheet.create, producing a stable style reference that the engine re-resolves natively when the theme changes.
Incorrect (theme hook in render — subscription plus fresh object each pass):
function MedicationRow() {
const theme = useContext(ThemeContext)
// a new style object is allocated every render, and the row re-renders on theme reads
return (
<Text style={{ color: theme.colors.textPrimary, fontSize: 16 }}>Amoxicillin 500mg</Text>
)
}Correct (theme arrives as the StyleSheet.create argument):
const styles = StyleSheet.create((theme) => ({
name: { color: theme.colors.textPrimary, fontSize: theme.typography.body.fontSize },
}))
function MedicationRow() {
// styles.name is a stable reference; Unistyles repaints it natively on theme change
return <Text style={styles.name}>Amoxicillin 500mg</Text>
}Reference: Unistyles StyleSheet
Avoid Abstracting Tokens Beyond Three Layers
A token factory that resolves names through several maps makes it impossible to tell what color a component actually uses without running the app. The raw-to-semantic-to-component hierarchy is enough; adding string-keyed lookups or generator functions on top trades readability for flexibility you rarely need.
Incorrect (a four-level lookup nobody can trace):
// resolves through aliasMap → semanticMap → palette via a dotted string key
const resolveToken = (key: string) => palette[aliasMap[semanticMap[key]]]
const accent = resolveToken('button.primary.background.default.enabled')
const styles = StyleSheet.create(() => ({
bookButton: { backgroundColor: accent }, // what color is this? unknowable by reading
}))Correct (three plain layers you can read top to bottom):
const palette = { teal500: '#0F766E' }
const colors = { accent: palette.teal500 }
const components = { buttonPrimaryBackground: colors.accent }
const styles = StyleSheet.create((theme) => ({
bookButton: { backgroundColor: theme.components.buttonPrimaryBackground },
}))
// A reviewer traces buttonPrimaryBackground to accent to teal500 in three hops,
// each a literal object lookup rather than a runtime function.Reference: Unistyles theming
Define Every Token in the Unistyles Theme
When tokens live in both a standalone constants file and the Unistyles theme, the two sources drift apart and components disagree on the same color. Keeping the Unistyles theme as the only token source means every styled component resolves values the same way and theme switching stays automatic.
Incorrect (two token sources that have already drifted):
// constants/colors.ts — referenced by older screens
export const COLORS = { accent: '#0F766E', danger: '#DC2626' }
// design-system/theme.ts — referenced by newer screens
export const lightTheme = { colors: { accent: '#0EA5A4' } } // accent already differs
const styles = StyleSheet.create(() => ({
prescriptionBadge: { backgroundColor: COLORS.danger }, // ignores the theme entirely
}))Correct (one Unistyles theme is the single source):
// design-system/unistyles.ts
import { StyleSheet } from 'react-native-unistyles'
const lightTheme = {
colors: { accent: '#0F766E', danger: '#DC2626', surface: '#FFFFFF' },
} as const
StyleSheet.configure({ themes: { light: lightTheme }, settings: { initialTheme: 'light' } })
// every component reads from the theme argument, never a parallel constants file
const styles = StyleSheet.create((theme) => ({
prescriptionBadge: { backgroundColor: theme.colors.danger },
}))Reference: Unistyles configuration
Tokenize Elevation as Surface and Shadow Pairs
Shadows alone convey depth in light mode but vanish against dark backgrounds, so depth must be expressed as a pair: a surface tint plus a shadow. Copying raw shadow props into each component produces a dozen slightly different elevations that all break in dark mode. An elevation token bundles both halves so depth reads consistently in every theme.
Incorrect (ad-hoc shadow props copied per component):
const styles = StyleSheet.create(() => ({
card: {
shadowColor: '#000',
shadowOpacity: 0.2,
shadowRadius: 8,
shadowOffset: { width: 0, height: 2 },
elevation: 4,
},
}))
// Pasted into many components with drifting values; in dark mode the black
// shadow is invisible and no surface tint communicates that the card is raised.Correct (elevation token pairing a surface tint with a shadow):
// design-system/theme.ts
const lightTheme = {
colors: { surfaceRaised: '#FFFFFF' },
elevation: {
raised: { shadowColor: '#0F172A', shadowOpacity: 0.16, shadowRadius: 8,
shadowOffset: { width: 0, height: 2 }, elevation: 4 },
},
}
const styles = StyleSheet.create((theme) => ({
card: { ...theme.elevation.raised, backgroundColor: theme.colors.surfaceRaised },
}))
// The dark theme supplies a lighter surfaceRaised, so depth reads even when the
// shadow itself is barely visible.Reference: Unistyles theming
Avoid Raw Color and Size Literals in Components
Hex strings and off-scale numbers written directly in component styles are invisible to the token system — they cannot be themed, audited, or changed in one place. Routing every value through theme tokens lets a single edit update all usages and lets a lint rule reject literals before they merge.
Incorrect (hex strings and off-scale numbers in the component):
const styles = StyleSheet.create(() => ({
vitalsRow: {
backgroundColor: '#F1F5F9', // ungoverned hex, invisible to dark mode
padding: 13, // off-scale number nobody else uses
borderRadius: 7,
borderColor: 'rgba(0,0,0,0.08)', // hardcoded alpha breaks in dark theme
},
}))Correct (theme tokens only):
const styles = StyleSheet.create((theme) => ({
vitalsRow: {
backgroundColor: theme.colors.surfaceMuted,
padding: theme.space.md,
borderRadius: theme.radius.sm,
borderColor: theme.colors.border,
},
}))
// Every value resolves through the theme, so the dark theme overrides them all
// and an ESLint rule can ban color-literal strings inside feature files.When NOT to use this pattern:
- The token definition files themselves — the raw palette and the Unistyles theme — necessarily hold literal hex and numbers; that is where they are sanctioned. The ban applies to component and feature code.
Reference: Unistyles theming
Name Tokens by Role, Not by Value
A token named for its color (green, red) becomes a lie the moment design changes that color — the name says green but the value is teal. Naming tokens for their role (statusConfirmed) keeps the name accurate through any value change and tells the reader what the token is for, not just what it looks like today.
Incorrect (value-named tokens that go stale):
const lightTheme = {
colors: { green: '#16A34A', red: '#DC2626', blue: '#2563EB' },
}
// An appointment badge uses colors.green to mean "confirmed".
const styles = StyleSheet.create((theme) => ({
badgeConfirmed: { backgroundColor: theme.colors.green },
}))
// When design recolors confirmed to teal, the token still reads "green" —
// the next engineer has no idea green now holds a teal value.Correct (role-named tokens that stay honest):
const lightTheme = {
colors: { statusConfirmed: '#16A34A', statusCancelled: '#DC2626', statusPending: '#2563EB' },
}
const styles = StyleSheet.create((theme) => ({
badgeConfirmed: { backgroundColor: theme.colors.statusConfirmed },
}))
// Recoloring confirmed to teal changes the value only; the name still describes intent.Reference: Building the Airbnb Design System
Layer Tokens as Raw, Semantic, and Component Scales
A flat token map where components reference raw palette names couples every screen to specific brand values. When the brand changes, you edit every file. Three layers — raw palette, semantic roles, and component tokens — give you one place to change a value while keeping component code stable.
Incorrect (raw palette used as the only layer — a rebrand edits every screen):
// design-system/theme.ts
export const lightTheme = {
colors: { teal500: '#0F766E', gray50: '#F9FAFB', gray900: '#111827' },
}
// features/appointments/AppointmentCard.tsx
const styles = StyleSheet.create((theme) => ({
card: {
backgroundColor: theme.colors.gray50,
borderColor: theme.colors.teal500, // brand value hardcoded at the call site
},
}))
// Switching the brand from teal to indigo means renaming teal500 in every
// component that referenced it — there is no single place to change.Correct (raw to semantic to component layers — a rebrand edits one map):
// design-system/theme.ts
const palette = { teal500: '#0F766E', gray50: '#F9FAFB', gray900: '#111827' }
export const lightTheme = {
palette, // layer 1: raw values
colors: { surface: palette.gray50, accent: palette.teal500, textPrimary: palette.gray900 },
components: { card: { background: palette.gray50, border: palette.teal500 } }, // layer 3
}
// features/appointments/AppointmentCard.tsx
const styles = StyleSheet.create((theme) => ({
card: {
backgroundColor: theme.components.card.background,
borderColor: theme.components.card.border,
},
}))
// A rebrand changes palette.teal500 once; the semantic and component layers follow.Reference: Unistyles theming, Building the Airbnb Design System
Related skills
FAQ
What does expo-design-system do?
expo-design-system is a Claude Code skill for ai & agent building. It helps developers move faster with AI-assisted coding.
When should I use expo-design-system?
When you need to helps with ai & agent building tasks during ai-assisted development, or when expo-design-system is a claude code skill for ai & agent building. it helps developers move faster with ai-assisted coding.
What are the main capabilities?
expo-design-system; AI & Agent Building; AI-coding skill.