agents/openai.yaml
interface: display_name: "Mac Design" short_description: "Native macOS design rules"
daveebbelaar/bama · GitHub
Design rules that make a SwiftUI app look and feel like a native Mac app on macOS 26 and later, from Apple's eight design principles to Liquid Glass, layout, toolbars, menus, search, keyboard, type, color, and dark mode. Use when building or reviewing any window, sidebar, toolbar, menu, panel, or settings screen, or when the user asks for a more native, Apple-like, or better designed app.
프로젝트 폴더에서 아래 명령어를 실행하고, 설치할 에이전트를 선택하세요.
npx skills add daveebbelaar/bama --skill mac-design설치 명령을 직접 실행해야 적용됩니다. 지원 에이전트와 필요한 권한·라이선스는 제작자의 안내를 확인하세요.
agents/openai.yamlinterface: display_name: "Mac Design" short_description: "Native macOS design rules"
app-shell.md# App shell
The frame of a window with a left sidebar, a right panel, and a top bar on
macOS 26. Field test: LocalChat on macOS 26.5, one chat app rebuilt through
each layout below while its owner reviewed screenshots. Use
[toolbars.md](toolbars.md) for which item goes where.
## Contents
- Pick the right side
- Floating right panel
- Line up the content
- Pick the top bar
- No top bar
- Sidebar rows
- Motion
- Traps
- Review
- Example
## Pick the right side
The system sidebar floats: rounded, inset from the window's edges, with a soft
shadow over the content. A SwiftUI `.inspector` does not. On macOS 26.5 it is a
flat full-height column with a line along its edge, so the two sides look
different.
| | Inspector | Floating panel |
|---|---|---|
| Looks like | A flat column with a line | The sidebar on the left |
| Top bar | Splits at the column line | One bar over the chat and the panel |
| Width | People drag it | Fixed by your code |
| Menu command | `InspectorCommands()` | Your own button and shortcut |
| Color | System | Measured by hand |
Start with the inspector: it is less code, and people can resize it. Use the
floating panel when the two sides must match, as in a chat app or a window that
is on camera. Decide before you build the toolbar, because the choice sets how
the toolbar works.
Inspector rules are in [toolbars.md](toolbars.md), "Columns" and "Toggles". The
main one: put `.inspector` on the `NavigationSplitView`, not on a column.
## Floating right panel
Make the panel a sibling of the content inside the detail column, so the
content narrows when it opens.
- Inset it 8 pt on its three outer sides, with a 20 pt continuous corner radius:
`.padding([.top, .bottom, .trailing], 8)`. Leave no gap on the content's side,
or the content sits 4 pt off center between the panels. The radius matches
the sidebar by eye on macOS 26.5.
- Run it up to the top of the window: `.ignoresSafeArea(.container, edges: .top)`.
- Match the system sidebar's insets, measured on macOS 26.5: the selection pill
starts 10 pt from the panel's edge, and row text 6 pt inside the pill. These
are off the 4 pt grid on purpose, so name them as tokens in `Space`.
- Center the panel's first heading on the text in the sidebar's search field. In
LocalChat that is 59 pt of top padding.
- Build any status row at the bottom as one view, used by both panels. Pin it with
`.safeAreaInset(edge: .bottom)`, 10 pt from the panel's edges, with a 10 pt
corner radius: the panel's 20 minus the inset.
- Use a solid fill measured from the sidebar. Sample a pixel from snapshots with
colored traffic lights, in light and in dark. The sidebar changes when the
window is behind others, so read `@Environment(\.appearsActive)`. LocalChat
uses `0xFAFAFA` and `0x1F2124` in front, and `0xF7F7F7` and `0x2A2C30` behind.
The last one comes from a single sample. Measure yours.
- Add a shadow toward the content, and `.zIndex(1)` so the content does not
cover it: black at 10% in light and 35% in dark, radius 16.
- Set the window's minimum width to the sidebar, plus the content's minimum,
plus the panel. Otherwise the columns slide past the window's edge.
A behind-window material looks wrong here: `NSVisualEffectView` with `.sidebar`
shows the desktop through it, and the glass sidebar does not. `.glassEffect` on
the panel came out grayer than the sidebar.
## Line up the content
Views inside and outside a scroll view must share one edge: the text, the chips,
and the chat box under them.
- Make the scroll bar float. With a mouse attached, a scroll bar takes about 16 pt
of width. Centered content inside the scroll view then moves 8 pt left, while
the chat box outside it stays. It also jumps when a short chat grows long enough
to scroll. Set `scrollerStyle = .overlay` on the enclosing `NSScrollView`, and
set it again when the system's preferred style changes. The example has the code.
- Give the content's lowest control and both status rows one bottom. In LocalChat
that is 18 pt from the window: the panel's 8 plus the row's 10.
- Probe these edges across areas, so the check fails when one moves: `layout-grid`
skill, "Line up across areas".
- Run the snapshots once with `-AppleShowScrollBars Always`. Without a mouse the
default hides this bug.
## Pick the top bar
| Top bar | Use when | How |
|---|---|---|
| Full | The window has real commands to reach | [toolbars.md](toolbars.md) |
| Only the toggles | The window is about its content, and the sidebars can hold the rest | "No top bar", below |
With a floating panel and a full top bar, the bar has one section, so nothing
keeps items off the panel. Add a hidden item before the panel toggle, wide enough
to push the controls clear of the panel. Check it at the minimum width.
## No top bar
The toolbar stays, because it holds the sidebar toggles and lets people drag the
window. What goes away is everything people read as a bar.
1. Keep `.navigationTitle`, because the Window menu reads it. Hide it with
`.toolbar(removing: .title)`.
2. Hide the bar's background: `.toolbarBackgroundVisibility(.hidden, for: .windowToolbar)`.
3. Move the commands. New and search go to the sidebar. Text size and appearance
go to Settings and the View menu. Each one keeps its menu command and shortcut.
4. Run the content to the top of the window, and fade it out under the window
buttons with a mask. The system's scroll edge effect goes off:
`.scrollEdgeEffectHidden(true, for: .top)`.
5. Give the content a top margin, so a short page starts below the fade.
Without the sidebar, the fade alone keeps text clear of the traffic lights. Check
a window with no sidebar, scrolled so that text sits under them. In LocalChat the
fade is clear for the top 28 pt and solid at 60 pt.
The panel toggle needs `ToolbarSpacer(.flexible)` in front of it. As a lone item
it sits at the column's leading edge, over the content. On the panel it is an
icon with no capsule, like the sidebar toggle. With the panel closed it gets its
capsule back: `.sharedBackgroundVisibility(isOpen ? .hidden : .automatic)`.
## Sidebar rows
- Search: `.searchable(text:placement: .sidebar)`. The system places and styles
the field. `.searchFocused` connects it to Command-F, and Command-F also opens
a collapsed sidebar.
- New: a plain row in `.safeAreaInset(edge: .top)` on the list. It sits under the
search field and does not scroll with the list.
- Status: `.safeAreaInset(edge: .bottom)` on the list.
- A row's own actions (Delete): show them on hover and on the selected row, with
the same actions in the context menu.
## Motion
Both sides should open at the same speed. The sidebar's animation is the system's,
and one screen recording timed it at about 0.45 s. A `.snappy(duration: 0.2)` panel
felt visibly faster, and people notice. Use `.smooth(duration: 0.45)` with
`.move(edge: .trailing)` and no fade, and respect Reduce Motion. The 0.45 s comes
from one noisy recording, so compare the two sides by eye.
## Traps
| You see | Cause | Fix |
|---|---|---|
| Toolbar items cross the panel's line | `.inspector` sits on a column, not on the split view | Move it to the `NavigationSplitView` |
| A line runs beside the right panel | `.inspector` draws it, and covering it with a view does not hold | Use the floating panel |
| A line runs under the top bar | `.scrollEdgeEffectStyle(.hard, for: .top)` | Use the default style, or hide the effect |
| The content slides past the window's edge | Column minimums add up to more than the window | Raise the window's minimum width |
| A custom toolbar item is not there | A toolbar hides a `Button` with a custom label | Use a plain view with `.onTapGesture` and `.accessibilityAction` |
| The panel toggle shows twice | A second toggle in the main toolbar, while the first stays with the closed inspector | One toggle only |
| Text sits 8 pt left of the chat box | The scroll bar takes width | Make the scroll bar float |
| Content sits 4 pt off center between the panels | The floating panel has a gap on the content's side | Inset only its three outer sides |
| Section headers start 2 pt left of the rows | The system places them there | Add 2 pt of leading padding to the header's text |
| The status row sticks out past the selection pill | An 8 pt inset, where the pill uses 10 | Use the sidebar's measured inset |
| Two "Chat" menus in the menu bar | A `CommandMenu` has the app's name | Name it something else |
## Review
Take a snapshot of each state, in light and in dark, as the key window:
- Left and right panels share shape, inset, shadow, and color, in front and
behind other windows.
- The content's left and right edges match the chat box's, with scroll bars
forced on. Its column is centered between the panels.
- Both panels open, each one closed, and both closed. Each toggle appears once
and stays over its own panel.
- The window at its minimum width, with both panels open.
- The biggest text size, if the app has one.
- A window with no sidebar, scrolled, with text under the traffic lights.
- No line under the top bar, and none beside a panel, unless you chose the
inspector.
## Example
Typechecked against the macOS 26.5 SDK. `Color(light:dark:)` and `Space` come
from the template's `UI/Theme.swift` and `UI/Grid.swift`.
```swift
struct ContentView: View {
@State private var showPanel = true
@State private var search = ""
@Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View {
NavigationSplitView {
List { Text("First chat") }
.searchable(text: $search, placement: .sidebar, prompt: "Search")
.safeAreaInset(edge: .top, spacing: 0) {
Button("New Chat", systemImage: "square.and.pencil") {}
.buttonStyle(.plain)
.padding(.horizontal, Space.md)
.padding(.bottom, Space.xs)
}
} detail: {
HStack(spacing: 0) {
page
.frame(minWidth: 420)
if showPanel {
Text("Panel")
.frame(maxWidth: .infinity, maxHeight: .infinity)
.modifier(FloatingPanel(width: 260))
.transition(.move(edge: .trailing))
.zIndex(1)
}
}
.animation(reduceMotion ? nil : .smooth(duration: 0.45), value: showPanel)
.navigationTitle("First chat") // for the Window menu
.toolbar(removing: .title)
.toolbarBackgroundVisibility(.hidden, for: .windowToolbar)
.toolbar {
ToolbarSpacer(.flexible) // a lone item would sit at the leading edge
ToolbarItem {
Button(showPanel ? "Hide Panel" : "Show Panel", systemImage: "sidebar.trailing") {
showPanel.toggle()
}
.help("Show or hide the panel (Control-Command-I)")
}
.sharedBackgroundVisibility(showPanel ? .hidden : .automatic)
}
}
.frame(minWidth: 1000, minHeight: 520) // sidebar + content + panel
}
private var page: some View {
ScrollView {
Text("The conversation")
.frame(maxWidth: .infinity)
.floatingScrollBar()
}
.contentMargins(.top, 36, for: .scrollContent)
.mask { // text fades out under the window buttons
VStack(spacing: 0) {
LinearGradient(stops: [.init(color: .clear, location: 0.47), .init(color: .black, location: 1)],
startPoint: .top, endPoint: .bottom)
.frame(height: 60)
Color.black
}
}
.ignoresSafeArea(.container, edges: .top)
.scrollEdgeEffectHidden(true, for: .top)
}
}
/// Matches the system sidebar: inset, rounded, with a soft shadow over the content.
struct FloatingPanel: ViewModifier {
@Environment(\.colorScheme) private var colorScheme
@Environment(\.appearsActive) private var appearsActive
let width: CGFloat
func body(content: Content) -> some View {
let shape = RoundedRectangle(cornerRadius: 20, style: .continuous)
content
.frame(width: width)
.frame(maxHeight: .infinity)
.background(appearsActive ? Color(light: 0xFAFAFA, dark: 0x1F2124) : Color(light: 0xF7F7F7, dark: 0x2A2C30),
in: shape) // measure your own
.overlay(shape.strokeBorder(.primary.opacity(colorScheme == .dark ? 0.12 : 0.04), lineWidth: 1))
.shadow(color: .black.opacity(colorScheme == .dark ? 0.35 : 0.1), radius: 16, x: -2, y: 0)
.padding([.top, .bottom, .trailing], Space.sm) // no gap on the content's side
.ignoresSafeArea(.container, edges: .top)
}
}
extension View {
/// A scroll bar that takes no width. Put it on the content inside a ScrollView.
func floatingScrollBar() -> some View { background(FloatingScrollBarHook()) }
}
private struct FloatingScrollBarHook: NSViewRepresentable {
func makeNSView(context: Context) -> Probe { Probe() }
func updateNSView(_ probe: Probe, context: Context) { probe.apply() }
final class Probe: NSView {
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
NotificationCenter.default.removeObserver(self)
guard window != nil else { return }
// The system resets the style when the person plugs in a mouse or changes the setting.
NotificationCenter.default.addObserver(self, selector: #selector(styleChanged),
name: NSScroller.preferredScrollerStyleDidChangeNotification, object: nil)
DispatchQueue.main.async { [weak self] in self?.apply() }
}
@objc private func styleChanged(_ note: Notification) {
DispatchQueue.main.async { [weak self] in self?.apply() }
}
func apply() {
guard let scrollView = enclosingScrollView, scrollView.scrollerStyle != .overlay else { return }
scrollView.scrollerStyle = .overlay
}
}
}
```
design-rules.md# Design rules with sources
A checklist for reviewing a macOS SwiftUI app. Each rule is testable on a
snapshot or in the code. Sources are listed at the bottom.
## Structure
1. Write the archetype, sidebar pages, command map, and empty states before building. [petekp]
2. Use standard structures first: `NavigationSplitView`, `.toolbar`, `.searchable`, `.inspector`, `Settings`. They adopt Liquid Glass automatically. [WWDC25-323]
3. Global actions in the toolbar, navigation in the sidebar, content in the center. [Kole Jain, HIG]
4. No sidebar when navigation is one level. Give the width to content. [Kole Jain]
5. Sidebar: at most two levels, an SF Symbol per item, hideable, nothing critical at the bottom. [HIG Sidebars]
6. Selection details in a trailing inspector or detail column. [Kole Jain, WWDC25-323]
7. Design the entry points outside the main window: quick-capture panel, menu bar extra, global shortcut, drag and drop. [Kole Jain]
8. Quick-capture panels have no title bar, one input, and buttons labelled with their shortcuts. Escape closes, Return saves. [Kole Jain]
## Glass and materials
9. Glass only on the navigation and control layer. Never on rows, cards, or document content. Exception: a Spotlight-style panel is all control layer, so its results may sit on its glass. [HIG Materials, WWDC25-219]
10. Never glass on glass. Group neighboring custom glass in `GlassEffectContainer`. [WWDC25-323]
11. Remove custom backgrounds, borders, and custom dimming behind toolbars, sidebars, and sheets. macOS dims the parent window behind a sheet itself; keep that. [WWDC25-356, HIG Sheets]
12. Regular glass by default. Clear glass only over rich media, with a 35% dark dimming layer over bright content and none over dark content. Never mix the two. Background areas in the content layer use standard materials (`.regularMaterial`) with vibrant colors, not glass. [HIG Materials]
13. Tint only to carry meaning, and tint the background, not the glyph. [HIG Color]
14. Nested corners are concentric: inner radius equals outer radius minus padding. A part used both nested and alone gets `ConcentricRectangle` (or `.rect(corners: .concentric(minimum:))`) so it never loses its rounding. Mini, small, and medium controls are rounded rectangles; large controls are capsules. Capsules in dense layouts only for a standout action. [WWDC25-356, WWDC26-289]
15. One scroll-edge effect per pane, only where floating UI overlaps content. Keep the automatic style (it already turns more opaque over dense content). Panes side by side get edge effects of the same height. [WWDC25-356, HIG Scroll views]
## Toolbar and menus
16. At most three toolbar groups, borderless monochrome SF Symbols with `.help`, at most one prominent action (Done, Send) on the trailing side. A text button next to symbol buttons gets a fixed `ToolbarSpacer`. Placement per column: toolbars.md. [HIG Toolbars]
17. Every toolbar item and feature has a menu bar command. Standard menu order. Items stay visible and get disabled when they do not apply. [HIG Menu bar]
18. Every frequent command has a shortcut. Standard shortcuts are never repurposed. [HIG Keyboards]
19. Window titles name the content, stay under 15 characters, and are not the app name. [HIG Toolbars]
20. Menu icons are few and on purpose: only on key actions, all or none within a group, and none when no symbol clearly fits. In a group of related items (Copy, Copy Link, Copy as Markdown) only the first gets a symbol. In SwiftUI menu bar commands an item shows its icon only with `.labelStyle(.titleAndIcon)`. [HIG Menus, WWDC26-269]
## Keyboard and feedback
21. Every shortcut gives visible feedback in the same frame. [Kole Jain]
22. Return is the default action, Escape cancels, Delete removes, Space previews, arrows move. [HIG Keyboards, ehmo]
23. Logical focus order with `@FocusState`. New items get focus. [kageroumado]
24. Shortcuts are taught: menus, `.help` tooltips (they describe the action and end with the shortcut), a shortcut list in Settings or Help, and a short first-run hint for the one shortcut that matters most. [Kole Jain]
25. Optimistic UI with Undo, not spinners and confirmation dialogs. [Kole Jain]
## Content, type, and color
26. The UI is a container for content. Remove decoration that does not help the content. [Kole Jain]
27. Progressive disclosure: empty states invite the first action, filters and metadata appear when there is content. [Kole Jain]
28. Semantic colors only. One accent. Status color always paired with an icon or a word. Light and dark follow the Mac by default. Apple advises against an app-only appearance setting; add one only when the app's owner asks for it, with System as the default. [HIG Color]
29. macOS text styles, no iOS sizes. Minimum 10 pt. [HIG Typography]
| Style | Size / leading | Weight |
|---|---|---|
| Large Title | 26 / 32 | Regular |
| Title 1, 2, 3 | 22 / 26, 17 / 22, 15 / 20 | Regular |
| Headline | 13 / 16 | Bold (same size as Body) |
| Body | 13 / 16 | Regular |
| Callout, Subheadline | 12 / 15, 11 / 14 | Regular |
| Footnote, Caption 1 | 10 / 13 | Regular |
| Caption 2 | 10 / 13 | Medium |
No Ultralight, Thin, or Light weights in UI text. Text of three lines or more never gets tight leading.
30. Dark mode is its own palette with more tonal separation. Check both modes. [Kole Jain]
31. `Label` for icon and text. SF Symbols for icons. [Optical Flow design system]
32. Spacing: 20 pt window margins, 8 pt between related controls, 20 pt between groups. [ehmo]
## Interaction
33. `.contextMenu` on every actionable item. Hover states on custom controls. [kageroumado]
34. No 44 pt touch targets. Control heights come from `.controlSize`. Mac controls default to 28 x 28 pt, with a 20 x 20 pt minimum hit area. Leave about 12 pt around bezeled controls and about 24 pt around borderless ones, or give a borderless icon button a 28 pt hit area. [WWDC25-310, HIG Accessibility]
35. Drag in and out with `Transferable`, plus `ShareLink`. [Kole Jain]
36. Search in the trailing toolbar, live as you type, scope in the placeholder, result count visible. [HIG Search, Kole Jain]
37. Settings opens with Command-comma from the app menu. Few options. [HIG Settings]
38. Windows resize with sensible minimums and remember their frame. [HIG Windows]
39. Reduce Motion, Reduce Transparency, and VoiceOver labels on icon-only buttons. [WWDC25-219]
40. Customize on purpose. Stock UI is the baseline. Restrained custom chrome is fine when it makes the app better at its one job. [Brent Simmons 2026]
## Contrast and safety
41. Accent-colored text uses a light shade in dark mode and a deep shade in light mode, from one token. Text and symbols on an accent fill use the on-accent color, never a fixed white. [field test]
42. Hover and press states never lower contrast. Text over user content (screenshots, video) sits on a dark scrim or capsule. [field test]
43. Every one-click destructive action, in lists and in Settings, offers Undo in place. [field test]
## Toolbar and panels
44. The toolbar follows the columns: the sidebar button over the sidebar, actions and search over the list, the inspector button at the trailing edge over the inspector. No toolbar item crosses a column edge, and every panel can fold away with a button and a shortcut. Recipe: patterns.md, "Toolbar that follows the columns". When the left and right panels must match, or there is no top bar: app-shell.md. [field test]
45. Lists support multi-selection, and actions apply to every selected row. After an action removes the selected row, selection moves to the next row. [review]
46. Never ship always-enabled menu items that do nothing; fix the focused-value plumbing instead. Taking over Command-S or Command-O is allowed only in apps without documents, and is noted in the command map. [review]
## Consistency
47. Show each status once per row (one overdue chip, not a date chip and a "due" chip). [review]
48. Date fields share one control style and the list's date format. Values on the trailing edge of a form line up. [review]
49. What a user drags out of the app is meaningful (title text or a link), never an internal id. [review]
50. An on-accent token that is a fixed white fails rule 41 on light accents. Check 4.5:1 contrast in both modes as the floor; aim for 7:1 for small custom text. [review, HIG Dark Mode]
## Windows, focus, and scrolling
51. No control is focused when a window or sheet opens: no focus ring, no cursor, no selected text (`.noInitialFocus()`). Only a window whose one job is typing focuses its field. [field test]
52. Sheets, popovers, and Settings tabs size to their content. A scroll bar appears only when there is more to scroll. [field test]
53. System scroll bars, never restyled. They follow the "Show scroll bars" setting. [HIG Scroll views]
54. Settings: tabs by topic, one grouped Form per tab, 520 pt wide, applied at once, labels as statements. Recipe: settings.md. [HIG Settings, field test]
## Principles and hierarchy
55. Review every screen against Apple's eight principles: purpose, agency, responsibility, familiarity, flexibility, simplicity, craft, delight. Each feature costs people time, attention, and trust; cut what does not earn it. [HIG Design principles, WWDC26-250]
56. Every screen answers three questions at a glance: Where am I? What can I do? Where can I go? Squint at the snapshot: the most important thing is still the most visible. [WWDC25-359]
57. Simple is not minimal. Hiding the main actions in one More menu looks clean but is harder to use. [WWDC26-250]
58. Delight is the sum of the other seven, aimed at one feeling picked in the brief (calm, confident, quick). Never confetti added at the end. [WWDC26-250]
59. Navigation items are for navigation. No "New..." or other action rows in the sidebar list; actions go in the toolbar and menus. [WWDC25-359]
60. A list for few items or long text, a grid for many images with little text. Group big collections by time (Recent), progress (In Progress, Drafts), or relation (Related). [WWDC25-359]
## Glass, shape, and color (macOS 27)
61. Interactive glass (the bounce on click) only on controls or a group of controls, and sparingly. Apply glass to the control itself, never to the views inside it. [WWDC26-289, WWDC25-356]
62. Custom sidebar and toolbar parts dim when the window is inactive, like the system ones. Read `@Environment(\.appearsActive)`. Not for HUDs and non-activating panels: they never make the app active, so they would stay dim. [WWDC26-269, WWDC25-219]
63. At rest (first launch, top of the scroll) no content sits under glass. Extend rich backgrounds under the sidebar and inspector with `.backgroundExtensionEffect()`, and keep text and controls above it. [WWDC25-219, WWDC25-356, HIG Layout]
64. No controls or critical information at the bottom edge of a window. A bottom status bar holds only small facts about the window (a count, free space). [HIG Layout, HIG Windows]
65. Use the standard symbol for a standard action: Copy `document.on.document`, Paste `document.on.clipboard`, Duplicate `plus.square.on.square`, Rename `pencil`, Move `folder`, Filter `line.3.horizontal.decrease`, Share `square.and.arrow.up`, Delete `trash`, More `ellipsis`, Compose `square.and.pencil`, Archive `archivebox`. The trash icon means delete and nothing else. Where no symbol is unambiguous, use a text label. Match the symbol weight to the text next to it. [HIG Icons, WWDC26-250, WWDC25-356]
66. Never restyle or redefine semantic colors (no `separator` as text, no label color as a background), and never copy their values: they change per release. [HIG Color]
## Menus and keyboard
67. The menu bar follows Apple's order and labels. Window menu even with one window, Show/Hide labels that follow the state, Undo that names the action, "New <Noun>". Full list: [menus.md](menus.md). [HIG The menu bar]
68. Context menus hide items that do not apply (Cut, Copy, and Paste may stay dimmed), show no shortcuts, keep about three groups with the most frequent first, and go at most one submenu deep. Every context menu command also lives in the menu bar. Exception: a menu bar app's status item menu is its menu bar, so it shows the person's global shortcuts. [HIG Context menus]
69. Standard keys: Option-Command-F jumps to the search field, Command-Period cancels an operation or an alert, Escape closes. Never add a modifier to an existing shortcut for an unrelated command (Shift-Command-Z is only Redo). Avoid Option or Shift with punctuation keys; they move on other keyboard layouts. [HIG Keyboards]
70. A push button whose action opens another window or view ends in an ellipsis ("Export..."). At most one or two prominent buttons per view. Switches, checkboxes, and radio buttons never go in a toolbar. [HIG Buttons, HIG Toggles]
## Search, tables, and drag
71. Where search sits tells people what it searches: the trailing toolbar searches the whole window, the top of the sidebar filters the sidebar, and a Search sidebar item gives a big multi-section app room for results. One search location per app. [HIG Searching, WWDC26-292]
72. Search starts broad and narrows: a scope bar (`.searchScopes`, "All" vs the current place), suggestions while typing (`.searchSuggestions`, a few), recent searches people can remove one by one or clear, and tokens only next to visible filters. No results: `ContentUnavailableView.search(text:)`, so the query and its typos show. [WWDC26-292, HIG Search fields]
73. Multicolumn tables: click a header to sort, click again to reverse; columns resize; rows alternate in color; headers are nouns in title case; names that differ at the end truncate in the middle. Outlines save which rows are open across launches, and Option-click opens a row and everything under it. Recipe: patterns.md, "Sortable table". [HIG Lists and tables, HIG Outline views]
74. Drag and drop works everywhere and has a menu alternative. A drag to Finder makes a real file the app can open again. A drag of several items shows a count badge. Option at drop time copies. Dropped content stays selected, and a drop can be undone. [HIG Drag and drop]
## Windows, panels, and launch
75. Relaunch restores windows (frame, minimized, full screen), the selection, and the scroll position (`@SceneStorage`). Save UI state only, never data. Quit never asks unless typed work would be lost. [HIG Launching, WWDC26-289]
76. No splash screen and no onboarding tour. Teach with a contextual tip (TipKit) for a feature of three steps or fewer, at most about one tip a day. A skipped tutorial never shows again and stays reachable from Help. [HIG Onboarding, WWDC26-250]
77. Sidebar: never hidden by default, folds away on its own when the window gets narrow, and its rows follow the system sidebar icon size (no fixed row height). Big lists get disclosure groups; let people reorder or hide items when the list is long. [HIG Sidebars]
78. "Open in New Window" in the context menu or File menu. Do not open new windows by default. Say "window" in the UI, never "scene". [HIG Windows]
79. A time-limited view (a toast that hides itself) is never the only way to act. An Undo toast also has Edit > Undo, Command-Z. An error never lives only in a toast, and every toast is announced to VoiceOver. A menu bar app (LSUIElement) has Edit > Undo only while it switches to `.regular` with a window open. [HIG Accessibility]
80. Progress: determinate when the length is known, and switch from a spinner to a bar never mid-task. The text says what is happening ("Importing 12 photos"), not "Loading". Offer Cancel when it is safe. [HIG Progress indicators]
## Sources
- Kole Jain, "Everything you need to Design macOS Apps EXACTLY like Apple (beginner friendly)", YouTube, December 2025: https://www.youtube.com/watch?v=Vy0KKvZJRH8
- Apple Human Interface Guidelines, Designing for macOS: https://developer.apple.com/design/human-interface-guidelines/designing-for-macos
- HIG Materials, Color, Typography, Sidebars, Toolbars, Menus, Context menus, Scroll views, Sheets, Search fields, Searching, Settings, The menu bar, Keyboards, Icons, Buttons, Toggles, Layout, Windows, Lists and tables, Outline views, Drag and drop, Launching, Onboarding, Accessibility, Progress indicators: https://developer.apple.com/design/human-interface-guidelines
- HIG Design principles (June 2026): https://developer.apple.com/design/human-interface-guidelines/design-principles
- WWDC25-219 Meet Liquid Glass: https://developer.apple.com/videos/play/wwdc2025/219/
- WWDC25-356 Get to know the new design system: https://developer.apple.com/videos/play/wwdc2025/356/
- WWDC25-323 Build a SwiftUI app with the new design: https://developer.apple.com/videos/play/wwdc2025/323/
- WWDC25-310 Build an AppKit app with the new design: https://developer.apple.com/videos/play/wwdc2025/310/
- WWDC26-269 What's new in SwiftUI: https://developer.apple.com/videos/play/wwdc2026/269/
- WWDC26-250 Principles of great design: https://developer.apple.com/videos/play/wwdc2026/250/
- WWDC26-292 Design intuitive search experiences: https://developer.apple.com/videos/play/wwdc2026/292/
- WWDC26-289 Modernize your AppKit app: https://developer.apple.com/videos/play/wwdc2026/289/
- WWDC25-359 Design foundations from idea to interface: https://developer.apple.com/videos/play/wwdc2025/359/
- Brent Simmons, inessential.com, September 2026: https://inessential.com/2026/09/22/that-about-wraps-it-up-for.html
- petekp/agent-skills `macos-app-design`, kageroumado/swiftui-macos-skill, ehmo/platform-design-skills (GitHub)
- Optical Flow and Pulse design systems (Dave Ebbelaar's own apps)
menus.md# Menus
The menu bar, context menus, and the Dock menu, from the HIG (The menu bar,
Menus, Context menus, Dock menus, June 2026). Every command in the app
lives in the menu bar, even rare ones: Full Keyboard Access and Help >
Search reach it there.
A menu bar app (LSUIElement) has a main menu only while one of its windows
is open. Its status item menu plays the menu bar's role and may show the
person's global shortcuts. Its main menu needs App (with Settings...),
Edit, Window, and Help.
## Contents
- Menu bar order
- Labels
- Icons
- Context menus
- Dock menu
- SwiftUI
## Menu bar order
App, File, Edit, Format, View, your own menus, Window, Help. Your own menus
follow the app's structure, most general first (Mail: Mailbox, Message).
| Menu | What goes there |
|---|---|
| App | About <App> (name of 16 characters or fewer, no version), Settings... (Command-comma), app items, Services, Hide, Hide Others, Show All, Quit |
| File | New <Noun> ("New Event", never just "New"), Open..., Open Recent (names, not paths, plus Clear Menu), Close, Duplicate rather than Save As, Rename..., Move To..., Export As... only for other formats. An app without files renames or drops File |
| Edit | Undo and Redo that name the action ("Undo Typing", "Redo Paste"), Cut, Copy, Paste, Delete (never Erase or Clear), Select All, Find. The system adds Dictation and Emoji |
| View | Show/Hide Sidebar, Show/Hide Toolbar, Show/Hide Inspector, Enter Full Screen. Keep View even when it only holds Enter Full Screen |
| Window | Minimize, Zoom, Bring All to Front, then open windows in alphabetical order. Keep it with one window. No panels in the list. Zoom never enters full screen |
| Help | <App> Help, then a few items after a separator |
## Labels
- A verb or verb phrase in title case, no articles: "Add Bookmark".
- An ellipsis when the command asks for more input before it acts
("Rename...", "Export As...").
- A toggled item changes its label with the state ("Show Map", "Hide Map").
Add a verb when the noun alone is unclear ("Turn Grid On").
- A checkmark marks an attribute that is on (Bold). Offer one item that
clears several (Plain).
- A submenu when one word repeats in more than two items ("Sort By"). One
level deep, about five items. A submenu stays enabled even when all its
items are not.
- Items that do not apply stay visible and get disabled in the menu bar.
- Option-key (dynamic) items: one modifier, menu bar only, never the only
way to do something.
## Icons
- Few and on purpose: key actions, key features, places, devices, and
visual ideas (rotate, flip).
- All items in a group get an icon, or none do. In a group of related items
(Copy, Copy Link, Copy as Markdown) only the first gets the symbol.
- No icon when no symbol clearly fits. Use the standard symbols from
design-rules.md rule 65.
- In SwiftUI menu bar commands an item shows its icon only with
`.labelStyle(.titleAndIcon)` (WWDC26-269).
## Context menus
- Hide items that do not apply. Cut, Copy, and Paste may stay dimmed.
- No keyboard shortcuts shown. They belong in the menu bar.
- About three groups, the most frequent first, at most one submenu level.
- A title only when it adds something, such as "3 Items Selected".
- Every command here also lives in the menu bar.
- Offer context menus the same way everywhere an item can be acted on.
## Dock menu
A few high-value items for when the app is not in front (Mail: Get New
Mail, New Message), plus the open windows the system adds. Each item also
exists elsewhere in the app.
```swift
final class AppDelegate: NSObject, NSApplicationDelegate {
func applicationDockMenu(_ sender: NSApplication) -> NSMenu? {
let menu = NSMenu()
menu.addItem(withTitle: "New Task", action: #selector(newTask), keyEquivalent: "")
return menu
}
@objc private func newTask() { /* same action as File > New Task */ }
}
```
## SwiftUI
```swift
.commands {
SidebarCommands() // View > Show Sidebar, Control-Command-S
ToolbarCommands() // View > Show Toolbar, Customize Toolbar
CommandGroup(replacing: .newItem) {
Button("New Task") { model.addTask() }
.keyboardShortcut("n")
}
CommandGroup(after: .sidebar) {
// A label that follows the state.
Button(model.showsCompleted ? "Hide Completed" : "Show Completed") {
model.showsCompleted.toggle()
}
.keyboardShortcut("h", modifiers: [.command, .shift])
}
}
```
- A `Window` or `WindowGroup` scene gets the Window and Help menus from the
system. Keep them.
- Undo names the action: `undoManager?.setActionName("Add Task")` after
registering the undo. The Edit menu then reads "Undo Add Task".
Done when the menu bar follows the order above, every toolbar and context
menu command has a menu bar item, and no context menu shows a shortcut or a
disabled item other than Cut, Copy, and Paste.
patterns.md# SwiftUI patterns for macOS
Code that compiles on macOS 26 with Swift 6.2 and main-actor default isolation
(the `new-mac-app` template). `Item` is the template's SwiftData model. Rename it to yours.
Imports used across these snippets:
```swift
import AppKit
import Carbon.HIToolbox
import SwiftData
import SwiftUI
import UniformTypeIdentifiers
```
## Global shortcut (no Accessibility permission needed)
Carbon's hot key API still works on macOS 26 and needs no permission. Keep the `GlobalHotKey` object alive (store it on the app delegate), or the shortcut stops working.
```swift
/// Calls `action` when the user presses the shortcut anywhere in macOS.
/// Example: GlobalHotKey(key: kVK_Space, modifiers: [.option]) { panel.toggle() }
final class GlobalHotKey {
private var ref: EventHotKeyRef?
private var handler: EventHandlerRef?
private let action: () -> Void
init(key: Int, modifiers: NSEvent.ModifierFlags, action: @escaping () -> Void) {
self.action = action
var carbon: UInt32 = 0
if modifiers.contains(.command) { carbon |= UInt32(cmdKey) }
if modifiers.contains(.option) { carbon |= UInt32(optionKey) }
if modifiers.contains(.control) { carbon |= UInt32(controlKey) }
if modifiers.contains(.shift) { carbon |= UInt32(shiftKey) }
var spec = EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed))
let me = Unmanaged.passUnretained(self).toOpaque()
InstallEventHandler(GetApplicationEventTarget(), { _, _, data in
guard let data else { return noErr }
let hotKey = Unmanaged<GlobalHotKey>.fromOpaque(data).takeUnretainedValue()
MainActor.assumeIsolated { hotKey.action() }
return noErr
}, 1, &spec, me, &handler)
RegisterEventHotKey(UInt32(key), carbon, EventHotKeyID(signature: 0x4150_5031, id: 1), GetApplicationEventTarget(), 0, &ref)
}
isolated deinit {
if let ref { UnregisterEventHotKey(ref) }
if let handler { RemoveEventHandler(handler) }
}
}
```
## Quick-capture panel
A Spotlight-style panel for the app's most frequent action. Pair it with `GlobalHotKey` above. The field uses glass because the panel floats over other apps.
```swift
/// A floating panel without a title bar, like Spotlight. Escape closes it.
final class QuickCapturePanel: NSPanel {
init<Content: View>(@ViewBuilder content: () -> Content) {
super.init(
contentRect: NSRect(x: 0, y: 0, width: 560, height: 64),
styleMask: [.nonactivatingPanel, .borderless, .fullSizeContentView],
backing: .buffered,
defer: false
)
isOpaque = false
backgroundColor = .clear
hasShadow = true
isMovableByWindowBackground = true
level = .floating
hidesOnDeactivate = true
contentView = NSHostingView(rootView: content())
}
override var canBecomeKey: Bool { true }
override func cancelOperation(_ sender: Any?) { orderOut(nil) }
func toggle() {
if isVisible { orderOut(nil); return }
center()
makeKeyAndOrderFront(nil)
NSApp.activate()
}
}
struct QuickCaptureField: View {
@State private var text = ""
@FocusState private var focused: Bool
let onSubmit: (String) -> Void
var body: some View {
TextField("Add a task, like \"Call mom tomorrow at 5pm\"", text: $text)
.textFieldStyle(.plain)
.font(.title3)
.focused($focused)
.padding(.horizontal, 20)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.glassEffect(.regular, in: .rect(cornerRadius: 16))
.onAppear { focused = true }
.onSubmit {
let title = text.trimmingCharacters(in: .whitespaces)
guard !title.isEmpty else { return }
onSubmit(title) // optimistic: the caller saves, the panel closes right away
text = ""
}
}
}
```
## Commands that reach the focused window
Menu commands act on whatever the focused window has selected. Add `.commands { ItemCommands() }` to the scene and `.focusedSceneValue(\.selectedItem, ...)` in the view.
```swift
struct SelectedItemKey: FocusedValueKey { typealias Value = Item }
extension FocusedValues {
var selectedItem: Item? {
get { self[SelectedItemKey.self] }
set { self[SelectedItemKey.self] = newValue }
}
}
struct ItemCommands: Commands {
@FocusedValue(\.selectedItem) private var item
var body: some Commands {
CommandMenu("Item") {
Button(item?.isStarred == true ? "Unstar" : "Star", systemImage: "star") {
item?.isStarred.toggle()
}
.keyboardShortcut("s", modifiers: [.command, .shift])
.disabled(item == nil)
}
}
}
```
## Toolbar that follows the columns
A window with a sidebar, a list, and an inspector has three toolbar areas,
one over each column. Each button sits over the column it acts on. For a right
panel that floats like the sidebar, use [app-shell.md](app-shell.md) instead:
```text
[traffic lights] [sidebar] | Title [+] [Search...] | [inspector]
over the sidebar | over the list | over the inspector
```
- The sidebar button comes with `NavigationSplitView` at the sidebar's
trailing edge.
- Put `.inspector` on the `NavigationSplitView`, not inside the detail
view. The inspector is then a full-height column with its own toolbar
area. Inside the detail view it shares the detail's toolbar, and items
slide over the inspector.
- The inspector button goes in the inspector's own `.toolbar`, after a
flexible spacer. SwiftUI keeps it at the window's trailing edge while the
inspector is hidden. Do not add a second button to the detail toolbar:
it then shows twice.
- `.searchable(placement: .toolbar)` always moves to the window's trailing
edge, over the inspector. To keep search over the list, put a native
`NSSearchField` in a detail `ToolbarItem` (fixed width about 220 pt).
- Add `InspectorCommands()` to the scene's `.commands`: View > Show
Inspector, Control-Command-I. No second inspector command elsewhere.
```swift
NavigationSplitView {
Sidebar()
} detail: {
ListPage()
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button("New Item", systemImage: "plus") { model.add() }
}
ToolbarItem(placement: .primaryAction) {
ToolbarSearchField(text: $model.searchText, prompt: "Search") // NSViewRepresentable around NSSearchField
}
}
}
.inspector(isPresented: $model.showInspector) {
InspectorView()
.inspectorColumnWidth(min: 240, ideal: 280, max: 360)
.toolbar {
ToolbarSpacer(.flexible)
ToolbarItem(placement: .primaryAction) {
Button { model.showInspector.toggle() } label: {
Label(model.showInspector ? "Hide Inspector" : "Show Inspector",
systemImage: "sidebar.trailing")
}
.help("Show or hide the inspector (Control-Command-I)")
}
}
}
```
Check both states in a real window capture (inspector open and closed),
and once at the window's minimum width: nothing overlaps the column edge,
each button appears once, and search is still reachable (toolbars.md,
"Title and search").
## Search, inspector, keyboard, context menu, feedback
One list that shows search with a result count, an inspector, the Delete key, a context menu, and a symbol animation as feedback.
```swift
struct SearchableList: View {
@Environment(\.modelContext) private var context
@Query(sort: \Item.createdAt, order: .reverse) private var items: [Item]
@State private var query = ""
@State private var selection: Item.ID?
@State private var showInspector = true
private var results: [Item] {
query.isEmpty ? items : items.filter {
$0.title.localizedStandardContains(query) || $0.notes.localizedStandardContains(query)
}
}
var body: some View {
List(results, selection: $selection) { item in
Label(item.title, systemImage: item.isStarred ? "star.fill" : "circle")
.contentTransition(.symbolEffect(.replace)) // visible feedback when starring
.tag(item.id)
.contextMenu {
Button(item.isStarred ? "Unstar" : "Star", systemImage: "star") {
withAnimation { item.isStarred.toggle() }
}
Divider()
Button("Delete", systemImage: "trash", role: .destructive) { context.delete(item) }
}
}
.searchable(text: $query, placement: .toolbar, prompt: "Search items")
.navigationSubtitle(query.isEmpty ? "" : "\(results.count) results")
.onDeleteCommand { // the Delete key and Edit > Delete
if let item = results.first(where: { $0.id == selection }) { context.delete(item) }
}
.focusedSceneValue(\.selectedItem, results.first { $0.id == selection })
.inspector(isPresented: $showInspector) {
Text("Details of the selection")
.inspectorColumnWidth(min: 220, ideal: 280)
}
.toolbar {
ToolbarItem {
Button("Inspector", systemImage: "sidebar.trailing") { showInspector.toggle() }
.help("Show or hide details (Control-Command-I)")
// Same key as InspectorCommands(). Use one or the other, not both.
.keyboardShortcut("i", modifiers: [.command, .control])
}
}
}
}
```
## Search with scopes, suggestions, and recent searches
Search starts broad, a scope bar narrows it, recent searches show before
typing with a way to clear them, and no results shows the query
(design-rules.md rules 71 and 72).
```swift
enum SearchScope: String, CaseIterable, Identifiable {
case all = "All Items", starred = "Starred"
var id: Self { self }
}
struct SearchPage: View {
@Query(sort: \Item.createdAt, order: .reverse) private var items: [Item]
@State private var query = ""
@State private var scope = SearchScope.all
@AppStorage("recentSearches") private var recentStore = ""
private var recent: [String] { recentStore.split(separator: "\n").map(String.init) }
private var results: [Item] {
items.filter { item in
(scope == .all || item.isStarred)
&& (query.isEmpty || item.title.localizedStandardContains(query))
}
}
var body: some View {
List(results) { item in Text(item.title) }
.overlay {
if !query.isEmpty && results.isEmpty {
ContentUnavailableView.search(text: query) // shows the query, so typos show
}
}
.searchable(text: $query, placement: .toolbar, prompt: "Search items")
.searchScopes($scope) {
ForEach(SearchScope.allCases) { Text($0.rawValue).tag($0) }
}
.searchSuggestions {
if query.isEmpty {
ForEach(recent.prefix(5), id: \.self) { term in
Text(term).searchCompletion(term)
}
if !recent.isEmpty {
Button("Clear Recent Searches") { recentStore = "" }
}
}
}
.onSubmit(of: .search) { remember(query) }
}
private func remember(_ term: String) {
let term = term.trimmingCharacters(in: .whitespaces)
guard !term.isEmpty else { return }
recentStore = ([term] + recent.filter { $0 != term }).prefix(10).joined(separator: "\n")
}
}
```
## Sortable table
Header click sorts and reverses, columns resize and remember their widths,
rows alternate, and long names truncate in the middle (rule 73).
```swift
struct ItemTable: View {
@Query private var items: [Item]
@State private var sortOrder = [KeyPathComparator(\Item.createdAt, order: .reverse)]
@State private var selection = Set<Item.ID>()
@SceneStorage("ItemTable.columns") private var columns = TableColumnCustomization<Item>()
var body: some View {
Table(items.sorted(using: sortOrder), selection: $selection,
sortOrder: $sortOrder, columnCustomization: $columns) {
TableColumn("Title", value: \.title) { item in
Text(item.title).truncationMode(.middle)
}
.customizationID("title")
TableColumn("Created", value: \.createdAt) { item in
Text(item.createdAt, format: .dateTime.day().month().year())
}
.width(min: 90, ideal: 120)
.customizationID("created")
}
.alternatingRowBackgrounds()
}
}
```
## Drag to Finder makes a real file
A drag into Finder writes a file the app (or any app) can open again. Apps
that take no files get the title as text (rule 74).
```swift
struct ItemFile: Transferable {
let title: String
let notes: String
static var transferRepresentation: some TransferRepresentation {
FileRepresentation(exportedContentType: .plainText) { file in
let url = URL.temporaryDirectory.appending(path: "\(file.title).txt")
try "\(file.title)\n\n\(file.notes)".write(to: url, atomically: true, encoding: .utf8)
return SentTransferredFile(url)
}
ProxyRepresentation(exporting: \.title)
}
}
// In a row:
Text(item.title)
.draggable(ItemFile(title: item.title, notes: item.notes))
```
## Restore the selection on relaunch
Windows restore their frame on their own. `@SceneStorage` brings back UI
state per window: the selection, the open tab, column widths. Store a
stable id, never the data itself (rule 75).
```swift
struct RestoringList: View {
@Query private var items: [Item]
@SceneStorage("selectedItem") private var selectedID = "" // the item's uuid
var body: some View {
List(items, id: \.uuid.uuidString, selection: Binding(
get: { selectedID.isEmpty ? nil : selectedID },
set: { selectedID = $0 ?? "" })) { item in
Text(item.title)
}
}
}
```
## Custom chrome that follows the window
A custom selection fill turns gray when the window is not in front, keeps
concentric corners with a minimum radius, and lets a rich background run
under the sidebar (rules 14, 62, 63).
```swift
struct CustomSidebarRow: View {
@Environment(\.appearsActive) private var appearsActive
let title: String
let selected: Bool
private var fill: AnyShapeStyle {
guard selected else { return AnyShapeStyle(.clear) }
return appearsActive ? AnyShapeStyle(.tint) : AnyShapeStyle(.quaternary)
}
var body: some View {
Text(title)
.padding(.horizontal, 8)
.frame(maxWidth: .infinity, minHeight: 24, alignment: .leading)
.background(fill, in: ConcentricRectangle(corners: .concentric(minimum: 8)))
}
}
// On a hero image or tinted header that an inset sidebar covers:
HeroImage().backgroundExtensionEffect()
```
## Undo with SwiftData
Register the reverse of each action yourself, on the window's undo manager.
Do not set `mainContext.undoManager = UndoManager()`: a separate manager
never reaches Edit > Undo. SwiftData's automatic undo also failed to save
the reverted values in testing (the next fetch returned the old state).
```swift
@Environment(\.undoManager) private var undoManager
func complete(_ todo: Todo) {
let uid = todo.uid // your own UUID, stable across saves
todo.isDone = true
try? context.save()
undoManager?.registerUndo(withTarget: store) { store in
store.find(uid)?.isDone = false // look up again; the object may be new
try? store.context.save()
}
undoManager?.setActionName("Complete")
}
```
When `groupsByEvent` is off, wrap registrations in `beginUndoGrouping()` and
`endUndoGrouping()`, or the undo manager throws an exception that crashes
later inside SwiftUI. Test undo in the self-test: do, undo, fetch again.
## Menu bar extra
Add `MenuBarExtra` next to the main `Window` scene in `App.swift` for an always-available entry point. Let people turn it off in Settings with an `isInserted:` binding (if that hangs, see "A menu bar icon people can hide" below), and never make it the only way to reach a feature: the app also has a Dock menu (menus.md). The icon is a template SF Symbol, so it follows the menu bar's light and dark look. Show a menu, as below. Use `.menuBarExtraStyle(.window)` only when the feature is too complex for a menu (a timer with controls, a small player). [HIG The menu bar]
```swift
struct MenuBarScenePreview: Scene {
var body: some Scene {
MenuBarExtra("Starter", systemImage: "checkmark.circle") {
Button("Quick Add") {}
.keyboardShortcut("a")
Divider()
Button("Quit") { NSApp.terminate(nil) }
.keyboardShortcut("q")
}
}
}
```
## More patterns from real apps
Each one was built and checked in a shipped app.
### Highlight parts of a text field while typing
SwiftUI's `TextField` cannot color part of its text. For inline
highlights (dates, tags), wrap a one-line `NSTextView` in an
`NSViewRepresentable` and color ranges with temporary attributes, which
change the look without changing the text:
```swift
let range = NSRange(span.range, in: textView.string)
textView.layoutManager?.addTemporaryAttribute(.backgroundColor, value: NSColor.systemPurple.withAlphaComponent(0.16), forCharacterRange: range)
textView.layoutManager?.addTemporaryAttribute(.foregroundColor, value: NSColor.systemPurple, forCharacterRange: range)
```
Override `keyDown` for Return (keyCode 36) and Escape (53), and
`mouseDown` with `characterIndexForInsertion(at:)` to react to clicks on a
highlight. Draw a placeholder in `draw(_:)` when the string is empty.
### Let a CLI wake the app instantly
When the app and a command-line tool share a SQLite file, the CLI can ping
the app after each write. `notify_post` is not visible to Swift; the
CoreFoundation Darwin center is the same mechanism:
```swift
CFNotificationCenterPostNotification(CFNotificationCenterGetDarwinNotifyCenter(),
CFNotificationName("com.you.app.changed" as CFString), nil, nil, true)
```
The app adds an observer with `CFNotificationCenterAddObserver` and
reloads. Keep a cheap fallback: `PRAGMA data_version` changes whenever
another connection commits, so checking it once a second catches a missed ping.
### A menu bar icon people can hide
Start with `MenuBarExtra(isInserted:)` bound to `@AppStorage`, as in
Apple's `MenuBarExtra` example. Change the setting while the app runs and
check that the icon hides and comes back.
One launcher app hung at launch at 99% CPU with this binding. A sample
showed `AppGraph.graphDidChange` and `makeMainMenu` in a loop. A guarded
binding stopped the loop, but then the icon did not hide.
If you see this, use an `NSStatusItem` that follows the setting. Use it in
place of the `MenuBarExtra` scene, so the app has one icon:
```swift
final class MenuBarIcon: NSObject {
private let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
override init() {
super.init()
item.autosaveName = "MyApp" // macOS keeps the icon's place under this name
item.button?.image = NSImage(systemSymbolName: "checklist", accessibilityDescription: "MyApp")
item.menu = makeMenu()
follow()
}
/// Shows or hides the icon to match the option, now and whenever it changes.
private func follow() {
withObservationTracking {
item.isVisible = OptionsStore.shared.showsMenuBarIcon // an @Observable store owns the key
} onChange: { [weak self] in
DispatchQueue.main.async { self?.follow() }
}
}
}
```
In the self-test, check `item.isVisible`, not the status bar windows: one
icon has several windows, and only one is on a screen.
### Services menu: "Add to MyApp" in every app
In Info.plist, an `NSServices` entry with `NSMessage` (say `addTask`),
`NSPortName`, and `NSSendTypes` (`NSStringPboardType`). In
`applicationDidFinishLaunching`: `NSApp.servicesProvider = provider` and
`NSUpdateDynamicServices()`. The provider method signature is
`@objc func addTask(_ pasteboard: NSPasteboard, userData: String?, error: AutoreleasingUnsafeMutablePointer<NSString>)`.
Test it without clicking: `NSPerformService("Add to MyApp", pasteboard)` from a small Swift script.
### Menu bar extra that keeps the app alive
`MenuBarExtra("App", systemImage: "checklist", isInserted: $showMenuBar) { ... }`
(a menu; add `.menuBarExtraStyle(.window)` only for a feature too complex for one),
and return `false` from `applicationShouldTerminateAfterLastWindowClosed`
so closing the main window keeps the menu bar and global shortcuts working.
Hide the main window instead of closing it. On macOS 26, closing a SwiftUI
`Window` scene, opening it again, and then changing state crashed AppKit in
a constraint loop every time. Set a forwarding `NSWindowDelegate` whose
`windowShouldClose` calls `orderOut(nil)` and returns false. Prove it with
a self-test that closes and reopens the window 20 times.
### Notifications and the Dock badge
`NSApp.dockTile.badgeLabel = "3"` works right away. The badge counts only
unread notifications, never other numbers, and goes back to `nil` at zero.
A notification never says "open the app to do X", never repeats one
already shown, and offers at most four actions with title-case labels, none
of them "Open". Errors go in an alert inside the app, not a notification. Notifications need the
user's yes: if the permission prompt appears while nobody is there, macOS
records "denied" and `requestAuthorization` keeps failing with "not
allowed". Show the status in Settings and open
`x-apple.systempreferences:com.apple.Notifications-Settings.extension?id=<bundle id>`.
Implement the delegate's `didReceive` as `nonisolated`, call the
completion handler right away, and hop to the main actor for the work.
Skip notifications, login items, and anything else with a system prompt
when the store points to `/tmp`, so tests never trigger a prompt.
### A floating capture panel without a title bar
Use `styleMask: [.nonactivatingPanel, .borderless, .fullSizeContentView]`,
`isOpaque = false`, `hasShadow = true`, and override `canBecomeKey` to
return true. A `.titled` panel with a hidden title bar still leaves an
empty strip on top.
scripts/hig.sh#!/bin/zsh
# Prints one page of Apple's Human Interface Guidelines as plain text, for
# agents. Pages are cached for 7 days in ~/Library/Caches/bama/hig/ and never
# go into a repo (the text is Apple's).
#
# Usage: hig.sh <topic> the whole page, e.g. hig.sh toolbars
# hig.sh <topic> --mac drop the iOS, watchOS, tvOS, and visionOS sections
# hig.sh --list every topic name
set -euo pipefail
cache="$HOME/Library/Caches/bama/hig"
mkdir -p "$cache"
base="https://developer.apple.com/tutorials/data/design/human-interface-guidelines"
fetch() { # topic (or "index") -> cached json path
local file="$cache/$1.json" url="$base/$1.json"
[[ "$1" == "index" ]] && url="$base.json"
if [[ ! -s "$file" || -n "$(find "$file" -mtime +7 2>/dev/null)" ]]; then
curl -fsSL "$url" -o "$file.tmp" 2>/dev/null || { rm -f "$file.tmp"; echo "no HIG page named '$1'. Try: hig.sh --list" >&2; return 1; }
mv "$file.tmp" "$file"
fi
print -r -- "$file"
}
refs_of() { # json path -> topic names it links to
python3 -I -c '
import json, re, sys
refs = json.load(open(sys.argv[1])).get("references", {})
for k in refs:
m = re.search(r"Human-Interface-Guidelines/([a-z0-9-]+)$", k, re.I)
if m: print(m.group(1).lower())' "$1"
}
if [[ "${1:-}" == "--list" ]]; then
# The index links to sections, sections to groups, groups to pages. Walk
# three levels; the first run fetches about 40 pages, then the cache answers.
index=$(fetch index) || exit 1
typeset -U seen level next
level=($(refs_of "$index"))
seen=($level)
for depth in 1 2; do
next=()
for topic in $level; do
json=$(fetch "$topic" 2>/dev/null) || continue
[[ "$(python3 -I -c 'import json,sys; print(json.load(open(sys.argv[1])).get("metadata",{}).get("role",""))' "$json")" == "collectionGroup" ]] || continue
next+=($(refs_of "$json"))
done
level=(${next:|seen})
seen+=($level)
done
print -l $seen | sort
exit 0
fi
topic=${1:?usage: hig.sh <topic> [--mac] | --list}
mac=0; [[ "${2:-}" == "--mac" ]] && mac=1
json=$(fetch "$topic") || exit 1
python3 -I -c '
import json, sys
page = json.load(open(sys.argv[1])); mac = sys.argv[2] == "1"
refs = page.get("references", {})
def inline(items):
out = []
for i in items or []:
t = i.get("type")
if t == "text": out.append(i.get("text", ""))
elif t == "codeVoice": out.append("`" + i.get("code", "") + "`")
elif t == "reference": out.append(refs.get(i.get("identifier"), {}).get("title", ""))
elif t == "link": out.append(i.get("title", ""))
elif t == "image": pass
else: out.append(inline(i.get("inlineContent")))
return "".join(out)
def blocks(items, ind=""):
lines = []
for b in items or []:
t = b.get("type")
if t == "heading": lines.append(("\n" + "#" * b.get("level", 2) + " " + b.get("text", ""), b.get("level", 2)))
elif t == "paragraph":
s = inline(b.get("inlineContent"))
if s.strip(): lines.append((ind + s, None))
elif t in ("unorderedList", "orderedList"):
for it in b.get("items", []):
sub = blocks(it.get("content"), ind + " ")
if sub: lines.append((ind + "- " + sub[0][0].strip(), None)); lines += sub[1:]
elif t == "aside":
text = " ".join(x[0].strip() for x in blocks(b.get("content")))
lines.append((ind + "> " + (b.get("name") or b.get("style", "")) + ": " + text, None))
elif t == "table":
for row in b.get("rows", []):
lines.append((ind + " | ".join(" ".join(x[0].strip() for x in blocks(c)) for c in row), None))
elif t == "termList":
for it in b.get("items", []):
lines.append((ind + "- " + inline(it["term"]["inlineContent"]) + ": "
+ " ".join(x[0].strip() for x in blocks(it["definition"]["content"])), None))
elif t == "tabNavigator":
for tab in b.get("tabs", []):
lines.append((ind + "[" + tab.get("title", "") + "]", None)); lines += blocks(tab.get("content"), ind)
elif t == "row":
for c in b.get("columns", []): lines += blocks(c.get("content"), ind)
elif "content" in b: lines += blocks(b["content"], ind)
return lines
meta = page.get("metadata", {})
print("# " + meta.get("title", ""))
note = meta.get("customMetadata", {})
if note.get("alert-date"): print("Updated " + note["alert-date"] + ": " + note.get("alert-text", ""))
print(inline(page.get("abstract")))
skip = False
for section in page.get("primaryContentSections", []):
for text, level in blocks(section.get("content")):
if level is not None and level <= 3:
skip = mac and level == 3 and "macOS" not in text and "Platform" not in text and any(
p in text for p in ("iOS", "iPadOS", "watchOS", "tvOS", "visionOS"))
if not skip: print(text)
' "$json" "$mac"
settings.md# Settings window
How to build and structure the Settings window. The template already has
one: `SettingsView.swift`, `UI/AppearanceSettings.swift`, and
`UI/WindowBehavior.swift`. Grow it; do not replace it.
## Contents
- Structure
- Tabs
- Rows
- Size and scrolling
- Focus
- Check
## Structure
- A `Settings` scene, opened with Command-comma from the app menu. Never a
sheet, a page in the main window, or a custom window.
- `TabView` with one tab per topic. macOS draws the tabs as icons in the
toolbar, and the window title follows the tab.
- Each tab is one `Form` with `.formStyle(.grouped)`, 520 pt wide.
- Settings apply at once. No Save, Apply, or Cancel button.
- Settings reopens on the tab people last used (`AppModel.settingsTab` is
saved in the template).
- With a single pane, no tabs: the window title is "<App> Settings".
- `@AppStorage` for simple values, so every view that reads a setting
updates by itself.
```swift
Settings {
SettingsView()
.environment(model)
.themed()
}
.windowResizability(.contentSize)
```
## Tabs
Add a tab only when a topic has more than about three rows. Two to six tabs
in total. More than six topics (a suite of tools) needs a settings window
with a sidebar instead of tabs. Order:
| Tab | Holds | Symbol |
| --- | --- | --- |
| General | Startup, menu bar and Dock, default behavior | `gearshape` |
| Appearance | Accent color, density. Light and dark follow the Mac; a System/Light/Dark picker only when the owner asks for one (Apple advises against it) | `paintpalette` |
| Shortcuts | Global shortcut, the list of key commands | `keyboard` |
| One per feature | Options for one big feature (Capture, Sync) | the feature's symbol |
| Permissions | Each system permission with its state | `lock.shield` |
| Advanced | Data folder, export, reset, debug options | `gearshape.2` |
The tab names and symbols come from `SettingsTab`. Snapshot mode walks
through every case.
## Rows
- Group rows in `Section("Name")`. Put a short help text in the section
footer, never in the row label.
- Labels say what the setting does, as a statement: "Show Brisk in the menu
bar", not "Menu bar icon" or "Enable menu bar".
- On and off: in a grouped `Form` a `Toggle` is a switch; make it a mini
switch (`.controlSize(.mini)`) so every row keeps the same height. A
full-size switch only for a master setting that turns a whole group on.
Outside a grouped form, a checkbox (`.toggleStyle(.checkbox)`) for a
single option.
- Choices: radio buttons (`.pickerStyle(.radioGroup)`) for two to five, a
menu `Picker` beyond that. A segmented control belongs in toolbars and
inspectors, not in Settings rows. `LabeledContent` for a value or a
custom control on the trailing edge.
- Values on the trailing edge line up. Use the system controls, so they do.
- A permission row shows its state ("Allowed", "Not allowed") and one
button that opens the right pane of System Settings.
- A setting that cannot be used right now is disabled, with the reason in
the footer.
- Options that belong to one view (sort, filter, show/hide) live in that
view or its View menu, not in Settings.
- Do not repeat a system setting (text size, accent, light or dark mode)
unless the owner asks for it. Offer a button that opens the right System
Settings pane instead.
- Destructive buttons (Reset, Delete All) go at the bottom of Advanced, with
`role: .destructive` and a confirmation or Undo.
## Size and scrolling
- `.fitsContent()` on each tab's `Form`, and `.windowResizability(.contentSize)`
on the scene. The window then grows and shrinks to each tab, and shows no
scroll bar it does not need.
- A tab taller than 640 pt scrolls. Split it into two tabs before that.
## Focus
`.noInitialFocus()` on the `TabView`. A text field, shortcut recorder, or
button never opens with a focus ring or selected text. The person clicks or
presses Tab to start.
## Check
Snapshot mode writes `settings-<tab>-light.png` and `-dark.png` for every
tab. In each one:
- No control has a focus ring or selected text.
- No scroll bar, unless the tab is taller than 640 pt.
- The window is as tall as its content: no empty band at the bottom.
- Every label is a statement, and trailing values line up.
SKILL.md---
name: mac-design
description: Design rules that make a SwiftUI app look and feel like a native Mac app on macOS 26 and later, from Apple's eight design principles to Liquid Glass, layout, toolbars, menus, search, keyboard, type, color, and dark mode. Use when building or reviewing any window, sidebar, toolbar, menu, panel, or settings screen, or when the user asks for a more native, Apple-like, or better designed app.
---
# Mac design
Apple already designed your app. Pick the right system parts, arrange them,
and stay out of their way. Use the system first. Add custom design only
where the system has nothing.
- The full checklist with sources: [design-rules.md](design-rules.md).
Use it for reviews.
- Copyable SwiftUI code: [patterns.md](patterns.md).
- The Settings window: [settings.md](settings.md).
- Toolbars: which item goes where, per column: [toolbars.md](toolbars.md).
- A left sidebar, a right panel, and a top bar that fit together, or no top
bar: [app-shell.md](app-shell.md).
- The menu bar, context menus, and the Dock menu: [menus.md](menus.md).
- Apple's current text on one topic, when a rule here is not enough:
`"${CLAUDE_SKILL_DIR}/scripts/hig.sh" <topic>` (for example `toolbars`,
`sheets`). It prints the HIG page as plain text.
- App-wide tokens (colors, radii, motion): call the Skill tool with
`design-system`.
- How a feature works and what it says (names, flow, modals, errors,
accessibility): call the Skill tool with `ux-design`.
- Spacing, alignment, and the checks that catch misalignment: call the
Skill tool with `layout-grid`.
- Parts of your own instead of Apple's: call the Skill tool with
`design-kit`, or `design-from-app` to match an app the user names.
## Apple's eight principles
Use them as the review lens for every screen (HIG Design principles, 2026):
1. **Purpose**: each feature costs people time, attention, and trust. Cut what does not earn it.
2. **Agency**: people choose their path. Undo forgives mistakes. No forced tours.
3. **Responsibility**: ask for permissions at first use, keep data private, guard AI output.
4. **Familiarity**: things that look the same work the same. Standard parts, standard symbols.
5. **Flexibility**: keyboard, pointer, and resizing all work. People can customize what they use daily.
6. **Simplicity**: simple is not minimal. Hierarchy comes from order, space, and contrast.
7. **Craft**: instant response, smooth scrolling, aligned edges, layouts that survive resizing.
8. **Delight**: the sum of the other seven, aimed at one feeling. Never decoration added at the end.
Every screen answers: Where am I? What can I do? Where can I go?
## Before you write views
Write these four under `## Design plan` in `docs/BRIEF.md`, or in
`docs/DESIGN.md` when the brief would pass one page:
1. **Archetype**: sidebar window, single-window document, menu bar extra, or
quick-capture panel. Most personal apps are a sidebar window plus one
quick-capture entry point. A menu bar utility is "menu bar extra +
panels + Settings window": the toolbar and sidebar rules then apply to
the Settings window only.
2. **Information architecture**: sidebar pages, at most two levels.
3. **Command map**: every action, its menu, its shortcut.
4. **Empty states**: what each page shows before there is data.
5. **Feeling**: the one feeling the app aims for (calm, quick, confident).
Motion, words, and color all reinforce it.
## Entry points first
A Mac app is used through its small entry points: a global shortcut, the
menu bar, a quick panel, drag and drop, the Share menu. Design those first.
A to-do app lives in its quick-add panel.
- Quick capture: one floating panel, one input, opened by a global shortcut,
closed by Escape or Return.
- **Optimistic UI**: on Return, close and show the result at once. Save in the
background. Offer Undo, not a spinner.
## Layout
- `NavigationSplitView`: sidebar, content, detail. Global actions in the
toolbar. No sidebar when navigation is one level.
- Selection details in `.inspector` or the detail column. An inspector looks
flat beside the floating sidebar. To make both sides match, call on
[app-shell.md](app-shell.md) before you build the toolbar.
- The UI frames the user's content. Ask what the least UI is that lets it
stand out.
- Progressive disclosure: an empty page shows one `ContentUnavailableView`
with the first action. Filters appear once there is content.
- Keep the top 50 pt free for dragging the window.
- Spacing from `Space`, on a 4 pt grid: 20 pt window margins, 8 pt
between related controls, 20 pt between groups. `.controlSize` for dense
areas, never hard-coded heights.
## Liquid Glass
- Glass is for the navigation and control layer: toolbars, sidebars,
floating buttons, popovers. Content (rows, cards, documents) gets none.
- Never glass on glass. Group neighboring custom glass in
`GlassEffectContainer`.
- Standard components get glass on their own. Delete custom backgrounds
behind bars and sheets.
- `.glassEffect()` only for a custom floating control. Tint only to carry
meaning. Interactive glass (the bounce on click) only on controls, and
sparingly.
- Custom chrome dims with the window: read `@Environment(\.appearsActive)`
(patterns.md, "Custom chrome that follows the window").
## Toolbars and menus
- Each toolbar item sits over the column it acts on: nothing of your own
over the sidebar, New and Sort over the list, actions on the selection
over the detail, the inspector toggle over the inspector. Full rules and
a checked example: [toolbars.md](toolbars.md).
- At most three toolbar groups, split with `ToolbarSpacer`. Monochrome SF
Symbols, each with a title and `.help`. At most one prominent action,
only for a completing action like Done or Send. Most main windows have
none.
- `SidebarCommands()`, `ToolbarCommands()`, and (with an inspector)
`InspectorCommands()` in `.commands`, so the View menu can show and hide
each part.
- Every toolbar button and feature also lives in the menu bar with a
shortcut (`.commands`, `CommandGroup`, `CommandMenu`). Apple's menu order
and labels: [menus.md](menus.md).
- Menu icons only on key actions, all or none in a group.
- Standard shortcuts keep their meaning: Command-C, V, X, Z, W, Q, N, comma.
- Disable menu items that do not apply; keep them visible. SwiftUI menus
only update when the selection is passed into the commands from the App
(`swift-swiftui`, "Menu commands").
- Window titles name the content ("Today"), not the app.
## Windows open calm
- Nothing is focused when a window or sheet opens: no focus ring, no
blinking cursor, no selected text. Put `.noInitialFocus()` on every
window and sheet. The one exception is a window whose only job is
typing, like a search or quick-add panel: it focuses its field.
- A sidebar list may hold focus at launch. It shows as a selected row, not
a ring, and the arrow keys work at once.
- A sheet, popover, or Settings tab grows to fit its content
(`.fitsContent()`). It scrolls only when the content is taller than
the screen allows, about 640 pt.
- System scroll bars. The "Show scroll bars" setting decides when they
show. Do not restyle them.
## Keyboard first
- Every frequent action has a shortcut, and every shortcut shows feedback
in the same frame.
- Return is the default action, Escape cancels, Delete removes, Space
previews, arrows move.
- New items get focus (`@FocusState`) so the user can type at once.
- Teach shortcuts in menus, `.help` tooltips, and a list in Settings.
## Color, type, dark mode
- Text is `.primary`, `.secondary`, `.tertiary`. System backgrounds. No
fixed white, black, or gray in chrome.
- One accent color. Status color always comes with an icon or a word.
- macOS text styles: body 13 pt, nothing under 10 pt. Never iOS sizes.
Headline is bold 13 pt, the same size as Body. Full table:
design-rules.md rule 29.
- Dark mode is its own palette with more distance between tones. Check
both in every snapshot.
- Accent-colored text uses a deep shade in light mode and a light shade in
dark mode. Text and symbols on an accent fill use the theme's on-accent
color, never a fixed white (white fails on yellow and green accents).
- Hover and press never lower contrast. Text over user content
(screenshots, video) sits on a dark scrim or capsule.
- `Label` for icon and text, SF Symbols for every icon.
## Interaction
- `.contextMenu` on everything a user can act on. It hides items that do
not apply and shows no shortcuts. Hover states on custom controls. Mac
controls are 22 to 28 pt tall, not 44.
- Drag and drop in and out with `Transferable`. A drag to Finder makes a
real file. `ShareLink` where it fits.
- Relaunch brings back the windows, the selection, and the scroll position
(`@SceneStorage`). Quit never asks unless typed work would be lost.
- Undo for every destructive or bulk action, in Settings too. A one-click
trash button needs Undo in place.
- `Settings` scene, opened with Command-comma: tabs, one grouped Form per
tab, sized to its content. Structure and rows: [settings.md](settings.md).
- Respect Reduce Motion and Reduce Transparency. Label icon-only buttons.
## When to customize
Custom chrome is fine when it makes the app clearly better at its one job,
like a quick-add field or a calendar grid. Write why in a code comment, and
match the system: concentric radii, system fonts, system spacing.
## Review set
Snapshot every surface in light and dark: each page, the menu bar menu,
each panel and overlay (launcher, toasts, cards), editors, and each
Settings tab (snapshot mode does pages and Settings tabs). Look for focus
rings, selected text, scroll bars with nothing to scroll, and empty bands
under short content. Capture each window as the key window: a snapshot of
an inactive window (gray traffic lights, dim toggles) or with flattened
glass does not count as reviewed. Views that pause while covered show a
placeholder; that one is expected.
Done when every rule in [design-rules.md](design-rules.md) holds on the
light and dark snapshots of every surface, or has a comment saying why not.
toolbars.md# Toolbars
Toolbar layout for a macOS 26 SwiftUI window, checked on macOS 26.5.
Placement changed between 26.0 and 26.2: snapshot the toolbar after each OS update.
## Contents
- Areas
- Columns
- Toggles
- Glass groups
- Labels
- Title and search
- Not iOS
- Review
- Example
- Sources
## Areas
| Area | What goes there | SwiftUI |
|---|---|---|
| Leading | Sidebar toggle, back and forward, title and subtitle | system toggle, `.navigation`, `.navigationTitle` |
| Center | Common controls. They fold into the overflow menu as the window narrows | `.principal`, `.automatic` |
| Trailing | Inspector toggle, search, the one primary action. Visible at every width | `.primaryAction`, `.searchable` |
- At most three groups in the whole toolbar. Only frequent commands get an item.
- Every toolbar item also has a menu bar command and shortcut. People can hide the toolbar.
- The system adds the overflow menu. Never build your own to make items fit.
- No Settings button. Settings opens from the app menu with Command-comma.
- An app people keep open for hours, or with many items, lets people customize
the toolbar: `.toolbar(id:)` with `ToolbarItem(id:)`. Leading items stay fixed.
- A More menu (`ellipsis`) at the trailing edge may hold extra actions. The
main actions never hide in it.
- Back and Close use the standard symbols (`chevron.backward`, `xmark`), never
the words.
- macOS 27 SDK (not checked here): `visibilityPriority(.high)` keeps a key
group visible as the window narrows, and `ToolbarOverflowMenu` sends rare
items to the overflow menu on purpose. Check whether this ends the "custom
item dropped" gotcha below.
## Columns
In a `NavigationSplitView` the toolbar splits at the column dividers. An item
sits over the column whose view declares it.
- Sidebar column: no items of your own. The system puts the sidebar toggle there.
- Content column: title, subtitle, and actions that make or order rows (New,
Sort). `.primaryAction` puts them at the column's trailing edge.
- Detail column: actions on the selection (Share, Flag, Delete). Plain
`ToolbarItem`s start at the column's leading edge. Put
`ToolbarSpacer(.flexible)` first to push them trailing.
- `.inspector` goes on the `NavigationSplitView`, not on a column. It then
gets a full-height column with its own toolbar area. That column is flat, with
a line along its edge. For a right panel that matches the sidebar, or for no
top bar, use [app-shell.md](app-shell.md).
- No `.toolbar` on the `NavigationSplitView` itself. A `.primaryAction` there
lands at the leading edge of the detail column.
- Check: snapshot with the sidebar collapsed and the inspector closed. Every
item stays over its column, in the same order.
## Toggles
- Sidebar: keep the toggle `NavigationSplitView` adds. Never draw a second one.
Use `.toolbar(removing: .sidebarToggle)` only when the sidebar never hides.
- `SidebarCommands()` on the scene adds View > Show Sidebar, Control-Command-S.
Without it the View menu has no sidebar item.
- Inspector: one `sidebar.trailing` button in the inspector's own `.toolbar`,
after `ToolbarSpacer(.flexible)`. Without the spacer it jumps when the inspector opens.
- `InspectorCommands()` adds View > Show Inspector, Control-Command-I (tested on
macOS 26; it pairs with Show Sidebar, Control-Command-S). Put that shortcut in
the button's `.help`. No second inspector command elsewhere. The HIG keyboard
table still lists Option-Command-I; the framework command wins.
- `ToolbarCommands()` adds View > Show Toolbar (Option-Command-T) and Customize Toolbar.
## Glass groups
On macOS 26 toolbar items float on Liquid Glass, and neighbors share one capsule.
- `ToolbarSpacer(.fixed)` splits groups with different jobs.
`ToolbarSpacer(.flexible)` pushes items apart. `ToolbarItemGroup` keeps a set together.
- `.sharedBackgroundVisibility(.hidden)` takes one item out of its capsule.
- No backgrounds, borders, or dimming behind the toolbar. The automatic scroll
edge effect keeps it legible and already turns more opaque over dense content.
Do not switch it to `.hard`.
- Borderless buttons, monochrome SF Symbols. Tint only to carry meaning.
## Labels
- A symbol first. Text only when no symbol fits ("Edit"). Write
`Button("Title", systemImage:)` so the title shows in the overflow menu, in
Icon and Text mode, and to VoiceOver.
- Every item has `.help`: what it does, starting with a verb, then the shortcut ("Create a note (Command-N)"). Not the item's name again.
- A text button next to a symbol button gets a `ToolbarSpacer(.fixed)` between them.
- A primary action (Done, Send) is one item, trailing, alone, prominent. Most
main windows have none. "New" is not a primary action.
## Title and search
- `.navigationTitle` names the content in under 15 characters, never the app.
`.navigationSubtitle` holds a count or a date.
- Every page has a subtitle, or none does. A subtitle that comes and goes
changes the toolbar height, and that crashed AppKit on window reopen in Brisk.
- Window-wide search: `.searchable(text:placement: .toolbar)` on the split view.
It sits at the trailing edge, over an open inspector, as in Mail and Notes.
- `.searchable` on one column does not move the field over that column on
macOS. To filter only the sidebar, use `placement: .sidebar`. To keep a
list-only search over the list while an inspector is open, use a native
`NSSearchField` toolbar item (patterns.md, "Toolbar that follows the columns").
- Command-F focuses the field (`.searchFocused`), and so does
Option-Command-F, Apple's standard "jump to the search field". Escape
clears it. The subtitle shows the result count.
- A big app with many sections (Music) gets a Search item in the sidebar.
Selecting it shows a full results page and focuses its field.
- Scopes, suggestions, recent searches, and the no-results view:
patterns.md, "Search with scopes, suggestions, and recent searches".
- On macOS 27 the top scroll edge turns hard on its own when free text such as
the window title floats over content. Do not fight it.
- A custom toolbar item that does not fit is dropped, with no overflow
entry and no warning. Measure the list column (`.onGeometryChange`) and
below about 480 pt show a Find button (`magnifyingglass`) that opens the
search field in a popover; Command-F opens it too. Check the toolbar at
the window's minimum width with the inspector open and closed.
## Not iOS
Mac toolbars sit at the top of the window. These do not compile for macOS:
`.bottomBar`, `.topBarLeading`, `.topBarTrailing`, `.largeTitle`,
`.searchToolbarBehavior(.minimize)`, `ToolbarRole.navigationStack`. Also drop
large titles, 44 pt buttons, floating add buttons, and ellipsis menus that hold
the main actions.
## Review
The toolbar fails when it has two sidebar or inspector toggles, items over the
wrong column, items that move when a column collapses, more than three groups,
an item without `.help`, an action missing from the menu bar, a Settings
button, tint as decoration, or an `HStack` drawn in place of `.toolbar`.
Check the left and right panels together: [app-shell.md](app-shell.md), "Review".
## Example
Typechecked against the macOS 26.5 SDK. `Library` is your `@Observable` model.
The scene adds `.commands { SidebarCommands(); InspectorCommands(); ToolbarCommands() }`
plus New Note (Command-N) and Flag (Shift-Command-L) menu commands.
```swift
struct ContentView: View {
@Environment(Library.self) private var library
var body: some View {
@Bindable var library = library
NavigationSplitView {
FolderList() // no items of your own: the system adds the sidebar toggle
} content: {
NoteList()
.navigationTitle(library.folderName)
.navigationSubtitle("\(library.notes.count) notes")
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button("New Note", systemImage: "square.and.pencil") { library.addNote() }
.help("Create a note (Command-N)")
}
}
} detail: {
NoteEditor()
.toolbar {
ToolbarSpacer(.flexible) // push the items to the column's trailing edge
ToolbarItem { ShareLink(item: library.noteText) }
ToolbarSpacer(.fixed)
ToolbarItem {
Button("Flag", systemImage: "flag") { library.toggleFlag() }
.help("Flag the selected note (Shift-Command-L)")
}
}
}
.inspector(isPresented: $library.showInspector) { // on the split view
NoteInfo()
.inspectorColumnWidth(min: 220, ideal: 260, max: 340)
.toolbar {
ToolbarSpacer(.flexible) // keeps the toggle in place, open or closed
ToolbarItem {
Button("Inspector", systemImage: "sidebar.trailing") { library.showInspector.toggle() }
.help("Show or hide the inspector (Control-Command-I)")
}
}
}
.searchable(text: $library.query, placement: .toolbar, prompt: "Search all notes")
}
}
```
## Sources
- HIG: [Toolbars](https://developer.apple.com/design/human-interface-guidelines/toolbars), [Sidebars](https://developer.apple.com/design/human-interface-guidelines/sidebars), [Split views](https://developer.apple.com/design/human-interface-guidelines/split-views), [Search fields](https://developer.apple.com/design/human-interface-guidelines/search-fields), [The menu bar](https://developer.apple.com/design/human-interface-guidelines/the-menu-bar)
- WWDC25-323 Build a SwiftUI app with the new design: https://developer.apple.com/videos/play/wwdc2025/323/
- WWDC25-356 Get to know the new design system: https://developer.apple.com/videos/play/wwdc2025/356/
- SwiftUI: [ToolbarSpacer](https://developer.apple.com/documentation/swiftui/toolbarspacer), [sharedBackgroundVisibility](https://developer.apple.com/documentation/swiftui/toolbarcontent/sharedbackgroundvisibility(_:)), [ToolbarDefaultItemKind](https://developer.apple.com/documentation/swiftui/toolbardefaultitemkind), [InspectorCommands](https://developer.apple.com/documentation/swiftui/inspectorcommands), [Adding a search interface](https://developer.apple.com/documentation/swiftui/adding-a-search-interface-to-your-app)
- Landmarks toolbar sample: https://developer.apple.com/documentation/swiftui/landmarks-refining-the-system-provided-glass-effect-in-toolbars
- Inspector toggle placement across macOS 26 releases: https://developer.apple.com/forums/thread/795561
- Field test: Brisk commit f9f264b, and placement snapshots on macOS 26.5