CDS iOS — migrate RN → SwiftUI
SkillDev toolsUSE THIS when migrating a CDS React Native component to native iOS, or when writing or reviewing SwiftUI in packages/cds-ios or apps/ios-gallery. Covers rewrite patterns (Style vs SwiftUI API vs CDS view), RN visual spec (not HIG look-alikes), HIG-styled control enforcement, tokens, gallery, and tests.
Instructions available. Your AI can read the instructions. Execution depends on the setup they require.
Account requirements not reviewed. Check the skill instructions before use; ahel provides instructions and does not run this skill.
Add ahel to your AI once: Claude, ChatGPT, Cursor, Claude Code or Codex. Then ask it to use this.
Then ask your AI: use the CDS iOS skill
What this skill tells your AI
The instructions your AI receives, as published by coinbase/cds in .claude/skills/swiftui-best-practices/SKILL.md and read by ahel’s review.
Read packages/cds-ios/AGENTS.md before writing any Swift. That file is the API-boundary source of
truth (public theme only; components stay internal until they stabilize). This skill is the
migration workflow: take one RN component and produce the iOS equivalent without wrapping HIG
controls in a CDS view.
CDS SwiftUI Best Practices is the working reference (parallel to CDS Compose Best Practices). Read it before porting a component, and add new port learnings there, not here. It includes the full RN component → iOS map. If Linear is unavailable, rely on this file.
Also read:
- Native CDS: Rewrite Goals
- The component's Linear issue in Migrate CDS components to native
- RN source under
packages/mobile/(capability, not view tree)
Do not copy RN JSX into SwiftUI. Do not share widget code with Android. Do not make
components public to make the gallery compile. Do not leak Lottie / third-party types into
the CDS public (or even internal-customer-facing) API.
Visual spec is RN, not HIG. Match the React Native component’s layout, states, and chrome as
closely as iOS allows (tokens, Figma, RN source). HIG wins only for OS chrome that is not a CDS
component (nav bar, keyboard, status bar) or where iOS physically cannot match (Dynamic Type
reflow, safe area, no hover). Do not ship SwiftUI .alert / DisclosureGroup / .popover as the
CDS Alert / Accordion / Tooltip just because Apple has a look-alike.
Classify before writing code
Pick one rewrite pattern. If the Linear issue already has ios_work, honor it unless it still
says “use SwiftUI API” for a component whose chrome does not match RN — then reclassify to
CDS view.
| Pattern | When | Call site | CDS ships |
|---|---|---|---|
| style SwiftUI control | Apple has the widget and a Style protocol, and makeBody can match RN chrome (Button, Toggle, ProgressView, TextField) | Keep Apple's type + attach .cds | internal Style + .cds(…) factory |
| modifier on SwiftUI view | Apple has the widget but no Style protocol (Text, Divider insets) | Keep Apple's type + .cdsText / .cdsDivider | internal ViewModifier |
| CDS view | RN chrome is not the platform default (Alert modal, Accordion, Tooltip / Nudge, Coachmark, Avatar, Lottie host, SlideButton) | Alert(…), Accordion(…), SlideButton(…) | internal View |
| use SwiftUI API | The RN component already looks like the system API (HStack / Spacer / padding, existing CDSThemeProvider) | Use the system API | Gallery + docs only |
| skip | Not iOS (AndroidNavigationBar, MediaQueryProvider / useBreakpoints) | n/a | Do not port |
Text is modifier, not Style: SwiftUI has no TextStyle protocol. CDSTextStyle is a
typography role enum, not a View. Never name a new type CDSTextStyle for a modifier.
When two patterns could apply, prefer: Style → modifier → CDS view (if RN chrome differs) → SwiftUI API (only if it already looks like RN) → skip.
Need Design = the native version cannot match RN and would look or behave dramatically different (research question 7). A missing Figma link alone is not a reason, and neither is “should this look like HIG instead.”
Do not wrap styled HIG controls — wrap the app
Do not invent CDSButton, CDSToggle, CDSText, CDSProgressCircle, CDSTextField, or
CDSDivider views that hide the system widget. Overlay components whose RN chrome is not
system chrome (Alert, Accordion, Tooltip) are CDS views — that is visual parity, not a
Button wrapper.
The goal is the entire app looks like CDS, not “developers remembered to import CDSButton”. Those are opposite mechanisms:
| Approach | Forgotten Button("Save") {} | Entire app? |
|---|---|---|
Wrap in CDSButton | Compiles, looks like HIG | No — CDS is opt-in per control |
Default Style on CDSThemeProvider | Compiles, looks like CDS | Yes — CDS is opt-out per chrome |
SwiftUI cannot replace SwiftUI.Button at import time the way RN replaces Pressable with
@coinbase/cds-mobile Button. The analog of MaterialTheme / RN ThemeProvider is environment
inheritance. Wrap CDSThemeProvider around the app. Do not wrap the control.
A CDSButton type also swallows SwiftUI API (ButtonRole, toolbar placement, keyboardShortcut,
Menu, label as View) and recreates the RN facade this rewrite exists to delete.
How the whole app becomes CDS
CDSThemeProvider already injects \.cdsTheme and colorScheme. It should also install the
SwiftUI style environment so unstyled HIG controls pick up CDS without a per-instance modifier:
- Always-safe defaults (do these on the provider):
.tintfrom the theme primary.fontfromtypography.bodysoTextis CDS body unless a role is set.foregroundStylefromcolors.fg.toggleStyle(.cds(.primary)).progressViewStyle(.cds(.m))- future
.textFieldStyle(.cds)(not picker styles: apps can't implementPickerStyle/DatePickerStyle)
- Button is inherited too, but the default variant is not filled primary. A root
.buttonStyle(.cds(.primary))paints every toolbar item, list-row button, and many nav actions as a primary pill — that is not “the app is CDS”, that is “the app is covered in CTAs”. Default a CDS style that applies type, color, radius, and press without the filled primary chrome (a.cds/.cds(.plain)default). Explicit CTAs still write.buttonStyle(.cds(.primary)). Toolbar / list chrome that must stay HIG opts out with.buttonStyle(.plain)(or a later.cds(.toolbar)). - OS
.alert/.confirmationDialogare not CDS Alert. They ignore appButtonStyleand will never match RN’s custom modal. Ship a CDS Alert view for the RN component. Leave SwiftUI.alertfor true system dialogs (not a CDS export). - Typography roles still use
.cdsText(.title3)(and so on). Environment.fontcan only supply one default (body). WrappingCDSText("Hello")is still wrong — it blocksTextconcatenation,AttributedString, andLabel. - Gallery is the spec. Show both the inherited default and the explicit variant override.
- Do not add a second sugar (
.cdsButton(.primary)that only forwards to.buttonStyle) unless Style discovery is proven painful.
If a product app wants RetailPrimaryButton, that wrapper lives in the app, not in CDS.
Shipped call sites (copy these)
Button("Primary") { }
.buttonStyle(.cds(.primary))
Button(action: next) {
CDSButtonLabel("Continue", trailing: Image(systemName: "chevron.right"))
}
.buttonStyle(.cds(.primary, size: .l))
Toggle("Notifications", isOn: $on)
.toggleStyle(.cds(.primary))
Text("Balance")
.cdsText(.title3)
ProgressView()
.progressViewStyle(.cds(.m))
// CDS Alert (RN overlay) — custom view, not SwiftUI .alert
// Alert(title: "Delete wallet?", …)
// OS dialog only — not the CDS Alert component
.alert("Delete wallet?", isPresented: $show) {
Button("Delete", role: .destructive) { }
Button("Cancel", role: .cancel) { }
}
CDSButtonLabel is a label helper, not a Button wrapper. Skip it when the label is custom.
Read RN for capability, not tree
From packages/mobile/ (and shared bits in packages/common/):
- Take: variants, sizes, state (disabled, loading, error), a11y labels, animation intent,
token names (
bgPrimary,space.x2,borderRadius.roundedFull), and the visual design (padding, radius, chrome, pictogram, caret). That is the iOS spec. - Leave:
Box/HStackRN trees,ThemeProviderwrappers in every story, Reactmemo/useCallback, webclassName/styles, Pressable-as-root, RN-only props (testIDpatterns that don't map).
Map tokens through CDSTheme (@Environment(\.cdsTheme)). Never hard-code hex, point sizes, or
UIColor that duplicates a token.
Implement
- Put code in
packages/cds-ios/Sources/Components/<Name>.swift. Metrics-only helpers can live next to the style (seebuttonColors/buttonMetrics) or inSources/Components/internal/if shared. - Stay
internal. Nopublicon the component, Style, modifier, or variant enums until gallery + token tests exist and a human decides to stabilize. - Use
@Environment(\.cdsTheme)and@Environment(\.isEnabled). Do not take atheme:parameter on the Style unless a pure function needs it for tests. - Extract pure mapping functions (
buttonColors,toggleTrackColor,progressCircleDiameter) soTests/CDSDesignSystemTests/ComponentStyleTests.swiftcan lock token wiring without rendering. - Third-party (Lottie): wrap in a CDS type. Call sites never import
Lottiefor a CDS animation. - Accessibility: keep system traits from the HIG control. Don't replace
Buttonwith aonTapGestureViewjust to draw chrome — that's whatButtonStyle.makeBodyis for. - Add a gallery section — a new
<Name>GalleryView.swiftregistered inGalleryDestinationandComponentGalleryView, or a section insideOtherComponentsGalleryView.swiftfor components without their own destination — using the real call site (HIG control + Style, or the CDS view for overlays). - Tests:
yarn nx run cds-ios:test. Thenyarn nx run cds-ios:buildif the gallery or package graph changed. Do not run unscopedyarn test.
Anti-patterns
struct CDSButton: Viewthat takestitle/variantand hidesSwiftUI.Button- Porting RN
BoxasCDS.Box - Shipping SwiftUI
.alert/DisclosureGroup/.popoveras CDS Alert / Accordion / Tooltip - Setting
.buttonStyle(.cds(.primary))onCDSThemeProvider(filled primary on every Button) publiccomponents so the gallery compiles (use@testable)- Copying Android composable names/APIs because "parity"
- Importing
Lottie(or any library) from gallery or from a public CDS header - Using
UIViewRepresentablewhen a SwiftUI Style/modifier/view will do - Making
CDSTextStyleaVieworViewModifier
Checklist
- Pattern chosen (Style / modifier / CDS view / SwiftUI API / skip)
- Visual matches RN (gallery vs RN/Figma); HIG analog used only if it already looks like RN
- No wrapper around Button / Toggle / Text / ProgressView
- Tokens via
CDSTheme, pure mappers unit-tested - Gallery shows the real call site
-
internalvisibility -
yarn nx run cds-ios:testpasses
Signals
- GitHub stars
- 507
- Forks
- 110
- Last commit
- Sep 2026
Advanced
- Item type
- skill
- Key
swiftui-best-practices- Source
- github.com/coinbase/cds
Related picks
Skill · emilkowalski
The pick for Swiftswift-concurrency-expert
Skill · davila7
The pick for Swiftreact-component-performance
Skill · davila7
The pick for Reactreact-doctor
Skill · millionco
The pick for Reactreact-native-testing
Skill · callstack
The pick for React Nativevercel-react-native-skills
Skill · vercel-labs
The pick for React Native