Skip to content
Kinetixui

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.swift

That 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:swiftui

What's covered#

KinetixTheme plus Kinetix* views for everything in packages/ui/src/components except the eight non-ports below.

  • Controls — KinetixButton (buttonVariants CVA 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, KinetixInputGroup family, KinetixSelect, KinetixDatePicker, KinetixCalendar, KinetixFileUpload, KinetixField (+ Label / Description / Message).
  • Display — KinetixBadge, KinetixTag, KinetixLabel, KinetixKbd (+ KbdGroup), KinetixSeparator, KinetixSkeleton, KinetixSpinner, KinetixProgress, KinetixCircularProgress, KinetixAvatar (+ Fallback), KinetixImage, KinetixCard family, KinetixAlert family, KinetixInform, KinetixQuote, KinetixMetric, KinetixCodeBlock, KinetixChart, KinetixAudioPlayer, KinetixAspectRatio, KinetixEmpty family.
  • Data — KinetixTable family, KinetixDataTable (sort + pagination), KinetixCarousel.
  • Navigation & disclosure — KinetixAccordion family, KinetixCollapsible, KinetixTabsList / Trigger / Content, KinetixList / KinetixListItem, KinetixScrollArea, KinetixResizablePanels, KinetixSidebar / Item, KinetixBreadcrumb family, KinetixPagination family, KinetixTableOfContents, KinetixNavigationBar, KinetixAppBar / Link, KinetixTabBar / Item, KinetixFooter family.
  • Overlays & menus — KinetixDialog family, KinetixAlertDialog, KinetixModal, KinetixSheet, KinetixDrawer, KinetixPopover, KinetixHoverCard, KinetixTooltip, KinetixDropdownMenu (+ shared KinetixMenuItem / Separator / Label), KinetixContextMenu, KinetixMenubar / Menu, KinetixCommandDialog family, 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 no react-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; compose KinetixPopover + a filtered list, or reach for KinetixSelect.
  • 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 from Select's custom popover; KinetixSelect already wraps SwiftUI's own Menu.
  • AvatarGroup — re-wraps its children (React.Children.toArray), not an idiomatic SwiftUI pattern; compose an HStack of KinetixAvatars 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 of KinetixPopover steps, 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 / .dropDestination directly for the specific board.

Known gaps#

  • No remote publishing. swift build on macos-latest (against the .macOS(.v13) target, no simulator) is the sole compiler feedback on every push that touches packages/ui-swiftui, tokens/, or style-dictionary/.
  • Platform controls are reused where they're strong — KinetixSlider / KinetixDatePicker / KinetixCalendar / KinetixScrollArea / KinetixCarousel / KinetixChart wrap the SwiftUI control (or the Charts framework), so those use the system look, not a hand-built one (a documented gap, same call as the Compose Material3 wraps). KinetixCalendar is single-date only, no range select.
  • Type scale is wired. Font.kinetixBody / .kinetixLabelMd / .kinetixTitleMd … (TypeScale.swift, from the vendored KinetixType.swift) back the components' .font(…); KinetixTextStyle
    • KinetixType.<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.