SwiftUI Design Consistency: A Practical Guide for Real Apps

Keep SwiftUI design consistent by defining spacing, radius, color, and type tokens in one shared file, then building every screen from those tokens instead of raw values. Put colors in the asset catalog for automatic light and dark variants. If a value isn't a token, it doesn't ship.
Part of the complete web design guide.
On this page
- Start with tokens, not opinions
- Build components, then forbid the raw version
- Use ButtonStyle for real reuse
- Typography is where consistency quietly dies
- Previews are your consistency test
- The habits that actually prevent drift
- FAQ
- Do I need a full design system to get consistency?
- Should I use a third-party design system library?
- How do I get a team to actually follow it?
SwiftUI makes it easy to build a screen. It makes it surprisingly hard to build forty screens that look like they came from the same app. The framework hands you defaults that are almost right, and “almost right” is exactly how design drift starts. This is the process I use to keep consistency from becoming a full-time cleanup job.
Most inconsistency isn’t a discipline problem. It’s a missing system. If every developer picks their own spacing, corner radius, and font, you’ll get four versions of “primary button” and nobody will notice until a designer opens the app on a real device.
Start with tokens, not opinions
Before you build a single reusable view, define the raw values in one place. Spacing, radii, colors, type. Not a giant design system doc. Just a Swift file everyone imports.
enum Spacing {
static let xs: CGFloat = 4
static let sm: CGFloat = 8
static let md: CGFloat = 16
static let lg: CGFloat = 24
static let xl: CGFloat = 32
}
enum Radius {
static let control: CGFloat = 10
static let card: CGFloat = 16
}
enum Palette {
static let brand = Color("BrandPrimary") // Asset catalog color
static let surface = Color("Surface")
static let textPrimary = Color("TextPrimary")
static let textSecondary = Color("TextSecondary")
}
Two things matter here. Colors live in the asset catalog so they carry light and dark variants automatically. And you never type a raw .padding(13) in a view again. If it isn’t in the token file, it doesn’t exist.
Since iOS 17, SwiftUI has had @Entry for defining environment values with less boilerplate, which makes a theme object practical:
extension EnvironmentValues {
@Entry var theme: Theme = .default
}
struct Theme {
var spacing: Spacing.Type = Spacing.self
var radius: Radius.Type = Radius.self
static let `default` = Theme()
}
You may not need the environment route on a small app. Use it when you have more than one visual context, like a consumer app with a separate internal tools mode.
Build components, then forbid the raw version
The single highest-use move is wrapping the controls you use most. A primary button, a card container, a section header. Then treat the raw Button the way you’d treat a force unwrap: allowed, but you’d better have a reason.
struct PrimaryButton: View {
let title: String
let action: () -> Void
var body: some View {
Button(action: action) {
Text(title)
.font(.headline)
.frame(maxWidth: .infinity)
.padding(.vertical, Spacing.md)
}
.buttonStyle(.borderedProminent)
.tint(Palette.brand)
.clipShape(RoundedRectangle(cornerRadius: Radius.control))
}
}
Now every primary action in the app is identical, and a change to the corner radius happens in one file. If a designer asks for a slightly taller button, you edit one line instead of hunting through twenty views.
Use ButtonStyle for real reuse
Wrapping the whole button is fine for simple cases. When you need variants (primary, secondary, destructive), a ButtonStyle is cleaner because it composes with any label content:
struct AppButtonStyle: ButtonStyle {
enum Variant { case primary, secondary, destructive }
let variant: Variant
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.headline)
.padding(.vertical, Spacing.md)
.padding(.horizontal, Spacing.lg)
.background(background)
.foregroundStyle(foreground)
.clipShape(RoundedRectangle(cornerRadius: Radius.control))
.opacity(configuration.isPressed ? 0.7 : 1)
}
private var background: Color {
switch variant {
case .primary: Palette.brand
case .secondary: Palette.surface
case .destructive: .red
}
}
private var foreground: Color {
variant == .secondary ? Palette.textPrimary : .white
}
}
Usage stays readable: .buttonStyle(AppButtonStyle(variant: .primary)). And because it’s a style, it works on any button regardless of what’s inside the label.
Typography is where consistency quietly dies
Dynamic Type is not optional in 2026. Accessibility reviewers check it, and users actually use it. The trap is mixing system text styles with hardcoded sizes. Pick one approach and stick to it.
extension Font {
static let screenTitle = Font.largeTitle.weight(.bold)
static let sectionHeader = Font.title3.weight(.semibold)
static let bodyText = Font.body
static let caption = Font.footnote
}
Every one of those scales with the user’s text size setting because they’re built on system styles. A hardcoded .system(size: 17) does not, and it’s the most common source of “why does this screen break at large text sizes” bugs I see.
Previews are your consistency test
This is the part people skip. A component isn’t done until it renders correctly in every state and appearance you support. Build that into the preview instead of checking it manually.
#Preview("Primary button states") {
VStack(spacing: Spacing.md) {
PrimaryButton(title: "Continue") {}
PrimaryButton(title: "Continue") {}
.environment(.dynamicTypeSize, .accessibility3)
}
.padding()
}
#Preview("Dark mode") {
PrimaryButton(title: "Continue") {}
.preferredColorScheme(.dark)
}
When a component lives in its own preview with all variants side by side, drift is visible in about two seconds. That’s the whole point. You want the failure to show up in Xcode, not in a screenshot from a user.
The habits that actually prevent drift
- One token file, reviewed like code. If someone adds a color, it goes through a pull request like anything else.
- No raw spacing in feature views. If a screen needs a value that doesn’t exist, add it to the tokens deliberately.
- Screenshot tests for key screens. Record reference images at the two or three most common device sizes and fail the build on unexpected diffs.
- A short component inventory. Keep a running list of what exists so people stop rebuilding the same card for the fifth time.
- Review on device, not just canvas. Safe areas, notch cutouts, and real font rendering don’t show up the same way in the preview pane.
None of this is glamorous. It’s the same discipline that keeps a codebase readable, applied to visuals. The payoff is that adding a screen takes hours instead of days, and your app stops looking like it was assembled by five different teams.
If you’re working with a team spread across time zones, or you’re a local business trying to get a mobile presence off the ground, the same principle applies: define the system once, then build on it. That’s true whether you’re shipping an iOS app or a marketing site, and it’s a big part of how I approach Long Island web design work when a client needs both.
FAQ
Do I need a full design system to get consistency?
No. A token file and three or four shared components will fix most of the visible drift. Build the system up as you hit real duplication, not before.
Should I use a third-party design system library?
Only if it matches your visual language closely. Otherwise you spend more time overriding its defaults than you would have spent writing your own components.
How do I get a team to actually follow it?
Make the right thing the easy thing. If the shared component is faster to reach for than the raw one, people use it. Enforcement through review alone rarely sticks.


