DEV Community

Cover image for Stop Rebuilding the Same SwiftUI Components: A Guide to DesignFoundation
Nerd Snipe
Nerd Snipe

Posted on

Stop Rebuilding the Same SwiftUI Components: A Guide to DesignFoundation

"I'll just write a quick button style... and a text field with validation... and a card component..." Two weeks later you have a bespoke design system that only half the app uses, three slightly different buttons, and nothing shipped.

If you're building with an AI coding agent, the drift problem is worse, not better. Ask Claude, Cursor, or Codex to "add a settings screen" three separate times and you'll get three different paddings, three different corner radii, and a button style that quietly forked itself somewhere around commit 40. Agents are great at writing plausible SwiftUI and bad at remembering what the rest of your app already decided.

DesignFoundation is an open-source SwiftUI package that gives you a token-based theming engine, 25 components, and 6 feedback/overlay modifiers that all read from the same theme. You set it once at the app root, and everything underneath updates. That's the whole idea — and it ships with CLAUDE.md, AGENTS.md, and Cursor rules already written, so an agent working in your repo reaches for DFButton instead of inventing a new one.

In this tutorial you'll go from zero to a themed, consistent SwiftUI UI — without writing a single custom button style.


What We're Building

By the end you'll have:

  • A working app that uses DesignFoundation's theme system
  • A themed form with inputs, validation states, and feedback components
  • A custom theme built from tokens
  • An understanding of how the style system composes

Requirements: Xcode 16+, iOS 18+ / macOS 15+ target, Swift 6.


Installation

Via Xcode

  1. File → Add Package Dependencies
  2. Paste https://github.com/NerdSnipe-Inc/design-foundation
  3. Set the version rule to Up to Next Major from 1.0.0
  4. Add DesignFoundation to your target

Via Package.swift

dependencies: [
    .package(url: "https://github.com/NerdSnipe-Inc/design-foundation", from: "1.0.0")
],
targets: [
    .target(name: "YourApp", dependencies: ["DesignFoundation"])
]
Enter fullscreen mode Exit fullscreen mode

Step 1: One Line Instead of a ThemeManager Singleton

Every app that grows past a prototype ends up with some version of a ThemeManager singleton — a global object holding colors and fonts that every view reaches into, and that somebody eventually has to thread through previews, tests, and SwiftUI's environment by hand.

Import DesignFoundation and attach a preset theme to your root scene instead. That's all the wiring needed — every component below this point reads from it.

import SwiftUI
import DesignFoundation

@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
                .dfThemePreset(.slate)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Four general-purpose presets ship out of the box:

Preset Character Primary (light / dark) Works well for
.slate Professional, balanced, default radius #1C3D5A / #64B5F6 SaaS dashboards, dev tools
.aurora Vibrant, rounded, electric violet #6C47FF / #A78BFA Creative tools, social apps
.copper Warm, editorial, sharp radius #C4622D / #F4A261 Finance, content readers
.sage Calm, organic, very rounded #2D6A4F / #74C69D Health, wellness

Each preset automatically switches between its light and dark variant based on @Environment(\.colorScheme). You don't manage that yourself.


Step 2: Never Hand-Roll a Button Style Again

The second conversation every SwiftUI project has with itself: "should this button be .borderedProminent or a custom ButtonStyle, and does the disabled state look right, and did I remember to match the corner radius everywhere else?" You answer those questions once, then answer them again for the next button.

Once the theme is in the environment, you can drop in any DF* component instead. They all read tokens from the theme — no manual color or font passing required.

struct ContentView: View {
    var body: some View {
        VStack(spacing: 24) {
            DFText("Welcome back", scale: .title)
            DFText("Sign in to continue", scale: .body)
            DFButton("Sign in") {
                // action
            }
        }
        .padding()
    }
}
Enter fullscreen mode Exit fullscreen mode

DFText uses the theme's typography tokens. DFButton uses color, radius, and spacing tokens. Change the preset at the app root and both update.

Button styles

DFButton ships with five built-in styles:

DFButton("Primary action") { }          // .filled (default)
DFButton("Secondary") { }
    .dfButtonStyle(.outlined)
DFButton("Subtle") { }
    .dfButtonStyle(.ghost)
DFButton("Tinted") { }
    .dfButtonStyle(.tinted)
DFButton("Glass") { }
    .dfButtonStyle(.glass)              // requires iOS 26+ / macOS 26+
Enter fullscreen mode Exit fullscreen mode

Apply a style to an entire section instead of to each button individually:

VStack {
    DFButton("Save") { }
    DFButton("Cancel") { }
}
.dfButtonStyle(.outlined)
Enter fullscreen mode Exit fullscreen mode

Step 3: Stop Reinventing Inline Field Errors

