USE 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.
85%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
Passed
No findings from the security scan
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:
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.
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 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.
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:
.tint from the theme primary.font from typography.body so Text is CDS body unless a role is set.foregroundStyle from colors.fg.toggleStyle(.cds(.primary)).progressViewStyle(.cds(.m)).textFieldStyle(.cds) (not picker styles: apps can't implement PickerStyle /
DatePickerStyle).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))..alert / .confirmationDialog are not CDS Alert. They ignore app ButtonStyle and
will never match RN’s custom modal. Ship a CDS Alert view for the RN component. Leave SwiftUI
.alert for true system dialogs (not a CDS export)..cdsText(.title3) (and so on). Environment .font can only
supply one default (body). Wrapping CDSText("Hello") is still wrong — it blocks Text
concatenation, AttributedString, and Label..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.
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.
From packages/mobile/ (and shared bits in packages/common/):
bgPrimary, space.x2, borderRadius.roundedFull), and the visual design
(padding, radius, chrome, pictogram, caret). That is the iOS spec.Box/HStack RN trees, ThemeProvider wrappers in every story, React memo/useCallback,
web className/styles, Pressable-as-root, RN-only props (testID patterns that don't map).Map tokens through CDSTheme (@Environment(\.cdsTheme)). Never hard-code hex, point sizes, or
UIColor that duplicates a token.
packages/cds-ios/Sources/Components/<Name>.swift. Metrics-only helpers can live
next to the style (see buttonColors / buttonMetrics) or in Sources/Components/internal/
if shared.internal. No public on the component, Style, modifier, or variant enums until gallery +
token tests exist and a human decides to stabilize.@Environment(\.cdsTheme) and @Environment(\.isEnabled). Do not take a theme: parameter
on the Style unless a pure function needs it for tests.buttonColors, toggleTrackColor, progressCircleDiameter)
so Tests/CDSDesignSystemTests/ComponentStyleTests.swift can lock token wiring without
rendering.Lottie for a CDS animation.Button with a onTapGesture
View just to draw chrome — that's what ButtonStyle.makeBody is for.<Name>GalleryView.swift registered in GalleryDestination and
ComponentGalleryView, or a section inside OtherComponentsGalleryView.swift for components
without their own destination — using the real call site (HIG control + Style, or the CDS
view for overlays).yarn nx run cds-ios:test. Then yarn nx run cds-ios:build if the gallery or package
graph changed. Do not run unscoped yarn test.struct CDSButton: View that takes title / variant and hides SwiftUI.ButtonBox as CDS.Box.alert / DisclosureGroup / .popover as CDS Alert / Accordion / Tooltip.buttonStyle(.cds(.primary)) on CDSThemeProvider (filled primary on every Button)public components so the gallery compiles (use @testable)Lottie (or any library) from gallery or from a public CDS headerUIViewRepresentable when a SwiftUI Style/modifier/view will doCDSTextStyle a View or ViewModifierCDSTheme, pure mappers unit-testedinternal visibilityyarn nx run cds-ios:test passescoinbase/cds
85c8e17
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.