iOS – Building a Design System

August 10, 20268 min readUpdated 9/18/2026

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.