swiftui-design-system — one scale, and the guards that keep it
A design system is not a colour file. It is four scales, one grid, one
catalogue and one tripwire — and the tripwire is what makes it survive the
next feature. Everything here is native SwiftUI: no design-system framework is
worth taking on, because the platform is already built for this (Environment +
style protocols + containerRelativeFrame).
Apple ships its own SwiftUI skills inside Xcode 27 — invalidation, ForEach
identity, @Observable, Liquid Glass — as plain markdown any agent can read
with no Xcode running. On anything about correctness or update cost they
outrank this file, and they say so in their own header. Where they are, what
they cover, and the rules that most often hit real code:
references/apple-xcode-skills.md.
Reach for this when the symptom is "the screens look related and never line up". Measure before you believe it — see step 1.
Workflow
1. Count what is actually there
Never start from taste. Start from the inventory, because the number is the argument:
V=path/to/Views
grep -rhoE '\.padding\((\.[a-z]+, )?[0-9]+(\.[0-9])?\)' $V | grep -oE '[0-9.]+' | sort -n | uniq -c | sort -rn
grep -rhoE 'spacing: [0-9]+(\.[0-9])?' $V | sort | uniq -c | sort -rn
grep -rhoE 'cornerRadius: [0-9]+(\.[0-9])?' $V | sort | uniq -c | sort -rn
grep -rhoE '\.font\(\.[a-zA-Z0-9]+\)|\.font\(\.system\([^)]*\)\)' $V | sort | uniq -c | sort -rn
grep -rhoE '\.spring\(response: [0-9.]+, dampingFraction: [0-9.]+\)' $V | sort -u | wc -l
A real app measured this way: 27 padding values, 19 spacings, 23 radii, 48
font spellings, 21 springs — including .system(size: 17.5), 13.5, 12.5.
Half a point is invisible alone and lethal in a set: it is exactly the amount by
which two labels fail to look like the same label.
2. Write the scales — namespace enums, one file each
Views/Design/
Space.swift 4-point steps: hair(2) xs sm md lg xl xxl xxxl huge + touchTarget(44)
Grid.swift 12 columns, gutter, margin, gridSpan(_:), gridMargins(), GridOverlay
Typography.swift 8 steps, each a platform TextStyle + Metric for sizes a font can't set
Radii.swift 5 radii + shape(_:) — .continuous, always
Motion.swift 6 curves + the never-repeatForever rule
Surface.swift 3–4 levels (console/panel/inset/field), one recipe each
Enums over structs: compile-time names, zero runtime, no instance to thread.
Reach for @Entry var theme in the Environment only when there is a second
theme (white-label, per-brand, light/dark that is not the system's). Until
then a theme object is one indirection buying nothing.
Two rules that decide the arguments in step 3:
- Every type step is a platform text style —
Font.system(.subheadline, weight:), not.system(size: 15). Fixed sizes never grow with Dynamic Type; in the measured app 78 of them didn't. For sizes a font cannot set (an icon's box, a ring's diameter) use@ScaledMetric(relativeTo:)over aMetricconstant. - The unit of layout is the column, not the point.
containerRelativeFrame( .horizontal, count: 12, span: 4, spacing: gutter)is iOS 17+ and is the platform's own grid arithmetic — noGeometryReader, no percentages.
3. Migrate mechanically, with the rounding rule written down
Hand-editing hundreds of literals is where a migration dies — half done, half not, and nobody can say which half. Write a codemod, keep it in the repo, and let it carry the rule:
# nearest 4-point step, TIES GO UP (6→8, not 4): rounding down tightens a
# third of the app by two points at once, and a snug layout is the one that
# breaks. Only padding / spacing / cornerRadius / fonts in 9…26pt —
# never frame, offset, lineWidth or a shadow radius.
step, name = min(SCALE, key=lambda p: (abs(p[0] - value), -p[0]))
Full rule, the skip list, the spring and font tables: references/codemod.md.
Read it when actually migrating; one measured run was 991 substitutions in 31
files, dry-run first, build after each family.
4. Style protocols, not modifiers sprinkled per call site
SwiftUI's extension points are the system's spine — use them before inventing
.myButton():
struct PressableStyle: ButtonStyle { // one feel for every control
func makeBody(configuration: Configuration) -> some View {
configuration.label
.scaleEffect(configuration.isPressed ? 0.97 : 1)
.animation(configuration.isPressed ? Motion.press : Motion.release,
value: configuration.isPressed)
}
}
LabelStyle, ToggleStyle, ProgressViewStyle, MenuStyle the same way; a
ViewModifier + extension View for what has no protocol (.surface(.inset)).
5. Build the catalogue — and make it cheap to look at
Two doors, answering different questions:
make design # ImageRenderer → PNG sheets from a test: no launch, no taps, ~3s
make design-app # the same gallery in the simulator: glass, blur, motion
… launch <app> -designGrid YES # the 12 columns over the REAL screens
ImageRenderer inside an XCTest is the whole storybook you need — a gallery
view rendered to docs/previews/*.png, one file per sheet, no navigation and no
external dependency. It does not draw materials: .ultraThinMaterial and
glassEffect come out empty, so glass reads flat there. Geometry is exact,
which is what the sheets are for.
The grid overlay over the real app is the only thing that proves two screens agree; a gallery only proves one screen is tidy.
The writer, the debug flag, the localisation trap and when to reach for
swift-snapshot-testing: references/catalogue.md.
6. Guard it, or it comes back
Three layers, cheapest first:
- pre-commit grep on added lines (warn, not fail): a numeric
.padding(12),spacing: 6,cornerRadius: 18,.font(.system(size: 13)). Diff-scoped and warning-only is the right calibration — a whole-tree lint at fail severity breaks on inherited debt and gets bypassed, and a bypassed hook checks nothing. - tests on the arithmetic: 12 columns + 11 gutters + 2 margins == the
screen;
span(6) * 2 + gutter == width; every space step divisible by 4 except the one deliberate half step. - snapshot sheets in the repo — a reviewer sees the scale change as an image diff.
Gotchas
containerRelativeFramemeasures the container, not its content. ApplygridMargins()first, or every span is a margin too wide and nothing lines up with anything.- A
Sendablewarning onstatic lettokens in Swift 6: an enum ofstatic let CGFloatis fine; a struct holdingUserDefaultsneeds@unchecked Sendablewith a one-line reason. - Glass cannot sample glass. Two blurred surfaces side by side each sample
what is behind them and read as unrelated panes. On iOS 26 wrap a row of them
in
GlassEffectContainer(spacing:)and give morphing pairs a.glassEffectID(_:in:); below 26 fall back to.ultraThinMaterialin one place, not per screen. compositingGroup()+ a zero shadow is still an offscreen pass. Apply the lift only where there is a shadow to draw.- Never
repeatForever. A UI that never goes idle hangs everything that waits for idle: accessibility snapshots, UI automation, VoiceOver. Use.repeatCount(n)and honour\.accessibilityReduceMotion. - A debug surface needs a testable flag.
-designGallery YESfromsimctllands in the argument domain, so oneUserDefaults.bool(forKey:)read covers it — but wrap it in a small injectable type that is false in Release, or the flag ships. - A scalar threaded by hand through call sites is invisible to grep. In the measured app a wheel's vertical offset was written in three places; two moved onto the shared centre and the third did not, so the drawing and the single-tap hit test disagreed by 20 points with nothing on screen to say so. Pass one geometry value — then a call site that forgets it does not compile.
What is coming (and what already works)
- iOS 26 / Swift 6.2 — today. Liquid Glass (
glassEffect,GlassEffectContainer,.buttonStyle(.glass)),@Entryfor environment tokens with noEnvironmentKeyboilerplate,ToolbarSpacer,backgroundExtensionEffect(),scrollEdgeEffectStyle. - iOS 27 / Swift 6.4 (WWDC26 → 2027).
ContentBuildercollapses the container overloads that cause "unable to type-check this expression in reasonable time" — and it helps when built with the new Xcode regardless of deployment target..reorderable()in any container (not justList), swipe actions outsideList, toolbar overflow priorities,@Stateas a macro with lazy@Observableinit (back-deployed to iOS 17). Resizable iPhone apps is the one that touches a design system directly: baked-in sizes stop being safe, so snapshot at several widths.
Apple's own skills, and the rest of the field
Xcode ships agent skills in the toolchain — plain SKILL.md folders, so they
work in any agent, not only Xcode's assistant.
make apple-skills # solo-factory: export into ~/.agents/skills, diffed
make apple-skills-check # what it would bring, without writing
Xcode 26.6 exports nothing — the agent tool is there and answers "No
skills available to export", so the script says that plainly rather than look
broken. Xcode 27.0 beta 2 exports ten: swiftui-specialist,
swiftui-whats-new-27, uikit-app-modernization, modernize-tests,
audit-xcode-security-settings, adopt-c-bounds-safety, device-interaction,
app-intents-specialist, app-intents-whats-new-27 and
building-document-based-swiftui-applications. The names are not stable across
versions — four of the seven guessed from the 26.6 release notes came back
spelled differently — so read the export rather than a list.
Re-run after every Xcode update: these track the SDK, and a stale "what's new"
skill is worse than none. A beta installed alongside the release is not the
active toolchain, and xcode-select is machine-wide — scope one run instead:
DEVELOPER_DIR="/Applications/Xcode-beta.app/Contents/Developer" make apple-skills.
If the export names the toolchain instead, Xcode → Settings → Locations →
Command Line Tools points at the wrong Xcode.
device-interaction is the one that matters for a design system: it drives a
real device or simulator — screenshots, view hierarchy, synthesised taps — which
closes the loop a build tool alone cannot (write layout → build → look at it →
correct it) without a human running the walk.
Community skills worth reading before installing — a skill is injected into the assistant's context and changes how it writes your code:
| Where | Why |
|---|---|
twostraws/Swift-Agent-Skills |
curated index; start here |
twostraws/SwiftUI-Agent-Skill (swiftui-pro) |
aimed at the mistakes LLMs actually make: navigation, layout, state, VoiceOver, deprecated APIs |
AvdLee/SwiftUI-Agent-Skill |
the architecture to copy — references loaded on demand, so deep context costs nothing until asked for. Also a maintenance skill that refreshes the deprecated-API list after each release |
Dimillian/Skills |
swiftui-liquid-glass, swiftui-view-refactor, swiftui-performance-audit |
dpearson2699/swift-ios-skills |
86 skills on iOS 26+ — PolyForm Perimeter licence, not MIT; read it before commercial use |
Design-system repos worth reading rather than depending on: DSKit (organised for agents — generated docs link every component to its source, snapshots and usage), OversizeUI (semantic colours, Dynamic Type, spacing scale), design-foundation (MIT, Swift 6 concurrency-safe), ouds-ios (corporate scale, strong accessibility).
Don't
- Don't add a design-system framework. Environment + style protocols + the grid API cover it; a framework on top mostly fights the layout system.
- Don't name colours
blue500. Semantic names (surfaceElevated,textSecondary) survive a re-skin; a palette index turns one into a find-and-replace across the app. - Don't fold weight into the type scale.
isSelected ? .semibold : .regularis a step plus.fontWeight()on top — folding it in is how a scale of eight becomes a scale of sixteen. - Don't chase the photo grid onto the interface gutter. A wall of images wants 1–2pt between tiles; keep it on the twelve columns (a tile is a third of the width) and let the spacing be its own.
- Don't ship aliases.
Brand.tabRadius = Radii.lgreads as tidy and puts two spellings of 16pt in one file within a week. - Don't measure a render or a build on a loaded machine. Check
vm.loadavgfirst; three "regressions" in one project were the laptop.