Validation UI is deceptively fiddly: where does the error text sit, does it push the layout down or overlay it, does the border turn red before or after the user leaves the field. Most teams solve this differently in every form in the app.

DesignFoundation's input components share a DFValidationState type so error states look consistent everywhere.

struct SignInView: View {
    @State private var email = ""
    @State private var password = ""
    @State private var emailState: DFValidationState = .none
    @State private var passwordState: DFValidationState = .none

    var body: some View {
        VStack(spacing: 16) {
            DFTextField("Email", text: $email, validationState: emailState)
                .dfTextFieldStyle(.outlined)

            DFSecureField("Password", text: $password, validationState: passwordState)
                .dfTextFieldStyle(.outlined)

            DFButton("Sign in") {
                validate()
            }
        }
        .padding()
    }

    private func validate() {
        emailState = email.contains("@")
            ? .valid
            : .error("Enter a valid email address")

        passwordState = password.count >= 8
            ? .valid
            : .error("Password must be at least 8 characters")
    }
}
Enter fullscreen mode Exit fullscreen mode

DFValidationState has three cases: .none, .valid, and .error(String). The error message renders inline under the field automatically — you don't lay it out yourself.

DFSecureField also includes a built-in reveal toggle. No extra code needed.


Step 4: Consistent Elevation Without Duplicating Shadow Values

"What shadow radius did we use for cards again?" is a question that shouldn't need a Slack search. DFCard reads elevation from the same theme as everything else, so every card in the app — dashboard tile, list row, modal — gets the same shadow, radius, and surface color without you copy-pasting a .shadow() modifier around the codebase.

DFCard {
    VStack(alignment: .leading, spacing: 12) {
        DFText("Account summary", scale: .headline)
        DFDivider()
        HStack {
            DFText("Status", scale: .body)
            Spacer()
            DFBadge(text: "Active")
                .dfBadgeStyle(.tinted)
        }
    }
    .padding()
}
.dfCardStyle(.elevated)
Enter fullscreen mode Exit fullscreen mode

Card styles: .elevated, .outlined, .filled, .glass (iOS 26+).


Step 5: Toasts and Skeletons Without a State Machine

Toast queues in particular tend to grow their own bespoke state machine — an array of pending messages, timers to dismiss them, logic to avoid three toasts stacking on top of each other. DesignFoundation ships that machinery so you don't own it.

Toast notifications

Mount .dfToast() once on any root view, then trigger toasts imperatively from anywhere in the tree:

// Mount once at the root
ContentView()
    .dfToast()

// Trigger from any view action
DFButton("Save changes") {
    // do the save
    DFToastQueue.shared.show(text: "Changes saved", severity: .success)
}
Enter fullscreen mode Exit fullscreen mode

DFToastQueue handles queue management and auto-dismiss. Multiple toasts queued in quick succession display in order.

Loading states

if isLoading {
    DFSkeleton()
        .frame(height: 80)
} else {
    // actual content
}
Enter fullscreen mode Exit fullscreen mode

DFSkeleton plays a shimmer animation. Use it as a placeholder while data loads.

Progress

DFProgressBar(value: uploadProgress)              // linear, determinate
DFProgressBar(variant: .indeterminate)            // indeterminate
Enter fullscreen mode Exit fullscreen mode

Step 6: Rebrand Without a Find-and-Replace

The four presets cover a lot of ground, but sometimes you need your brand colors. DFTheme exposes token namespaces for colors, typography, spacing, radius, shadow, and animation.

@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
                .dfTheme(DFTheme(
                    colors: DFColorTokens(
                        primary: Color(red: 0.388, green: 0.400, blue: 0.945)   // brand indigo, #6366F1
                    ),
                    spacing: DFSpacingTokens(md: 20),
                    radius: DFRadiusTokens(md: 14)
                ))
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

DesignFoundation doesn't ship a Color(hex:) initializer — use Color(red:green:blue:), an asset catalog color, or your own hex-to-Color helper if you're pasting values straight from a design tool.

You don't have to specify every token. Unspecified tokens fall back to the defaults.

Mutating a single token from a preset

If a preset is 95% right but one token is off, mutate just that token:

var theme = DFTheme.slateLight
theme.colors.primary = .purple

MyView()
    .dfTheme(theme)
Enter fullscreen mode Exit fullscreen mode

Light/dark variants

The preset modifier handles switching automatically. If you need to force a specific variant — useful in Previews or sub-tree overrides — use the named theme directly:

// Force dark regardless of system setting
MyView()
    .dfTheme(.slateDark)
Enter fullscreen mode Exit fullscreen mode

Override the theme for a subtree

Because DFTheme lives in SwiftUI's environment, you can scope overrides to any part of the view tree:

VStack {
    // rest of the UI uses the app-level theme

    SettingsPanel()
        .dfTheme(.copperLight)   // this subtree uses copper
}
Enter fullscreen mode Exit fullscreen mode

