Every app acquires a design system. The only question is whether it is written down or scattered across ninety files as slightly different greys. This lesson builds one in three tiers, and makes the case for the middle tier — which is the one people skip and the one that makes a retune possible.
The three tiers
Tokens are raw values: this red, this spacing step, this radius. They have no opinion about where they are used.
The semantic layer names roles: "muted text", "the surface a card sits on", "the colour of a destructive action". Each role points at a token.
Components consume roles, never raw tokens.
Skip the middle tier and every component references grey600 directly. Changing what
muted text looks like then means a find-and-replace that also hits the borders which merely happened
to share the value. The indirection is the entire point: it lets you change one meaning without
changing the others.
Tokens
enum Palette {
static let red = Color(hex: 0xD8102A)
/// `color.adjust($pizza-red, $lightness: -10%)` in Sass, precomputed — as in the RN app.
static let redDark = Color(hex: 0xAB0D21)
static let redSoft = Color(hex: 0xFDEAEC)
static let black = Color(hex: 0x231F20)
static let cream = Color(hex: 0xFFF8F0)
static let white = Color(hex: 0xFFFFFF)
A caseless enum rather than a struct, and that is a small idiom worth
knowing: an enum with no cases cannot be instantiated, which is exactly right for a namespace. A
struct would silently allow Palette().
SwiftUI ships no hex initialiser, and the usual workaround parses a String at
runtime. Taking an integer literal instead means a typo is a compile error rather than a colour that
falls back to black:
extension Color {
init(hex: UInt32) {
self.init(
.sRGB,
red: Double((hex >> 16) & 0xFF) / 255,
green: Double((hex >> 8) & 0xFF) / 255,
blue: Double(hex & 0xFF) / 255,
opacity: 1
)
}
}
Spacing and radius
enum Spacing {
static let xs: CGFloat = 4
static let sm: CGFloat = 8
static let md: CGFloat = 12
static let lg: CGFloat = 16
static let xl: CGFloat = 24
static let xxl: CGFloat = 32
static let xxxl: CGFloat = 48
}
A 4-point scale. The value is not the numbers — it is that naming the steps stops
padding(13) appearing next to padding(12) and quietly breaking the
rhythm. Once a scale exists, an off-scale value becomes visible in review as
.padding(13) standing out among named constants.
Shadows, and the one place SwiftUI is simplest
enum Elevation {
struct Shadow {
let color: Color
let radius: CGFloat
let x: CGFloat
let y: CGFloat
}
static let card = Shadow(color: Palette.black.opacity(0.08), radius: 4, x: 0, y: 2)
Worth a comparison. React Native has to set shadowColor/shadowOffset/
shadowOpacity/shadowRadius for iOS and elevation for
Android, and forgetting the second gives you cards that are flat on half your devices. The web sets a
box-shadow string. Here there is one .shadow(color:radius:x:y:) and no
platform split at all.
One conversion trap: SwiftUI's radius is a Gaussian blur radius while CSS and React
Native's is roughly a diameter. Porting a design directly gives you shadows about twice as soft as
intended — the token above halves the web's value deliberately.
The semantic layer
enum Theme {
enum colors {
/// Brand
static let primary = Palette.red
static let primaryDark = Palette.redDark
static let primarySoft = Palette.redSoft
static let onPrimary = Palette.white
/// Surfaces
static let background = Palette.cream
static let surface = Palette.white
static let surfaceAlt = Palette.grey100
Views reach for Theme.colors.textMuted, never Palette.grey600. Note
the onPrimary naming convention — it means "the colour of content placed
on the primary colour", which keeps a foreground and its background paired rather than
independently chosen.
Typography as a modifier
The instinct is a PizzaText wrapper view. SwiftUI makes the modifier better,
because .font() and .foregroundStyle() already inherit down the view tree
— so a modifier composes with a Label, a TextField or a whole stack, where
a wrapper only works with text it renders itself.
enum TextStyle {
case display
case title
case heading
case subheading
case body
case bodyStrong
/// Small, uppercase section headers — the web app's `.text-uppercase.text-muted.h6`.
case label
case caption
case mono
var font: Font {
switch self {
case .display: .system(size: FontSize.display, weight: .heavy)
case .title: .system(size: FontSize.xxl, weight: .heavy)
case .heading: .system(size: FontSize.lg, weight: .bold)
Applied through a ViewModifier:
func body(content: Content) -> some View {
content
.font(style.font)
.tracking(style.tracking)
.foregroundStyle(tone.color)
.textCase(style.isUppercased ? .uppercase : nil)
}
.textCase rather than uppercasing the string is an accessibility decision: it
changes the rendering and leaves the underlying value alone, so VoiceOver reads the real text rather
than spelling out what it takes for an acronym.
The extension that hides all of it:
extension View {
func textStyle(_ style: TextStyle, tone: TextTone = .standard) -> some View {
modifier(TextStyleModifier(style: style, tone: tone))
}
}
Buttons: restyle, do not rebuild
A custom PizzaButton view would have to re-implement everything
Button already does: the tap gesture, press-and-drag-away cancellation, the
accessibility trait, Dynamic Type, keyboard activation and the .disabled() environment.
A ButtonStyle restyles the real thing and inherits all of it.
func makeBody(configuration: Configuration) -> some View {
configuration.label
.textStyle(size.textStyle)
.foregroundStyle(variant.foreground)
.padding(.vertical, size.verticalPadding)
.padding(.horizontal, size.horizontalPadding)
.frame(maxWidth: isFullWidth ? .infinity : nil)
.frame(minHeight: 44)
.background(
RoundedRectangle(cornerRadius: Radius.md)
.fill(configuration.isPressed ? variant.pressedBackground : variant.background)
)
configuration.isPressed is the native answer to CSS :active, and it
arrives for free. minHeight: 44 is Apple's touch-target guidance, applied to the
tappable frame so a small button stays pressable without padding the design out of shape.
One detail that is easy to get wrong:
@Environment(\.isEnabled) private var isEnabled
Read from the environment rather than passed in, so .disabled(true) on the button —
or on any ancestor — is what dims it. A separate isDisabled property would let the two
disagree, giving you a button that looks enabled and does nothing.
Registering it as a static keeps call sites short — .buttonStyle(.pizzaPrimary)
rather than naming the type, which is how SwiftUI's own styles read.
The loading state belongs in the component
A button that shows a spinner but still accepts taps is how a customer ends up with two orders. Tying the two together means no call site can express one without the other:
struct AsyncButton<Label: View>: View {
let title: String
var isLoading = false
var isDisabled = false
let action: () -> Void
@ViewBuilder var label: () -> Label
Inside, the label stays in the layout while hidden and the spinner is drawn over it, so the button does not resize as work starts — a button that jumps is a button people miss. This is the general pattern: when two properties must always change together, the component is the place to enforce it, not a convention in a code review checklist.
Status colours, and exhaustiveness
Badges are where a design system quietly rots, because a new status arrives and nobody updates the
colour map. A switch prevents it:
private var tone: StatusBadge.Tone {
switch status {
case .pendingPayment: .warning
case .paid: .primary
case .preparing: .info
case .completed: .success
case .cancelled: .neutral
}
}
Add a case to OrderStatus and this stops compiling until it is handled. A dictionary
lookup with a ?? .neutral fallback would compile happily and render every new status
grey — which is the kind of bug that ships, because grey is not obviously wrong.
When SwiftUI ships nothing
A row of topping chips has to wrap onto a second line. HStack never wraps, and
LazyVGrid needs columns of known width, which is wrong for chips sized by their text.
This is the one place in the app where SwiftUI asks for more code than
flex-wrap: wrap.
The Layout protocol lets you take part in the layout pass directly, with two
methods:
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache _: inout ()) -> CGSize {
let maxWidth = proposal.replacingUnspecifiedDimensions().width
let rows = layout(subviews: subviews, maxWidth: maxWidth)
let height = rows.reduce(0) { $0 + $1.height } + lineSpacing * CGFloat(max(rows.count - 1, 0))
let width = rows.map(\.width).max() ?? 0
return CGSize(width: max(width, 0), height: max(height, 0))
}
replacingUnspecifiedDimensions() is the line that matters. A
ProposedViewSize's dimensions are optional: nil means "I
am not proposing anything, tell me what you want", which is what a ScrollView does along
its scroll axis. Treating nil as zero is the classic mistake and it collapses the layout
to nothing.
Both methods share one line-breaking function, so measurement and placement cannot disagree — computing the rows twice with slightly different code is how a layout reports one height and draws another.
What about dark mode?
This app is pinned to light, matching its three web siblings, and the README says so rather than leaving it as an apparent oversight. The reason is worth stating because it follows from the structure above: the semantic layer declares one value per role, and supporting both schemes means declaring two and resolving between them.
Doing that properly is not hard — an asset-catalogue colour carries both appearances and SwiftUI
picks the right one automatically, which is why colours defined in the catalogue rather than in code
are the usual recommendation for an app that needs it. The mistake is reading
@Environment(\.colorScheme) in a view and branching on it, which spreads the decision
across every component instead of keeping it in the one file that owns colour.
What this buys
Three things, and the third is the one that justifies the effort. A visual change happens in one file. A new component starts from roles that already exist rather than from a colour picker. And a review can tell the difference between a deliberate exception and a mistake, because an off-token value stands out among named ones.
Dynamic Type, briefly
One caveat about everything above. .system(size:) pins a point size, which means it
does not grow when a customer increases text size in Settings — and a meaningful number of people
run their phone at larger sizes.
The alternative is .system(.body) and friends, which scale, or
@ScaledMetric for a custom size that scales with them. The demo app takes the fixed
route deliberately, for consistency with the three web frontends it is compared against, and that is
a trade-off rather than a recommendation. If you are building something real, start from the
scaling styles — retrofitting them later means revisiting every fixed frame that was sized around a
fixed font.
Next
That is the presentation layer complete. The next lesson goes to the other end of the app: talking to a server, with one type that performs requests and everything else describing them.