Mobile System Design

By the author of the Mobile System Design and Swift in Depth books
Tjeerd in 't Veen

Written by

Tjeerd in 't Veen

X

Mastodon

LinkedIn

Youtube

Instagram

Newsletter

Reader

Structuring Spacing for Scalable Mobile UIs

Apr 06, 2025, reading time: 7 minutes

In growing teams and evolving codebases, maintaining visual consistency is a challenge, especially in mobile apps where layouts must adapt to different devices, screen sizes, and use cases. One of the easiest places for inconsistencies to creep in is spacing.

From the gap between a button and its label to the padding around a card, spacing choices are everywhere. When made inconsistently, whether due to quick decisions, design handoff gaps, or unclear standards, they compound into UI that feels messy and harder to maintain.

This post explores how you can scale your UI more effectively by defining a structured spacing system. By aligning on a shared language of spacing primitives and semantic values, teams can reduce decision fatigue, streamline collaboration, and build a foundation that scales across platforms and contributors. It’s a small but powerful step toward a robust UI library, or even a design system!

Structured spacing is especially valuable in the context of mobile system design, where components need to scale across devices, screen sizes, and teams. A well-defined spacing system contributes to a more predictable and maintainable architecture. This enables us to grow and evolve without manually tweaking values across all screens.

Let’s explore how.

The Problem with Hardcoded Numbers

Many developers initially find it quick and easy to use arbitrary spacing values when creating layouts. They receive a design, measure the values, and enter them in their views.

Let’s illustrate this with a common SwiftUI example. We vertically lay out two texts, and use hard-coded numbers to set the spacing and padding.

// 12 and 20 are hardcoded numbers
VStack(spacing: 12) {
    Text("Card Title")
    Text("Card Description")
}.padding(20)

In this snippet, a developer picks spacing values (12, 20) by measuring a design. Over time, such random values create confusion; Why 12, why not 14 or 16? These inconsistencies result in UI that feel subtly off-balance and unpolished.

But while it seems harmless at first, this practice quickly leads to inconsistency, visual clutter, and increased maintenance effort.

Regarding spacing. If you are on Mac, as a trick, I like to use CMD + Shift + 4 to bring up the screenshot tool, draw a rectangle, measure, move it around with SPACE, and then press ESCAPE to cancel the screenshot-making.

Introducing Spacing Primitives

A much better approach is to establish spacing primitives. These are a defined set of spacing values that ensure consistency and ease of maintenance. Typical spacing scales follow values like 4, 8, 16, 24, etc., aligning nicely with grid systems and common UI frameworks.

Here’s one way you can define spacing primitives in Swift:

struct Space {
  static let s4: CGFloat = 4
  static let s8: CGFloat = 8
  static let s16: CGFloat = 16
  static let s24: CGFloat = 24
  static let s32: CGFloat = 32
  static let s40: CGFloat = 40
  static let s48: CGFloat = 48
  static let s64: CGFloat = 64
  static let s80: CGFloat = 80
}

Why stop at 80? Larger gaps are often handled structurally, such as centering elements or wrapping them in containers that naturally create space. This reduces the need for precise spacing values at larger scales, as the layout itself provides the separation.

There are numerous ways to define spacing primitives, but most approaches ultimately involve a consistent list of allowed values. These might be represented as properties in a struct or as constants in a shared configuration file. The key is to define these values once, in a central place, so they can be reused consistently throughout the app.

By using these predefined values, developers no longer need to guess or measure spacing repeatedly. Here’s how you might use these values in practice:

// Now we refer to the space constants
VStack(spacing: Space.s16) {
    Text("Card Title")
    Text("Card Description")
}.padding(Space.s24)

This usage ensures that the spacing aligns with your defined system and stays consistent across your app.

It might seem like we’ve just replaced the number 16 with a named constant like s16, but the real benefit is deeper: By restricting spacing to a set of allowed values, you remove ambiguity.

For example, if you ever measure a spacing like 14 in a design (and yes, even designers make mistakes, too!) you know it’s not part of the scale. Then you pick the nearest allowed value, such as 16. This helps enforce consistency across views and contributors, and makes deviations easier to spot and correct.

It might seem like a small problem at first. But across years of development and dozens of contributors, small inconsistencies like slightly different spacing choices, compound into real visual and technical debt. By establishing clear, shared spacing values, you eliminate a common source of drift and create spacing in UI that follows “pre-approved” values.

Adding Clarity with Semantic Spacing

By working with a predefined list of values, you already are creating more consistent UI! You can stop here if you want. But, to further enhance our solution, let’s take it some steps further.

The spacing primitives we defined eliminate guesswork. But, they still lack contextual meaning.

We can add semantic clarity by mapping these primitive values to meaningful categories like small (sm), medium (md), or large (lg). We make sure these semantic values use the primitive values we defined earlier, such as Space.s8.

Here’s an example of semantic spacing definitions. We introduce a new Spacing struct that maps the Space values to semantic values.

struct Spacing {
  static let xxs = Space.s4
  static let xs  = Space.s8
  static let sm  = Space.s16
  static let md  = Space.s24
  static let lg  = Space.s32
  static let xl  = Space.s40
  static let xxl = Space.s48
  static let x3l = Space.s64
  static let x4l = Space.s80
}

Applying semantic spacing in your UI is straightforward and clarifies your intent within the code:

// Now we don't use constants anymore.
// We now refer to small (sm)...
VStack(spacing: Spacing.sm) {
    Text("Card Title")
    Text("Card Description")
}.padding(Spacing.md) // ...  or medium (md)