Step 7: One Preset, Light and Dark, No Duplicate Logic

The alternative to a custom preset is remembering to apply your brand color twice — once for light mode, once for dark — everywhere you set a theme. DFThemePreset collapses that into one named value:

let brandPreset = DFThemePreset(
    light: {
        var t = DFTheme.slateLight
        t.colors.primary = Color(red: 0.388, green: 0.400, blue: 0.945)   // #6366F1
        return t
    }(),
    dark: {
        var t = DFTheme.slateDark
        t.colors.primary = Color(red: 0.506, green: 0.549, blue: 0.973)   // #818CF8
        return t
    }()
)

MyApp()
    .dfThemePreset(brandPreset)
Enter fullscreen mode Exit fullscreen mode

Change One Call Site, Restyle a Whole Screen

Without a shared style system, "make every button on this screen ghost-style" means visiting every button. With one, it's one modifier on the container.

The style pattern follows the same protocol SwiftUI uses for ButtonStyle. Styles compose, propagate through the environment, and apply hierarchically.

// Apply styles to a whole section
VStack { ... }
    .dfButtonStyle(.outlined)
    .dfCardStyle(.glass)

// Override for one component
DFButton("Delete", role: .destructive) { }
    .dfButtonStyle(.ghost)
Enter fullscreen mode Exit fullscreen mode

If you want Liquid Glass across your entire UI (iOS 26+ / macOS 26+):

ContentView()
    .dfButtonStyle(.glass)
    .dfCardStyle(.glass)
    .dfTooltipStyle(.glass)
Enter fullscreen mode Exit fullscreen mode

Platform Considerations

DesignFoundation targets iOS 18+, macOS 15+, and visionOS 2+. Most components work across all three. A few notes:

  • .glass styles require iOS 26+ / macOS 26+ (Liquid Glass). You get a compile-time reminder if you use them on an older target.
  • DFSidebar is only meaningful on macOS and iPadOS.
  • DFPlatformVariant (.automatic / .compact / .expanded / .immersive) lets components adapt at runtime, or lets you force a layout for Previews and testing.

The part worth calling out: you do not need #if os(macOS) / #if os(iOS) to use any DF component. Platform differences — sidebar vs. tab bar, desktop vs. touch density, native table vs. scrollable rows — are handled internally by DFPlatformContext, injected automatically by .dfTheme(). Your own view code stays platform-agnostic; the only place you still reach for a guard is app-level APIs the package doesn't wrap, like a Mac-only WindowGroup.


Built for AI Coding Agents, Not Just Humans

If you're using Claude Code, Cursor, or another agent to build UI, DesignFoundation ships instructions for all three: CLAUDE.md, AGENTS.md, and .cursor/rules/design-foundation.mdc sit at the package root. Point an agent at a repo that depends on DesignFoundation and it already knows the rule — reach for DFButton, DFCard, DFTextField, not a hand-rolled equivalent — without you writing that instruction yourself.

That matters more for agents than for you. A human developer remembers the button style they used last week; an agent starting a fresh session doesn't, and will happily invent a fourth ButtonStyle if nothing tells it otherwise. Combined with the platform-agnostic API above, this means an agent can build a real cross-platform screen — no #if os() blocks, no reinvented button — in one pass, and the result is something you can actually debug later, because every screen it touched is made of the same handful of known components instead of N slightly different one-offs.


What's in the Box

A quick reference of everything that ships:

Primitives: DFButton, DFText, DFIcon, DFBadge, DFAvatar, DFDivider

Inputs: DFTextField, DFSecureField, DFTextArea, DFToggle, DFSlider, DFPicker, DFDatePicker, DFCheckbox

Layout: DFCard

Overlays (modifiers, not constructible views): .dfModal(), .dfSheet(), .dfPopover(), .dfTooltip()

Navigation: DFTabBar, DFNavigationBar, DFSidebar

Supplementary: DFAlertConfiguration + .dfAlert(), DFToastQueue + .dfToast(), DFSkeleton, DFProgressBar, DFList, DFListRow, DFTable, DFDataTable, DFDataGrid


Wrapping Up

DesignFoundation solves the problem most SwiftUI projects hit at month three: component drift, where three different developers have built three slightly different buttons and nobody's sure which one is "correct" anymore. When every component reads from the same DFTheme in the environment, a brand refresh is a token edit, not a project-wide find-and-replace.

The package is MIT licensed and actively maintained. The GitHub repo is the right place for issues or feedback, especially if the theme API feels rough for your use case.

If you need pre-built screens (auth flows, dashboards, CRM layouts) composed from these same primitives, there's a pro tier — but the primitives in this tutorial stay free.


Have a question about a specific component or want to see a more advanced theming example? Drop it in the comments.

Top comments (0)