Part 2 of 2. This tutorial builds on Part 1 — DesignFoundation core. If you haven't added the base package and theme yet, start there.
The core package gives you tokens and primitives. DesignFoundationPro adds what comes next — 29 blocks, 47 screens, 18 navigation shells, and 9 runnable composition examples across auth, onboarding, charts, data tables, and full product verticals. The docs claim ~87% fewer lines of code versus building from scratch. That's the bet.
This matters even more if an AI coding agent is doing the building. Ask an agent for a CRM screen and a settings screen in the same session and, without guardrails, you'll get two different takes on spacing, two different sidebar behaviors, and a table that's native on Mac in one screen and a scroll view pretending to be a table in the other. Pro ships with that guardrail already in place — more on that in Where to go from here.
How the two packages relate
Pro sits on top of Foundation and re-exports it. Import only DesignFoundationPro and you get everything from both:
YourApp your models, data, routing
DesignFoundationPro 29 blocks · 47 screens · 18 shells · 9 examples
DesignFoundation tokens · primitives · validation · theme engine · MIT
Access: Foundation is MIT and public. Pro is a commercial add-on — repo access is granted after purchase. Licenses are lifetime (no subscription); annual updates are $39/year and entirely optional. Pricing: $149 individual · $449 team (up to 5 devs).
Before buying, browse everything in DFPlayground — a free macOS app that lets you preview all 29 blocks, 47 screens, and 18 shells with live theming.
Step 1: Add DesignFoundationPro
Pro declares Foundation as its own dependency and re-exports it via @_exported import DesignFoundation. Add only the Pro package — Foundation comes with it automatically. The Pro repo URL is provided after purchase — contact nerdsnipe.inc@gmail.com or visit the Pro page to get access.
dependencies: [
.package(url: "https://github.com/NerdSnipe-Inc/DesignFoundationPro", from: "1.0.0"),
],
targets: [
.target(
name: "YourApp",
dependencies: [
.product(name: "DesignFoundationPro", package: "DesignFoundationPro"),
]
),
]
One package, one import: import DesignFoundationPro gives you both layers with no separate Foundation entry needed.
Step 2: Use the workspace theme
Pro adds DFTheme.workspace — a platform-adaptive preset tuned for data-dense product UIs. It uses tighter desktop typography and adjusted surface tokens on macOS. For most product apps, this is the right starting point.
import SwiftUI
import DesignFoundationPro
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
MyRootView()
.dfTheme(.workspace)
}
}
}
Still want a custom brand? Build your
DFThemefrom tokens as shown in Part 1 — the individual blocks (auth, charts, onboarding) genuinely read whatever theme is in the environment. The composition roots and shells in Step 8 are the exception: each one hard-codes.environment(\.dfTheme, .workspace)internally, so mountingDFCRMRootView()(or any other root, orDFOnboardingFlow) always renders in.workspaceregardless of what theme you set upstream. If you need a branded CRM/PM/Analytics root, that's a real customization, not a one-line override — check the root's source for where it applies the theme before assuming an ambient override will reach it.
Step 3: Stop Redesigning Sidebar Navigation for Every New App
Every product app needs some variation of "sidebar on Mac and iPad, tabs on iPhone," and it's one of the most-rebuilt pieces of chrome in SwiftUI — split view state, collapse behavior, selection binding, all rewritten per project. Shells are layout-only, pre-solved versions of that problem. They give you navigation structure — sidebar, columns, tabs — and expose @ViewBuilder slots for your own content. No data or business logic.
| Shell | Use for |
|---|---|
DFStandardSidebarShell |
Single sidebar + detail. macOS-native split view. |
DFThreeColumnShell |
Source list → content → inspector. Documents, mail. |
DFWorkspaceSidebarShell |
Collapsible sidebar with workspace density tokens. |
DFIconRailShell |
Narrow icon rail. Dashboard or tool-focused apps. |
DFAdaptiveShell |
Tabs on iPhone, sidebar on iPad and Mac. |
DFSearchSidebarShell |
Sidebar with integrated search bar at top. |
Each shell has its own init signature matching its structural role. (The table above lists 6 of the 18 general-purpose shells; the rest follow the same pattern.) There's also a 19th, vertical-specific shell — DFSocialAppShell — built for feed/explore/notifications/profile apps specifically rather than generic navigation. It's the simplest to read, with four named @ViewBuilder slots:
struct MyRootView: View {
var body: some View {
DFSocialAppShell {
FeedView()
} explore: {
ExploreView()
} notifications: {
NotificationsView()
} profile: {
ProfileView()
}
.dfToastHost()
}
}
The sidebar shells take typed nav data alongside a selection binding and a content @ViewBuilder: DFStandardSidebarShell takes sections: [DFNavSection] (each section holds [DFNavItem]), DFAdaptiveShell takes [DFAdaptiveTab], DFWorkspaceSidebarShell takes [DFWorkspace] plus an icon rail and account switcher. The composition roots (Step 8) wire all of this for you when you're building a known vertical.
Mount
.dfToastHost()once on any root view. After that, trigger toasts from anywhere in the tree withDFToastQueue.shared.show(text: "Saved", severity: .success).
Step 4: Ship a Real Sign-In Flow Before Lunch, Not After a Sprint
Auth screens feel simple until you're deep into forgot-password states, social provider buttons, OTP resend timers, and inline validation — a week of work for something that isn't your product's differentiator.
The four Pro auth blocks — DFWelcomeBlock, DFSignInBlock, DFSignUpBlock, DFOTPBlock — each take a Configuration struct with callbacks. Form validation is wired by default using DFFormState from Foundation; set usesFormValidation: false only when you own it yourself.
DFSignInBlock(configuration: .init(
title: "Sign in to Acme",
subtitle: "Welcome back",
socialProviders: [
.apple { Task { await AuthService.signInWithApple() } },
.google { Task { await AuthService.signInWithGoogle() } },
],
onSubmit: { email, password in
// onSubmit is not async — wrap async work in a Task
Task { await AuthService.signIn(email: email, password: password) }
},
onForgotPassword: { showForgotPassword() },
onSignUp: { showSignUp() }
))
The block handles layout, field spacing, validation error display, and social auth button presentation across iOS, macOS, and visionOS. Desktop density is applied automatically from @Environment(\.dfUseDesktopDensity).
OTP verification
Pair DFSignUpBlock with DFOTPBlock for email verification. Each block is independent — you control navigation between them:
DFOTPBlock(configuration: .init(
title: "Check your email",
subtitle: registeredEmail, // shown under the title
onSubmit: { code in
// onSubmit is not async — wrap async work in a Task
Task { await AuthService.verify(otp: code) }
},
onResend: {
Task { await AuthService.resendOTP() }
}
))
Step 5: One Config Object Instead of a Seven-Screen State Machine You Own
Onboarding flows are where a lot of hand-rolled apps quietly go wrong — the "what screen comes next" logic ends up spread across seven view files with their own local state, and resuming mid-flow after the app is backgrounded is an afterthought nobody tests.
DFOnboardingFlow is the highest-level Pro component: a multi-step flow that covers welcome, sign-up or sign-in, OTP verification, profile setup, permission requests, plan selection, personalisation, and a success screen. It manages its own step state (persisted across launches via UserDefaults) and handles back navigation.
You configure the whole flow via DFOnboardingConfiguration — one struct, no subclassing:
DFOnboardingFlow(configuration: .init(
appName: "Acme",
welcomeTagline: "Welcome to Acme",
welcomeHeadline: "The best tool for your team.",
enableAppleSignIn: true,
enableGoogleSignIn: true,
featureHighlights: [
.init(icon: "sparkles", title: "AI-Powered",
description: "Smart suggestions across every workflow."),
.init(icon: "person.3", title: "Built for Teams",
description: "Real-time collaboration, no friction."),
],
requestedPermissions: [.notifications],
showPlanSelection: true,
planTiers: [
.init(name: "Free", price: "$0", period: "forever",
features: ["3 projects", "1,000 API calls"], isPopular: false),
.init(name: "Pro", price: "$49", period: "/ month",
features: ["Unlimited projects", "Priority support"],
isPopular: true, badge: "Most Popular"),
],
personalisationTags: [
.init(id: "startup", label: "Startup"),
.init(id: "enterprise", label: "Enterprise"),
.init(id: "freelance", label: "Freelance"),
],
onSignUp: { name, email, password in
// Return Bool: true = success, false = failure (e.g. email taken)
return await AuthService.signUp(name: name, email: email, password: password)
},
onSignIn: { email, password in
return await AuthService.signIn(email: email, password: password)
},
onOTPVerify: { code in return await AuthService.verify(otp: code) },
onOTPResend: { await AuthService.resendOTP() },
onProfileSave: { name, avatar in await ProfileService.save(name, avatar) },
onPermissionRequest: { perm in await PermissionService.request(perm) },
onPlanSelected: { tier in BillingService.select(tier) },
onPersonalisationComplete: { tags in ProfileService.saveTags(tags) },
onComplete: { AppState.shared.isOnboarded = true }
))
That one initialiser configures every step. If the user backgrounds the app mid-onboarding, they resume where they left off.
Step 6: Swift Charts Without the Axis-and-Legend Boilerplate
Swift Charts gives you the primitives, but every dashboard still needs the same surrounding scaffolding — a period picker, a legend, loading and empty states, consistent theme colors per series. That's the part that gets rebuilt per project.
Chart blocks are composables, not screen-locked. Drop them into your own views, not just the Analytics root. They use Swift Charts under the hood and inherit theme colors automatically.
| Block | Use for |
|---|---|
DFLineChartBlock |
Trends, time series, cumulative metrics |
DFBarChartBlock |
Category comparison, period-over-period |
DFDonutChartBlock |
Part-to-whole breakdown, proportions |
DFStatCardBlock |
Single metric with delta and trend sparkline |
Don't use
DFChartPlaceholderBlockin shipping UI. It's a loading/skeleton state only. For loading chart data, passisLoading: trueto the real chart block instead.
// Keyword arguments must appear in the same order as the initializer declares them —
// isLoading: comes right after title:, before data:.
DFLineChartBlock(configuration: .init(
title: "Active Users",
isLoading: isFetchingData,
data: [
("Mon", 182), ("Tue", 195), ("Wed", 188),
("Thu", 212), ("Fri", 240), ("Sat", 198),
],
periods: DFChartPeriodPreset.allCases,
selectedPeriod: .sevenDays,
onPeriodChange: { period in
// onPeriodChange is not async — wrap async work in a Task
Task { await loadTrend(for: period) }
},
height: isDesktop ? 140 : 220
))
The period toolbar is built in. Your domain period enum conforms to DFChartPeriodRepresentable if you need custom ranges beyond the preset.
Step 7: The Mac-Native-Table-vs-iPhone-Fallback Problem, Solved Once
A data table that feels native on macOS (keyboard navigation, Return to activate a row) and still works on a phone-width screen is two different UIs wearing the same data. Building both, and deciding which one renders where, is a recurring cross-platform tax.
DFDataTable ships in Foundation but it's most useful in Pro-scale apps. On macOS it renders a native Table with keyboard navigation and Return-to-activate. On iPhone it falls back to scrollable header + rows. Use it on regular-width layouts; keep DFList with swipe actions on compact phone screens where that fits better.
@State private var selectedIDs: Set<Contact.ID> = []
private var columns: [DFDataTableColumn<Contact>] {
[
DFDataTableColumn(id: "name", title: "Name") { $0.name },
DFDataTableColumn(id: "company", title: "Company") { $0.company },
DFDataTableColumn(id: "status", title: "Status") { $0.status.rawValue },
]
}
DFDataTable(
data: filteredContacts,
columns: columns,
selection: $selectedIDs,
selectionMode: .multiple,
filterQuery: searchText,
onRowActivate: { openDetail($0) },
emptyContent: {
DFEmptyStateBlock(configuration: .init(
icon: "person.crop.circle.badge.questionmark",
title: "No contacts found",
message: "Try a different search or filter."
))
}
)
Step 8: A CRM/PM/Analytics Shell You Delete Code From, Not Build Up To
Starting a new product vertical from a blank ContentView means weeks before it looks like a real app. If you're building a known vertical, Pro ships full composition roots instead: pre-wired screens, navigation, and blocks. Mount the root, replace the fixture data with your models, and you have something that looks like a real product immediately.
| Root | What it includes |
|---|---|
DFCRMRootView |
Home, pipeline, contacts table, analytics |
DFPMRootView |
Project board, list, timeline, team roster |
DFAnalyticsRootView |
Overview, events, revenue, users — all with charts |
DFAIChatRootView |
New chat, thread, compare view, settings sheet |
DFEcommerceRootView |
Store home, orders table, products, revenue |
DFSettingsRootView |
Account, security, billing, team, danger zone |
DFSocialAppShell |
Feed, explore, notifications, profile |
struct MyRootView: View {
var body: some View {
DFCRMRootView()
.dfToastHost()
}
}
The .environment(\.dfTheme, .workspace) line from Step 2 isn't needed here — every composition root applies .workspace internally, so it renders themed correctly with no setup at all. The root ships with preview fixtures (CRMPreviewFixtures, etc.) so it renders immediately with realistic data. Replace those fixtures with your own domain types as you go. Keep the layout, the density helpers, and the theme wiring — those are the parts you don't want to rebuild.
Quality gate. Before shipping a screen built on Pro,
QUALITY-GATE.mdin the repo has 15 review items including platform-native layout, non-happy-path previews, and correct root wiring. The bar is: would this hold up as a standalone app?
Advanced: Validation Wiring You Write Once, Not Per Screen
Every form in an app tends to reinvent its own field-to-validator plumbing — a slightly different isValid computed property each time. Pro auth and settings screens wire DFFormState by default so that plumbing exists once. If you need the same pattern in your own views:
private enum Field {
static let email = "email"
static let password = "password"
}
@State private var form = DFFormState()
// Register fields and validators — e.g. in .task or a setup method:
form.register(field: Field.email, validators: [DFRequiredValidator(), DFEmailValidator()])
form.register(field: Field.password, validators: [DFRequiredValidator(), DFMinLengthValidator(minLength: 8)])
// In your view body:
DFValidatedTextField("Email", field: Field.email, form: form)
DFSecureField(
"Password",
text: form.binding(for: Field.password),
validationState: form.validationState(for: Field.password)
)
DFButton("Sign in") {
guard form.validate() else { return }
// DFButton action is () -> Void — wrap async work in a Task
Task {
await submit(
form.values[Field.email, default: ""],
form.values[Field.password, default: ""]
)
}
}
Built-in validators: DFRequiredValidator, DFEmailValidator, DFMinLengthValidator, DFMaxLengthValidator, DFRegexValidator. Conform to DFFieldValidator to add your own.
Built for AI Coding Agents
Pro ships the same CLAUDE.md / AGENTS.md / .cursor rule file as Foundation, plus more: a .cursor/skills and .cursor/references library covering Apple's Human Interface Guidelines platform-by-platform, Liquid Glass, Swift Charts patterns, accessibility, and desktop-app archetypes. Point Claude Code, Cursor, or Codex at a repo that depends on DesignFoundationPro and it isn't just told "use DFButton" — it has design-pattern reference material to draw on when it's deciding how a screen should actually look.
That's the difference between an agent that produces a working screen and one that produces a correct-looking, cross-platform screen without a single #if os(macOS) block — because the platform branching already happened inside the shell, the block, or the root it reached for. The practical result: when three different agent sessions build three different features of the same app, they come out looking like one product instead of three.
Where to go from here
DesignFoundationPro is designed to be adopted incrementally — a single block takes about 5 minutes to drop in, a full screen 10 minutes, a composition root 15. You don't need the full vertical to get value from it.
- DFPlayground — free macOS app, browse all 29 blocks, 47 screens, and 18 shells with live theming before committing to anything
- Official docs — integration guide, theme reference, the Foundation Way philosophy
Issues and feedback on the GitHub repo. If you missed the foundation layer, start with Part 1.
Top comments (0)