Skip to content

Customization & Style Protocols

Beyond swapping a Theme, ThemeKit lets you re-skin whole families of components — cards, fields, chips, bars, meters, toasts, list rows — without forking a single one. This is the flexibility architecture that shipped across 0.11.0–0.16.0.

Six archetype style protocols mirror SwiftUI’s own ButtonStyle idiom: a Configuration describing the component’s state, a protocol with one makeBody(configuration:) requirement, and a .xStyle(_:) modifier.

ProtocolModifierPilot / adopters
CardStyle.cardStyle(_:)Card, FlightCard, RoomCard, DestinationCard, HotelResultCard, FareFamilyCard… (16 card-family organisms)
FieldStyle.fieldStyle(_:)TextInput, Select, DateField, TimeField, OTPInput, SearchBar… (15 form-family molecules)
ChipStyle.chipStyle(_:)Chip, ImageChip, CompactChip, ChoseChip, FilterChip, MapPriceMarker
BarStyle.barStyle(_:)SheetHeader, Footer, PageHeader, NavigationBar, StickyBookingBar
MeterStyle.meterStyle(_:)ProgressBar, RadialProgress, Steps
ToastStyle.toastStyle(_:)AlertToast, Feedback
ListRowStyle.listRowStyle(_:)ListRow

Every default style reproduces the component’s original look exactly — adopting a style protocol never changes existing call sites. Write a custom style once, apply it to every component in the family:

struct GlassCardStyle: CardStyle {
func makeBody(configuration: CardStyleConfiguration) -> some View {
configuration.content
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 20))
}
}
FlightCard(airline: "Anadolu Air", from: "IST", to: "ESB", departure: dep, arrival: arr)
.price(1_299)
.cardStyle(GlassCardStyle()) // re-skins the shell; FlightCard's content is untouched

Chrome styles (your design system’s paint)

Section titled “Chrome styles (your design system’s paint)”

A host design system often wraps a ThemeKit component and needs its own tokens, text styles and icon font to match its spec. Chrome styles let it draw the chrome while ThemeKit keeps the behaviour, content, slots, accessibility, RTL and state. Each ships only .default — the built-in look — and with no style set the component renders exactly as before.

ProtocolModifierComponent
ButtonChromeStyle.buttonChromeStyle(_:)ThemeButton
BadgeChromeStyle.badgeChromeStyle(_:)Badge
CountBadgeStyle.countBadgeStyle(_:)CountBadge, .countBadge(_:) overlays
IconTileStyle.iconTileStyle(_:)IconTile
PriceTagStyle.priceTagStyle(_:)PriceTag
RadioButtonChromeStyle.radioButtonChromeStyle(_:)RadioButton, RadioGroup rows
SkeletonStyle.skeletonStyle(_:)Skeleton, .skeleton(_:), loading states
DividerStyle.dividerStyle(_:)DividerView, component separators
CalloutChromeStyle.calloutChromeStyle(_:)Callout
InlineTextStyle.inlineTextStyle(_:)InlineText, linked helper text
TooltipStyle.tooltipStyle(_:).tooltip(…) bubbles, InputLabel info tooltips
TitleStyle.titleStyle(_:)Title section headers
SegmentedTabBarChromeStyle.segmentedTabBarChromeStyle(_:)one SegmentedTabBar tab (the style owns the indicator)
ButtonDockChromeStyle.buttonDockChromeStyle(_:)the bar of .buttonDock { } (ThemeKit keeps the pinning)
SheetHeaderStyle.sheetHeaderStyle(_:)SheetHeader — the whole layout, outside BarStyle
DialogStyle.dialogStyle(_:)the fixed-layout dialog card of .dialog(isPresented:title:…) and confirm(…) (ThemeKit keeps the scrim and dismissal)
EmptyStateStyle.emptyStateStyle(_:)EmptyState — the whole block: media, title, message and actions
struct HostButtonChrome: ButtonChromeStyle {
func makeBody(configuration: ButtonChromeStyleConfiguration) -> some View {
HostButtonChromeBody(configuration: configuration)
}
}
private struct HostButtonChromeBody: View {
let configuration: ButtonChromeStyleConfiguration
@Environment(\.theme) private var theme
var body: some View {
let fill = theme.resolve(configuration.color)
configuration.label
.textStyle(.labelLg600) // re-fonts the title
.foregroundStyle(fill.onSolid)
.tint(fill.onSolid) // the loading spinner
.padding(.horizontal, Theme.SpacingKey.base.value)
.frame(minHeight: 52)
.frame(maxWidth: configuration.isFullWidth ? .infinity : nil)
.background(configuration.isPressed ? fill.active : fill.solid, in: Capsule())
.opacity(configuration.isEnabled ? 1 : 0.4)
}
}
RootView().buttonChromeStyle(HostButtonChrome()) // every ThemeButton below

The style is read from the environment, so it also reaches the copies ThemeKit composes inside other components (dialog actions, price tags inside cards, list separators…). Resolve colors inside the style from @Environment(\.theme) so theme.custom tokens and per-subtree .theme(_:) both apply. Design rationale: ADR-0009.

DialogStyle draws the whole dialog card — its buttons, its width and its margin from the screen’s edges too (ThemeKit adds none around a custom card; pad by configuration.stockMargin to keep the stock clearance). Call each action’s perform and the configuration’s onClose: they carry ThemeKit’s loading and dismissal. Set it on the .dialog(…) call’s result (or on .feedbackHost()’s, for confirm(…)) or an ancestor. The slotted forms — .dialog(content:footer:), .dialog(header:content:footer:), .dialog(content:) — and AlertDialog don’t consult it.

EmptyStateStyle draws the whole empty state. The media arrives twice — as a mediaKind to switch on (.symbol / .image / .animated) and as a ready-made media view to place as-is — and the message arrives as the raw string, its messageLinks, and a messageContent view that carries the link marking and routes a tapped link to its handler. ThemeKit wraps nothing around a custom body, so set your own .frame(maxWidth: .infinity) if the block should span its container. ResultView, which generalizes the same shape with its own slots, doesn’t consult it.

Presenter and container components expose ViewBuilder slots for injecting custom content without a new initializer parameter:

ListRow("Account")
.leading { Avatar(.initials("AB")).size(.sm) }
.trailing { Badge("3").badgeStyle(.info) }
ListView(items) { ListRow($0.title) }
.empty { EmptyState("No results").icon("magnifyingglass") }
.loadingView { Spinner().style(.dots) }

Geometry that used to be a raw CGFloat now also accepts a theme token, so spacing and radius stay on the token scale even where a raw override remains available for edge cases:

FilterBar(filters, selection: $active)
.spacing(.sm) // Theme.SpacingKey, in addition to the raw CGFloat overload
AnimatedImage(gifURL)
.cornerRadius(.card) // Theme.RadiusRole, in addition to the raw CGFloat overload

And the one color verb across the catalog is accent(_:) — Icon, Avatar, ProgressBar, ScoreBadge, Counter, Breadcrumbs, and more all take a SemanticColor? through the same modifier name, so re-tinting a component never requires learning a per-component color API.