-
Notifications
You must be signed in to change notification settings - Fork 0
Style System
Every DesignFoundation component uses the same four-part pattern to separate visual rendering from component logic: a protocol, a configuration struct, a view modifier, and built-in implementations exposed via static accessors. This page documents that pattern in full and shows you how to write your own styles.
Token reference: Theming Component usage reference: Components-Overview
SwiftUI's own ButtonStyle and LabelStyle protocols inspired this design. The core idea is that a component owns its logic — state management, interaction handling, accessibility — while a style protocol owns its appearance. Swapping styles requires one line at the call site or in a parent view.
// same DFButton, two completely different visual treatments
DFButton("Save") { save() }
.dfButtonStyle(.filled) // opaque filled pill — the default
DFButton("Save") { save() }
.dfButtonStyle(.ghost) // label-only, no backgroundEvery component in DesignFoundation follows this exact pattern. If you understand it for one component you understand it for all of them.
Each component declares a protocol with a single makeBody(configuration:) requirement:
public protocol DFButtonStyle {
associatedtype Body: View
@ViewBuilder func makeBody(configuration: DFButtonStyleConfiguration) -> Body
}The associatedtype Body: View lets implementations return concrete types — no AnyView wrapping required in your implementation. @ViewBuilder lets you write multi-statement view bodies without explicit Group {}.
The configuration struct carries everything makeBody might need: the component's rendered content, its interaction states, and the full active theme. The theme travels directly in the struct — you never need @Environment(\.dfTheme) inside a style.
public struct DFButtonStyleConfiguration {
public let label: AnyView // the button's rendered label
public let isPressed: Bool
public let isDisabled: Bool
public let role: DFButtonRole? // .destructive, .cancel, or nil
public let theme: DFTheme // full active theme, no @Environment needed
}Protocols with associated types cannot be stored directly in EnvironmentValues. DesignFoundation erases each concrete style into an AnyDF*Style wrapper before storing it:
public struct AnyDFButtonStyle: DFButtonStyle, @unchecked Sendable {
private let _makeBody: (DFButtonStyleConfiguration) -> AnyView
public init<S: DFButtonStyle & Sendable>(_ style: S) {
_makeBody = { AnyView(style.makeBody(configuration: $0)) }
}
public func makeBody(configuration: DFButtonStyleConfiguration) -> some View {
_makeBody(configuration)
}
}@unchecked Sendable is safe here because _makeBody is set once at init and never mutated. You never interact with AnyDF*Style directly — the .dfButtonStyle(_:) modifier handles wrapping.
Each component exposes a modifier on View that writes the style into the environment:
public extension View {
func dfButtonStyle<S: DFButtonStyle & Sendable>(_ style: S) -> some View {
environment(\.dfButtonStyle, AnyDFButtonStyle(style))
}
}The & Sendable constraint is mandatory. All concrete style types you write must conform to Sendable. See the Sendable Requirement section.
Built-in styles are accessed through static members constrained to the concrete type, mirroring Swift's own ShapeStyle and ButtonStyle conventions:
extension DFButtonStyle where Self == DFFilledButtonStyle {
static var filled: DFFilledButtonStyle { DFFilledButtonStyle() }
}
extension DFButtonStyle where Self == DFOutlinedButtonStyle {
static var outlined: DFOutlinedButtonStyle { DFOutlinedButtonStyle() }
}This constraint enables the dot-shorthand syntax: .dfButtonStyle(.filled).
Styles cascade through the SwiftUI view hierarchy via the environment. A style set on a parent applies to all descendant components of that type. A style set on a descendant overrides the parent for that subtree only.
Before — inconsistent styles across two sections:
VStack {
HStack {
DFButton("Cancel") { }.dfButtonStyle(.outlined)
DFButton("Back") { }.dfButtonStyle(.outlined)
}
HStack {
DFButton("Skip") { }.dfButtonStyle(.ghost)
DFButton("Later") { }.dfButtonStyle(.ghost)
}
}After — set style on the container, override at one leaf:
VStack {
HStack {
DFButton("Cancel") { }
DFButton("Back") { }
}
.dfButtonStyle(.outlined)
HStack {
DFButton("Skip") { }
DFButton("Later") { }
.dfButtonStyle(.filled) // leaf override — this one button is filled
}
.dfButtonStyle(.ghost)
}The rule: the innermost .dfButtonStyle(_:) call wins.
* = requires iOS 26 / macOS 26 or later.
| Component | Style Protocol | Configuration Type | Built-in Styles |
|---|---|---|---|
DFButton |
DFButtonStyle |
DFButtonStyleConfiguration |
filled (default), outlined, ghost, tinted, glass* |
DFCard |
DFCardStyle |
DFCardStyleConfiguration |
elevated (default), outlined, filled, glass* |
DFTextField |
DFTextFieldStyle |
DFTextFieldStyleConfiguration |
outlined (default), filled, glass* |
DFSecureField |
DFSecureFieldStyle |
DFSecureFieldStyleConfiguration |
outlined (default), filled, glass* |
DFToggle |
DFToggleStyle |
DFToggleStyleConfiguration |
switch (default), checkbox, glass* |
DFSlider |
DFSliderStyle |
DFSliderStyleConfiguration |
standard (default), labeled, glass* |
DFPicker |
DFPickerStyle |
DFPickerStyleConfiguration |
menu (default), segmented, wheel, glass* |
DFDatePicker |
DFDatePickerStyle |
DFDatePickerStyleConfiguration |
compact (default), graphical, wheel, glass* |
DFAvatar |
DFAvatarStyle |
DFAvatarStyleConfiguration |
circle (default), rounded, ring, glass* |
DFBadge |
DFBadgeStyle |
DFBadgeStyleConfiguration |
filled (default), tinted, outlined, glass* |
DFIcon |
DFIconStyle |
DFIconStyleConfiguration |
standard (default), tinted, secondary
|
DFText |
DFTextViewStyle |
DFTextViewStyleConfiguration |
standard (default), secondary, muted
|
DFDivider |
DFDividerStyle |
DFDividerStyleConfiguration |
standard (default), thick, subtle
|
DFCheckbox |
DFCheckboxStyle |
DFCheckboxStyleConfiguration |
default |
DFProgressBar |
DFProgressBarStyle |
DFProgressBarStyleConfiguration |
default (handles .linear, .circular, .indeterminate via variant) |
DFSkeleton |
DFSkeletonStyle |
DFSkeletonStyleConfiguration |
default |
DFSidebar |
DFSidebarStyle |
DFSidebarItemStyleConfiguration |
standard (default), plain, glass* |
DFTabBar |
DFTabBarStyle |
DFTabBarStyleConfiguration |
standard (default), minimal, glass* |
DFNavigationBar |
DFNavigationBarStyle |
DFNavigationBarStyleConfiguration |
standard (default), transparent, glass* |
DFModal |
DFModalStyle |
DFModalStyleConfiguration |
standard (default), glass* |
DFSheet |
DFSheetStyle |
DFSheetStyleConfiguration |
standard (default), compact, glass* |
DFPopover |
DFPopoverStyle |
DFPopoverStyleConfiguration |
arrow (default), compact, glass* |
Note on
DFSidebar:DFSidebarStylecontrols how individual item rows are rendered, not the sidebar's overall chrome. Its protocol declaresmakeItemBody(configuration:)(notmakeBody) and provides an optionalsidebarBackground(theme:) -> AnyViewmethod with a default implementation.
Note on
DFTabBar:DFTabBarStyleis@MainActor-isolated. Custom tab bar styles must be@MainActoror usenonisolated initas appropriate.
The workflow is always the same: conform to the protocol, implement makeBody, add Sendable conformance, and optionally add a static accessor.
import SwiftUI
import DesignFoundation
struct NeonDashedCardStyle: DFCardStyle, Sendable {
let glowColor: Color
func makeBody(configuration: DFCardStyleConfiguration) -> some View {
let theme = configuration.theme
let radius = theme.radius.lg
configuration.content
.padding(theme.spacing.lg)
.background(
RoundedRectangle(cornerRadius: radius)
.fill(theme.colors.surface)
)
.overlay(
RoundedRectangle(cornerRadius: radius)
.stroke(
glowColor.opacity(configuration.isPressed ? 1.0 : 0.6),
style: StrokeStyle(lineWidth: 2, dash: [8, 4])
)
)
.shadow(
color: glowColor.opacity(configuration.isPressed ? 0.5 : 0.2),
radius: configuration.isPressed ? 12 : 6
)
.scaleEffect(configuration.isInteractive && configuration.isPressed ? 0.97 : 1.0)
.animation(theme.animation.fast, value: configuration.isPressed)
.opacity(configuration.isDisabled ? 0.4 : 1.0)
}
}
extension DFCardStyle where Self == NeonDashedCardStyle {
static func neonDashed(color: Color) -> NeonDashedCardStyle {
NeonDashedCardStyle(glowColor: color)
}
}
// Usage
DFCard { Text("Content") }
.dfCardStyle(.neonDashed(color: .cyan))struct GlowingNeonButtonStyle: DFButtonStyle, Sendable {
let neonColor: Color
func makeBody(configuration: DFButtonStyleConfiguration) -> some View {
let theme = configuration.theme
let radius = theme.components.button.cornerRadius ?? theme.radius.md
let hPad = theme.components.button.horizontalPadding ?? theme.spacing.lg
let vPad = theme.components.button.verticalPadding ?? theme.spacing.md
configuration.label
.font((theme.components.button.labelStyle ?? theme.typography.label).font)
.foregroundStyle(configuration.isDisabled ? theme.colors.textDisabled : neonColor)
.padding(.horizontal, hPad)
.padding(.vertical, vPad)
.background(
RoundedRectangle(cornerRadius: radius)
.fill(neonColor.opacity(configuration.isPressed ? 0.2 : 0.08))
.overlay(
RoundedRectangle(cornerRadius: radius)
.stroke(neonColor.opacity(configuration.isDisabled ? 0.2 : 0.9), lineWidth: 1.5)
)
)
.shadow(
color: neonColor.opacity(configuration.isPressed ? 0.7 : 0.3),
radius: configuration.isPressed ? 14 : 6
)
.scaleEffect(configuration.isPressed ? 0.96 : 1.0)
.animation(theme.animation.fast, value: configuration.isPressed)
.opacity(configuration.isDisabled ? 0.5 : 1.0)
}
}
extension DFButtonStyle where Self == GlowingNeonButtonStyle {
static func glowingNeon(color: Color) -> GlowingNeonButtonStyle {
GlowingNeonButtonStyle(neonColor: color)
}
}
DFButton("Launch") { launch() }
.dfButtonStyle(.glowingNeon(color: .green))struct UnderlineTextFieldStyle: DFTextFieldStyle, Sendable {
func makeBody(configuration: DFTextFieldStyleConfiguration) -> some View {
let theme = configuration.theme
let lineColor: Color = {
if configuration.isDisabled { return theme.colors.border }
switch configuration.validationState {
case .error: return theme.colors.destructive
case .valid: return theme.colors.success
case .none: return configuration.isFocused ? theme.colors.primary : theme.colors.border
}
}()
VStack(alignment: .leading, spacing: theme.spacing.xs) {
if !configuration.label.isEmpty {
Text(configuration.label)
.font(theme.typography.caption.font)
.foregroundStyle(theme.colors.textSecondary)
}
HStack(spacing: theme.spacing.sm) {
if let leading = configuration.leadingContent { leading.foregroundStyle(theme.colors.textSecondary) }
configuration.fieldContent
.font(theme.typography.body.font)
.foregroundStyle(configuration.isDisabled ? theme.colors.textDisabled : theme.colors.textPrimary)
if let trailing = configuration.trailingContent { trailing.foregroundStyle(theme.colors.textSecondary) }
}
.padding(.vertical, theme.spacing.xs)
.overlay(alignment: .bottom) {
Rectangle()
.fill(lineColor)
.frame(height: configuration.isFocused ? 2 : 1)
}
if case .error(let message) = configuration.validationState {
Text(message)
.font(theme.typography.caption.font)
.foregroundStyle(theme.colors.destructive)
}
}
.opacity(configuration.isDisabled ? 0.5 : 1.0)
.animation(theme.animation.fast, value: configuration.isFocused)
}
}
extension DFTextFieldStyle where Self == UnderlineTextFieldStyle {
static var underline: UnderlineTextFieldStyle { UnderlineTextFieldStyle() }
}
DFTextField("Username", text: $username)
.dfTextFieldStyle(.underline)DFToastStyleConfiguration is fully Sendable — it holds DFToastMessage and DFTheme, both value types.
struct BannerToastStyle: DFToastStyle, Sendable {
func makeBody(configuration: DFToastStyleConfiguration) -> some View {
let theme = configuration.theme
let message = configuration.message
HStack(spacing: theme.spacing.sm) {
if let icon = message.icon {
Image(systemName: icon)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(severityColor(message.severity, theme: theme))
}
Text(message.text)
.font(theme.typography.body.font)
.foregroundStyle(theme.colors.textPrimary)
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(.horizontal, theme.spacing.lg)
.padding(.vertical, theme.spacing.md)
.background(
Rectangle()
.fill(theme.colors.surface)
.overlay(alignment: .bottom) {
Rectangle()
.fill(severityColor(message.severity, theme: theme))
.frame(height: 3)
}
)
.shadow(
color: theme.shadows.sm.color,
radius: theme.shadows.sm.radius,
x: theme.shadows.sm.x,
y: theme.shadows.sm.y
)
}
private func severityColor(_ severity: DFToastSeverity, theme: DFTheme) -> Color {
switch severity {
case .info: return theme.colors.info
case .success: return theme.colors.success
case .warning: return theme.colors.warning
case .error: return theme.colors.destructive
}
}
}
extension DFToastStyle where Self == BannerToastStyle {
static var banner: BannerToastStyle { BannerToastStyle() }
}
// Usage — apply to the root view alongside .dfToast(queue:)
ContentView()
.dfToast(queue: DFToastQueue.shared)
.dfToastStyle(.banner)| Config | Property | Type | Description |
|---|---|---|---|
DFButtonStyleConfiguration |
label |
AnyView |
The button's rendered label view |
DFCardStyleConfiguration |
content |
AnyView |
The card's child content |
DFTextFieldStyleConfiguration |
fieldContent |
AnyView |
The pre-configured TextField, ready to lay out |
DFTextFieldStyleConfiguration |
leadingContent |
AnyView? |
Optional leading icon/view |
DFTextFieldStyleConfiguration |
trailingContent |
AnyView? |
Optional trailing icon/view |
DFTextFieldStyleConfiguration |
label |
String |
The field's label string (empty if none) |
DFTextFieldStyleConfiguration |
placeholder |
String |
The field's placeholder string |
DFSecureFieldStyleConfiguration |
onToggleReveal |
() -> Void |
Call this to toggle password visibility |
DFToastStyleConfiguration |
message |
DFToastMessage |
Full message object: .text, .icon, .severity, .duration
|
DFSidebarItemStyleConfiguration |
item |
DFSidebarItem |
The sidebar item: .id, .icon, .label
|
DFTabBarStyleConfiguration |
items |
[DFTabItem] |
All tab items |
DFTabBarStyleConfiguration |
selectedID |
String |
The currently selected tab's ID |
DFTabBarStyleConfiguration |
onSelect |
@MainActor (String) -> Void |
Call with an item ID to change selection |
| Property | Meaning |
|---|---|
isPressed |
The user is actively pressing (button, card) |
isDisabled |
The component has been disabled |
isFocused |
The field currently holds keyboard focus |
isInteractive |
The card has an action attached |
isChecked |
The checkbox is checked |
isOn |
Toggle's current value |
isSelected |
The sidebar item is the active selection |
isRevealed |
The secure field is showing plain text |
Every configuration carries a theme: DFTheme property. This is the full active theme as of the moment the component rendered:
func makeBody(configuration: DFButtonStyleConfiguration) -> some View {
let theme = configuration.theme
configuration.label
.foregroundStyle(theme.colors.primary)
.font(theme.typography.label.font)
.padding(theme.spacing.md)
.background(
RoundedRectangle(cornerRadius: theme.radius.md)
.shadow(
color: theme.shadows.sm.color,
radius: theme.shadows.sm.radius,
x: theme.shadows.sm.x,
y: theme.shadows.sm.y
)
)
.animation(theme.animation.fast, value: configuration.isPressed)
}Why theme is in the configuration, not @Environment: Styles are instantiated ahead of time and stored in the environment as type-erased closures. The component reads @Environment(\.dfTheme) and injects it into the configuration before calling makeBody. Your custom style always gets the correct active theme without needing @Environment inside the style struct.
extension DFButtonStyle where Self == DFGhostButtonStyle {
static var ghost: DFGhostButtonStyle { DFGhostButtonStyle() }
}
.dfButtonStyle(.ghost)extension DFButtonStyle where Self == GlowingNeonButtonStyle {
static func glowingNeon(color: Color) -> GlowingNeonButtonStyle {
GlowingNeonButtonStyle(neonColor: color)
}
}
.dfButtonStyle(.glowingNeon(color: .purple))You can also pass the concrete struct instance directly without a static accessor:
.dfButtonStyle(GlowingNeonButtonStyle(neonColor: .purple))The .dfButtonStyle<S: DFButtonStyle & Sendable>(_ style: S) modifier requires S: Sendable. This is not optional — the modifier will not compile without it.
Why: Style instances are captured into @Sendable closures inside AnyDF*Style and can cross Swift concurrency actor boundaries. A non-Sendable style would break Swift 6's data isolation guarantees.
How to conform: A struct whose stored properties are all Sendable conforms automatically:
struct MyCardStyle: DFCardStyle, Sendable {
let cornerRadius: CGFloat // CGFloat is Sendable
let strokeColor: Color // Color is Sendable
func makeBody(configuration: DFCardStyleConfiguration) -> some View { ... }
}What breaks conformance:
// BROKEN — UIImage is not Sendable
struct BadStyle: DFCardStyle, Sendable {
let backgroundImage: UIImage // compile error
}
// FIXED — use the image name; resolve at render time
struct FixedStyle: DFCardStyle, Sendable {
let imageName: String // String is Sendable
func makeBody(configuration: DFCardStyleConfiguration) -> some View {
Image(imageName) // resolved inside makeBody on the main thread
}
}Never use @unchecked Sendable on your own styles unless you are certain the stored state is never mutated after init.
This section is for contributors modifying DesignFoundation internals.
Each style type is stored in EnvironmentValues via a private EnvironmentKey:
private struct DFButtonStyleKey: EnvironmentKey {
static let defaultValue: AnyDFButtonStyle = AnyDFButtonStyle(DFFilledButtonStyle())
}
public extension EnvironmentValues {
var dfButtonStyle: AnyDFButtonStyle {
get { self[DFButtonStyleKey.self] }
set { self[DFButtonStyleKey.self] = newValue }
}
}defaultValue is the style that applies when no .dfButtonStyle(_:) modifier appears anywhere in the ancestor hierarchy. Changing defaultValue changes what every un-styled component in the app looks like.
To add a new styleable component to DesignFoundation, the checklist is:
- Define the configuration struct
- Define the protocol
- Implement
AnyDF*Styletype erasure with@unchecked Sendable - Define a private
EnvironmentKeywith the default built-in style asdefaultValue - Add the
EnvironmentValuescomputed property - Add the
Viewextension modifier - Write at least one built-in style with a
Sendableconformance - Add static accessors for each built-in
You can wrap a built-in style inside a custom one to extend rather than replace its rendering:
// Wrap DFOutlinedCardStyle and add a colored top border accent
struct AccentedCardStyle: DFCardStyle, Sendable {
let accentColor: Color
private let base = DFOutlinedCardStyle()
func makeBody(configuration: DFCardStyleConfiguration) -> some View {
let theme = configuration.theme
base.makeBody(configuration: configuration)
.overlay(alignment: .top) {
Rectangle()
.fill(accentColor)
.frame(height: 3)
.clipShape(
.rect(
topLeadingRadius: theme.radius.lg,
topTrailingRadius: theme.radius.lg
)
)
}
}
}
extension DFCardStyle where Self == AccentedCardStyle {
static func accented(_ color: Color) -> AccentedCardStyle {
AccentedCardStyle(accentColor: color)
}
}
DFCard { content }
.dfCardStyle(.accented(.orange))Tip: If you only need to adjust opacity, shadow, or transforms on top of a built-in, prefer a plain view modifier on the
DFCard/DFButtoncall site rather than a custom style. Reserve custom styles for cases where the core shape or layout needs to change.