DEV Community

Cover image for Build a Contacts app in 20 minutes with DesignFoundation
Nerd Snipe
Nerd Snipe

Posted on

Build a Contacts app in 20 minutes with DesignFoundation

Build a Contacts app in 20 minutes with DesignFoundation

Level: Beginner · Time: ~20 minutes · You'll need: Xcode 16, iOS 18 / macOS 15 target, and passing familiarity with SwiftUI (@State, VStack, bindings).

By the end of this tutorial you'll have a themed, cross-platform Contacts app — list, detail, add-contact form with validation, toasts, and an empty state — written entirely with DesignFoundation components. Zero custom buttons. Zero re-rolled text fields. No #if os(macOS) guards in your view code.

This is Part 1 of a three-part series. Part 2 adds search, a command palette, and a sidebar. Part 3 goes deep on custom themes, multi-window macOS, and shipping-quality UX.


Why DesignFoundation

Every hand-rolled toggle is 25 lines of view code that:

  • Doesn't match your app's other toggles.
  • Doesn't respect the active theme.
  • Doesn't survive a dark-mode pass.
  • Costs you tokens on every AI-assisted edit.

DesignFoundation ships the theming, the platform quirks, and the accessibility hooks. You write intent. DFButton("Save") { save() } is one line, themed, and identical on iOS, macOS, and visionOS.

The free package covers the entire component vocabulary you'll use here. DesignFoundation Pro ships production-ready screens built on top of it — auth flows, dashboards, an AI chat vertical, a full CRM including the contacts screens we're about to build by hand. We'll flag the Pro moments as we go, so you can see where the leverage compounds. → nerdsnipe-inc.github.io/design-foundation/pro


1. Install

Add the package to your project — File → Add Package Dependencies…

https://github.com/NerdSnipe-Inc/design-foundation
Enter fullscreen mode Exit fullscreen mode

Then in any file that uses DF:

import DesignFoundation
Enter fullscreen mode Exit fullscreen mode

Set a theme once, at your scene root. Everything below inherits automatically.

import SwiftUI
import DesignFoundation