Introducing two entire structs just to add a value such as 16 or 8 might seem like a very roundabout way to set spacing. But, there are benefits, believe it or not.

Now, instead of worrying about whether the values are exactly 16 or 24, you focus on what they mean. When you use semantic names like Spacing.md instead of hardcoded numbers. Developers and designers can immediately understand the intent behind a spacing decision, rather than wondering why 12 or 20 was used.

Second, thanks to semantic spacing, maintainability gets much easier. Once your spacing values are centralized, making global changes is fast and reliable. If the UI calls for more breathing room, you don’t need to hunt down every instance of 12 or 20. You update the definition of Spacing.md or Spacing.lg, and the change applies consistently throughout the app! That’s a major benefit of using semantic spacing.

Of course, it’s wise to verify views after updating central UI logic, but it’s a quick way to tweak UI in one location, as opposed to hunting down various hardcoded numbers everywhere.

Introducing Dynamic Spacing

Structured spacing provides a solid foundation, but mobile apps often need layouts to adjust based on the device. For example, tighter spacing might work well on a phone, while a tablet may need more breathing room.

To support this, you can make spacing values adaptive.

For instance, in SwiftUI, you can access the current size class (such as compact for phones or regular for tablets) and adjust spacing accordingly:

struct AdaptivePaddingView: View {
    // We read the environment to figure out what size class we have.
    // Such as `compact`.
    @Environment(\.horizontalSizeClass) var sizeClass

    // We change the padding depending on the size class
    var adaptivePadding: CGFloat {
        sizeClass == .compact ? Spacing.sm : Spacing.md
    }

    var body: some View {
        VStack {
            Text("Adaptive Spacing")
              // Depending on the available space, the padding changes
              .padding(adaptivePadding)
        }
    }
}

Doing this across all views works, but quickly becomes tedious. You could instead encode this logic directly into your spacing definitions, making them dynamic-aware.

For example, we could redefine our Spacing struct and ensure it has a (default) value as well as an offset for larger screens.

We’ll also add a dynamic method to get either the default value or larger one:

struct Spacing {
    //  We now store a value and a potential offset
    let value: CGFloat
    let offset: CGFloat

    // Instance method that returns the dynamic spacing.
    func dynamic(for sizeClass: UserInterfaceSizeClass?) -> CGFloat {
        return sizeClass == .regular ? value + offset : value
    }

    // We update the defaults a regular value and a potential offset
    static let xxs = Spacing(value: Space.s4,  offset: Space.s4)
    static let xs  = Spacing(value: Space.s8,  offset: Space.s8)
    static let sm  = Spacing(value: Space.s16, offset: Space.s8)
    static let md  = Spacing(value: Space.s24, offset: Space.s8)
    static let lg  = Spacing(value: Space.s32, offset: Space.s8)
    static let xl  = Spacing(value: Space.s40, offset: Space.s8)
    static let xxl = Spacing(value: Space.s48, offset: Space.s16)
    static let x3l = Spacing(value: Space.s64, offset: Space.s16)
    static let x4l = Spacing(value: Space.s80, offset: Space.s16)
}

Then we can access both static as well as dynamic values.

let staticPadding = Spacing.md.value // Returns Space.s24

@Environment(\.horizontalSizeClass) var sizeClass
 // Returns either Space.s24 or Space.s24 + Space.s8 (32 total)
let dynamicPadding = Spacing.md.dynamic(for: sizeClass)

This approach keeps your spacing consistent while allowing it to scale naturally with the UI. It also reduces the need to create separate layout logic for every device class.

From structured spacing to a design system

By treating spacing as a shared, meaningful contract instead of a set of scattered numbers, you build the foundation for UI that’s more consistent, maintainable, and scalable across teams and platforms.

This is only the start; We can take this one step further and make it part of a design system that even works cross-platform.

For example, in a design system, there could be “medium spacing”, or Spacing.md used both by designers as well as developers. This Spacing.md, might resolve to 16pt on iOS and 12dp on Android. These small differences are tailored to platform norms, but the semantic label, such as “medium spacing”, maintains a shared meaning. Everyone is aligned on why a certain amount of space is being used, even if the implementation differs slightly.

Using the same terminology and centralized values allow you to scale up. Designers can work confidently with consistent spacing, developers don’t have to interpret raw measurements, and platform-specific tweaks can happen in one place. When spacing decisions are semantic, updating your system becomes far simpler and safer.

To learn more about transitioning UI elements into a full-fledged a design system for mobile apps, check out the Mobile System Design book.

The Mobile System Design book

If you want to boost your career and become an even better mobile engineer, then check out the Mobile System Design book.

I am the author of this book, but you may also know me from the highly-rated Swift in Depth book.

Even if you're a senior or staff-level engineer, I am confident you'll get a lot out of it.

It covers topics in depth such as:

  • Passing system design interviews
  • Large app architectures
  • How to avoid over-engineering
  • Dependency injection without fancy frameworks
  • Saving time by delivering features faster
  • Integrating a Design System into your apps

... and much more!

Get the Mobile System Design book

Written by

Tjeerd in 't Veen has a background in product development inside startups, agencies, and enterprises. His roles included being a staff engineer at Twitter (before X) and iOS Tech Lead at ING Bank.

He is the author of the Mobile System Design and Swift in Depth books.

Stay up to date and subscribe to the newsletter to get the latest updates in your mailbox.