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
Then in any file that uses DF:
import DesignFoundation
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
}
}
}
.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) }
}
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)
}
}
}
Notes:
-
DFEmptyStategives you a first-run experience for free. No zero-state debt later. -
DFAvatar(contact.initials)renders initials in a themed circle. There's alsoDFAvatar(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 standaloneDFSheetyou 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) { }
}
}
Two things worth internalizing:
-
Text uses
scale:, notstyle:..headline,.body,.captionare the tokens. -
Buttons take a
role:separately from astyle:. 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()
}
}
What just happened:
-
DFFormStateowns the values, the validators, the errors, and the "touched" state — you don't wire any of it yourself. -
DFValidatedTextFieldreads 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)
}
}
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.
Questions or feedback: open an issue on the repo.
Top comments (0)