@main
struct ContactsApp: App {
    var body: some Scene {
        WindowGroup {
            RootView()
                .dfThemePreset(.slate)      // .slate .aurora .copper .sage
                .dfToast()                  // enables the shared toast queue
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

.dfThemePreset(.slate) picks the light or dark variant automatically from @Environment(\.colorScheme). That's it — every DF component in the tree now uses Slate.

Pro moment. Pro ships four additional brand-tuned themes plus a theme editor so designers can iterate without touching Swift.


2. Model

Nothing DF-specific yet — just a plain contact type.

struct Contact: Identifiable, Hashable {
    let id = UUID()
    var name: String
    var email: String
    var role: String
    var initials: String { name.split(separator: " ").compactMap(\.first).prefix(2).map(String.init).joined() }
}

@Observable
final class ContactStore {
    var contacts: [Contact] = [
        Contact(name: "Ada Lovelace",   email: "ada@analytical.co",   role: "Engineering"),
        Contact(name: "Grace Hopper",   email: "grace@compiler.dev",  role: "Compilers"),
        Contact(name: "Katherine Johnson", email: "kj@trajectory.nasa", role: "Math"),
    ]
    func add(_ c: Contact) { contacts.append(c) }
}
Enter fullscreen mode Exit fullscreen mode

3. The list screen

We want an avatar, a title, a subtitle, a role badge, and a disclosure chevron. All of that is one row.

struct ContactListView: View {
    @Environment(ContactStore.self) private var store
    @State private var showAdd = false

    var body: some View {
        Group {
            if store.contacts.isEmpty {
                DFEmptyState(
                    icon: "person.crop.circle.badge.plus",
                    title: "No contacts yet",
                    message: "Add your first person to get started.",
                    actionTitle: "Add contact",
                    onAction: { showAdd = true }
                )
            } else {
                DFList(store.contacts) { contact in
                    NavigationLink(value: contact) {
                        DFListRow(
                            title: contact.name,
                            subtitle: contact.email,
                            showDisclosure: true,
                            leading: { DFAvatar(contact.initials) },
                            trailing: { DFBadge(text: contact.role) }
                        )
                    }
                }
            }
        }
        .dfNavigationBar(title: "Contacts") {
            DFButton("Add") { showAdd = true }
                .dfButtonStyle(.tinted)
        }
        .navigationDestination(for: Contact.self) { ContactDetailView(contact: $0) }
        .dfSheet(isPresented: $showAdd) {
            AddContactView().environment(store)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Notes:

  • DFEmptyState gives you a first-run experience for free. No zero-state debt later.
  • DFAvatar(contact.initials) renders initials in a themed circle. There's also DFAvatar(image:) for a full image.
  • DFBadge(text:) picks up the theme's accent colors — no color params to fiddle with per call.
  • .dfNavigationBar(title:) is a modifier, not a wrapping view. Same signature on iOS and macOS.
  • .dfSheet(isPresented:) is a modifier — there is no standalone DFSheet you construct.

Pro moment. Pro's CRM vertical ships this exact list plus segmented filters, stage indicators, sortable columns, and a fully-wired inspector panel — as a screen, not a snippet.


4. The detail screen

DFCard for structure, DFText for typography scale, DFDivider between sections, DFButton for actions.

struct ContactDetailView: View {
    let contact: Contact

    var body: some View {
        ScrollView {
            VStack(spacing: 16) {
                DFCard {
                    HStack(spacing: 16) {
                        DFAvatar(contact.initials)
                        VStack(alignment: .leading, spacing: 4) {
                            DFText(contact.name, scale: .headline)
                            DFText(contact.email, scale: .caption)
                        }
                        Spacer()
                        DFBadge(text: contact.role)
                    }
                }

                DFCard {
                    VStack(alignment: .leading, spacing: 12) {
                        DFText("Notes", scale: .headline)
                        DFDivider()
                        DFText("No notes yet. Add one below.", scale: .caption)
                    }
                }

                HStack {
                    DFButton("Email") { /* mailto */ }
                        .dfButtonStyle(.outlined)
                    DFButton("Delete", role: .destructive) { /* delete */ }
                        .dfButtonStyle(.ghost)
                }
            }
            .padding()
        }
        .dfNavigationBar(title: contact.name) { }
    }
}
Enter fullscreen mode Exit fullscreen mode

Two things worth internalizing:

  1. Text uses scale:, not style:. .headline, .body, .caption are the tokens.
  2. Buttons take a role: separately from a style:. Destructive isn't a style — it's an intent that any style can carry.

5. The add-contact form (with validation)

This is where DF earns its keep. Validation is normally the ugliest code in the app; DF gives you an @Observable form-state object with pluggable validators.

struct AddContactView: View {
    @Environment(\.dismiss) private var dismiss
    @Environment(ContactStore.self) private var store

    @State private var form = DFFormState(fields: [
        "name":  [DFRequiredValidator()],
        "email": [DFRequiredValidator(), DFEmailValidator()],
        "role":  [DFRequiredValidator()],
    ])

    var body: some View {
        VStack(spacing: 16) {
            DFText("New contact", scale: .headline)

            DFValidatedTextField("Name",  field: "name",  form: form)
            DFValidatedTextField("Email", field: "email", form: form)
            DFValidatedTextField("Role",  field: "role",  form: form)

            HStack {
                DFButton("Cancel") { dismiss() }
                    .dfButtonStyle(.ghost)
                DFButton("Save") {
                    guard form.validate() else { return }
                    let c = Contact(
                        name:  form.values["name",  default: ""],
                        email: form.values["email", default: ""],
                        role:  form.values["role",  default: ""]
                    )
                    store.add(c)
                    DFToastQueue.shared.show(text: "Contact saved", severity: .success)
                    dismiss()
                }
            }
        }
        .padding()
    }
}
Enter fullscreen mode Exit fullscreen mode

What just happened:

  • DFFormState owns the values, the validators, the errors, and the "touched" state — you don't wire any of it yourself.
  • DFValidatedTextField reads and writes the named field on the form. Inline error messages appear as the user interacts.
  • form.validate() runs every registered validator and returns a Bool. Short-circuit your submit on it.
  • DFToastQueue.shared.show(text:severity:) fires a toast anywhere in the app because you added .dfToast() at the root.

Built-in validators are DFRequiredValidator, DFEmailValidator, DFMinLengthValidator, DFMaxLengthValidator, DFRegexValidator. When you need custom logic, conform your own type to DFFieldValidator.

Pro moment. Pro's auth vertical is the same building blocks composed: sign-in, sign-up, forgot-password, OTP verification, welcome — all wired, all themed, all cross-platform. If you're shipping any auth surface, that's a week you don't spend.


6. Wire it up

struct RootView: View {
    @State private var store = ContactStore()

    var body: some View {
        NavigationStack {
            ContactListView()
        }
        .environment(store)
    }
}
Enter fullscreen mode Exit fullscreen mode

Run it on iOS. Run it on macOS. Same code. Themed. Accessible. Done.


What you built

  • Themed root with one line
  • Empty state with a primary action
  • List with avatars, subtitles, badges, and disclosure
  • Detail screen with cards, typography, and role-tagged actions
  • Validated form with a real error surface
  • Toast confirmations from anywhere in the app

Line count of DF-specific code: roughly 90. Line count of custom UI code you'd have written otherwise: several hundred, minus the theming, minus the validation, minus the cross-platform behavior.


Where this goes next

Part 2 — Intermediate: Search, palette, and settings. We'll add a command palette (⌘K), a persistent sidebar on macOS/iPad, a settings screen with toggles/pickers/sliders/date picker, skeleton loaders for async fetches, and per-component token overrides so one screen can bend the theme without redefining it.

Part 3 — Advanced: Custom themes, multi-window, and polish. A hand-built DFTheme with your brand colors, a macOS detail window opened via @Environment(\.openWindow), a custom DFFieldValidator, DFDataGrid and DFTable for tabular data, popovers and tooltips, DFAlertConfiguration for destructive confirmations, and a full accessibility pass.


Skip the runway with Pro

The screens you'd build next — a proper CRM contacts screen with inspector, an analytics dashboard for your team, an AI chat interface, an onboarding flow — are the reason DesignFoundation Pro exists. Pro is 40+ full vertical screens and shell layouts (sidebar-plus-inspector, floating panel, adaptive workspace) built on the same free foundation you just used. If you liked the ergonomics of the components, the screens feel identical — just further up the stack.

DesignFoundation Pro

Questions or feedback: open an issue on the repo.

Top comments (0)