SwiftUI
packages/ui-swiftui is the native SwiftUI port of KinetixUI — the
iOS counterpart to the Jetpack Compose library.
Kinetix* views for 90 of the
98 React components — the full React component
surface bar the eight deliberate non-ports, built in batches on a macOS swift build CI path;
each mirrors its packages/ui/src/components/*.tsx counterpart 1:1, with
a doc comment stating anything not carried over.
It's a standalone SwiftPM package inside this monorepo, not a
pnpm/npm workspace package — there's no package.json, so it's invisible
to pnpm install / Turborepo. It lives here for proximity to the token
source it's built from.
Not on the SwiftPM registry. Consume it from a repo checkout with
.package(path: "…/packages/ui-swiftui") — the same "own the code"
model as the web CLI and the Compose library. A tagged release needs
repo/signing decisions and is deliberately not wired up.
Setup#
Package.swift declares one library target, KinetixUI, for
.iOS(.v16) / .macOS(.v13) (the floor is 16 / 13 because
KinetixButton uses the View-level .tracking text modifier).
Wrap a screen — or a #Preview — in KinetixTheme so every Kinetix*
view resolves to the right light/dark token set:
import KinetixUI
import SwiftUI
struct ContentView: View {
@State private var enabled = true
var body: some View {
KinetixTheme {
// KinetixTheme picks light/dark off the system colorScheme,
// the same as the web "system" mode.
VStack(spacing: 12) {
KinetixButton(action: {}) { Text("Get started") }
KinetixButton(variant: .outline, size: .sm, action: {}) { Text("Cancel") }
KinetixButton(variant: .destructive, action: {}) { Text("Delete") }
.disabled(!enabled)
}
.padding(16)
}
}
}@Environment(\.kinetixColors) exposes the active KinetixColors set if
you need a token directly.
A custom theme#
KinetixTheme takes your own colour sets and still switches on
colorScheme:
KinetixTheme(light: AcmeTheme.light, dark: AcmeTheme.dark) {
KinetixButton(action: {}) { Text("Save") }
}You can write that pair by hand, or export one from a Create design:
kinetixui preset swiftui KX1_… --name AcmeTheme --output Sources/AcmeTheme.swiftThat writes a public enum with a light and a dark KinetixColors,
built from the same resolved theme the workspace exports —
see the CLI reference.
Or skip the terminal: Create's Export panel has a SwiftUI target that generates the same file in the browser, and shows the command above beside it. The preview there is web — it renders the React components — so what changes when you select SwiftUI is the generated file, not the picture.
Colours, and now corners and shadows too. KinetixColors used to be the
whole themeable surface: corner radius and elevation were literals inside each
view (RoundedRectangle(cornerRadius: 16)), so a design could not move them.
The package ships two more sets beside it, and the components read them:
KinetixTheme(
light: AcmeTheme.light,
dark: AcmeTheme.dark,
radii: KinetixRadii(field: 2, control: 6, container: 10, surface: 14),
elevations: .flat
) {
ContentView()
}KinetixRadii is four corners named for what the corner does — field (inputs,
tags), control (buttons, tabs), container (alerts, panels), surface (cards,
dialogs, sheets) — plus none and full. KinetixElevations is the sm / md
/ lg / xl ladder, each step an ordered list of shadow layers, the same shape
Flutter's KinetixShadow uses. Both default to the shipped tokens, so
KinetixTheme { … } looks exactly as it did.
preset swiftui exports all three. A Create design arrives as colours, radii
and elevation together:
KinetixTheme(
light: AcmeTheme.light,
dark: AcmeTheme.dark,
radii: AcmeTheme.radii,
elevations: AcmeTheme.elevations
) {
ContentView()
}A value the design did not change — a colour, or a whole ladder — is written as a reference to the shipped token rather than a copy of today's number, so it keeps following the library. A design that changes nothing exports a file of pure references.
Two limits worth knowing. SwiftUI's .shadow has no spread, and the shared
token ladder uses it on lg, so a SwiftUI lg renders slightly larger than the
web's — the built-in ladder is mapped the same way, so a generated theme and the
default differ from the web identically. And eight corners across five components
(AppBar, ColorPicker, FileUpload, InputOtp, Sidebar) sit at 6, which is not a
step on the 4 / 8 / 12 / 16 ladder; they stay literal rather than being moved
silently.
Tokens: SwiftUI Color, not UIColor#
The pre-existing iOS token output
(packages/tokens/dist/ios/KinetixColors.swift, Theme.swift) is
UIColor-based. UIColor is UIKit-only — it doesn't compile on macOS,
so a package that must swift build on a macOS CI runner can't consume
it. The token engine emits a parallel SwiftUI-Color semantic set
for this library (KinetixColorsSwiftUI light /
KinetixColorsSwiftUIDark dark), from the ios-swiftui-theme platform
block in style-dictionary/sd.config.mjs. The UIColor output is
untouched — this is purely additive, the same way Android has Color.kt
alongside Theme.kt / Theme.dark.kt.
Unlike every other native platform, this set runs on both Style
Dictionary passes — a KinetixTheme needs real light and dark values.
The vendored copies under Sources/KinetixUI/ are generated. After any
token change:
pnpm build:tokens && pnpm vendor:swiftuiWhat's covered#
KinetixTheme plus Kinetix* views for everything in
packages/ui/src/components except the eight non-ports below.
- Controls —
KinetixButton(buttonVariantsCVA 1:1: variant × size × corners),KinetixCheckbox,KinetixSwitch,KinetixToggle/KinetixToggleGroup,KinetixSlider,KinetixRadioGroup/KinetixRadioButton,KinetixRating,KinetixFab,KinetixStepper. - Inputs —
KinetixInput(corners enum, trailing slot),KinetixTextarea,KinetixNumberInput,KinetixPasswordInput,KinetixInputOtp,KinetixInputGroupfamily,KinetixSelect,KinetixDatePicker,KinetixCalendar,KinetixFileUpload,KinetixField(+Label/Description/Message). - Display —
KinetixBadge,KinetixTag,KinetixLabel,KinetixKbd(+KbdGroup),KinetixSeparator,KinetixSkeleton,KinetixSpinner,KinetixProgress,KinetixCircularProgress,KinetixAvatar(+Fallback),KinetixImage,KinetixCardfamily,KinetixAlertfamily,KinetixInform,KinetixQuote,KinetixMetric,KinetixCodeBlock,KinetixChart,KinetixAudioPlayer,KinetixAspectRatio,KinetixEmptyfamily. - Data —
KinetixTablefamily,KinetixDataTable(sort + pagination),KinetixCarousel. - Navigation & disclosure —
KinetixAccordionfamily,KinetixCollapsible,KinetixTabsList/Trigger/Content,KinetixList/KinetixListItem,KinetixScrollArea,KinetixResizablePanels,KinetixSidebar/Item,KinetixBreadcrumbfamily,KinetixPaginationfamily,KinetixTableOfContents,KinetixNavigationBar,KinetixAppBar/Link,KinetixTabBar/Item,KinetixFooterfamily. - Overlays & menus —
KinetixDialogfamily,KinetixAlertDialog,KinetixModal,KinetixSheet,KinetixDrawer,KinetixPopover,KinetixHoverCard,KinetixTooltip,KinetixDropdownMenu(+ sharedKinetixMenuItem/Separator/Label),KinetixContextMenu,KinetixMenubar/Menu,KinetixCommandDialogfamily,KinetixToaster.
Selection / expansion / presentation state is caller-owned throughout (a
@Binding or a plain isExpanded / isSelected + action) — the same
division the Compose port uses, since there's no SwiftUI context to
thread a shared value through the way Radix does. Overlay views
(KinetixDialog, KinetixSheet, KinetixSidebar, KinetixToaster, …)
are placed in an .overlay { } or a top-level ZStack and driven by an
isPresented / isOpen binding.
Not ported (deliberate)#
Form—KinetixField(+Label/Description/Message) is the equivalent; there's noreact-hook-form-shaped context to wrap. Same call as the Compose port.NavigationMenu— a hover-triggered desktop mega-menu with no touch idiom.Combobox— a recipe (Popover + Command) on the web, not a standalone component; composeKinetixPopover+ a filtered list, or reach forKinetixSelect.DirectionProvider— the web needs a provider to thread direction through Radix; this platform already carries layout direction in the framework itself, so there is nothing to wrap.NativeSelect— wraps the browser's own<select>, a web-only escape hatch fromSelect's custom popover;KinetixSelectalready wraps SwiftUI's ownMenu.AvatarGroup— re-wraps its children (React.Children.toArray), not an idiomatic SwiftUI pattern; compose anHStackofKinetixAvatars with negative spacing (spacing: -8).Tour— targets an arbitrary already-rendered element by CSS selector; SwiftUI has no equivalent live-tree query. Compose a sequence ofKinetixPopoversteps, each anchored to the view it explains.KanbanBoard— built on@dnd-kit's accessible multi-container drag-and-drop; hand-rolling that from scratch is a much bigger lift than porting the component. Use.draggable/.dropDestinationdirectly for the specific board.
Known gaps#
- No remote publishing.
swift buildonmacos-latest(against the.macOS(.v13)target, no simulator) is the sole compiler feedback on every push that touchespackages/ui-swiftui,tokens/, orstyle-dictionary/. - Platform controls are reused where they're strong —
KinetixSlider/KinetixDatePicker/KinetixCalendar/KinetixScrollArea/KinetixCarousel/KinetixChartwrap the SwiftUI control (or theChartsframework), so those use the system look, not a hand-built one (a documented gap, same call as the Compose Material3 wraps).KinetixCalendaris single-date only, no range select. - Type scale is wired.
Font.kinetixBody/.kinetixLabelMd/.kinetixTitleMd… (TypeScale.swift, from the vendoredKinetixType.swift) back the components'.font(…);KinetixTextStyleKinetixType.<style>are public for your own text. Letter-spacing is still a sibling.tracking(…); a single style-and-tracking modifier is a possible follow-up.
- Icons use SF Symbols (
checkmark,chevron.down,star.fill, …) rather than the web's lucide set — the closest system equivalent.