CommandPalette adds the ⌘K command palette people know from Xcode, Raycast and Linear to your SwiftUI app on the Mac and iPad: fuzzy search with highlighted matches, sections, recent commands at the top, shortcut hints, and full keyboard navigation (↑ ↓ to move, ↩ to run, ⎋ to close). It floats on Liquid Glass on macOS 26 and iPadOS 26 and on a material before; everything runs on macOS 14 and iOS 17.
NotesView()
.commandPalette(isPresented: $showsPalette, commands: [
Command("New Note", section: "Notes", symbol: "square.and.pencil",
shortcut: CommandShortcut("⌘N")) { model.newNote() },
Command("Toggle Sidebar", section: "View", symbol: "sidebar.left") { model.toggleSidebar() },
])
// ⌘K opens it.Captured from the example app on macOS 26 and an iPad simulator with iPadOS 26 by CI.
| Mac: recent commands and sections | Mac: searching "new" |
|---|---|
![]() |
![]() |
| iPad: recent commands and sections | iPad: searching "new" |
|---|---|
![]() |
![]() |
- One modifier (
.commandPalette(isPresented:commands:)): the palette floats near the top over a dimmed backdrop; ⌘K toggles it, a click or tap on the backdrop, ⎋ or running a command closes it. - Fuzzy search (
FuzzyMatcher): the letters of the query in order, ignoring case, diacritics and spaces. Word starts, consecutive letters and prefixes score higher, and the best fit is highlighted ("note"in "Rename Note" highlights the word "Note"). Keywords find a command too: "create" finds "New Note". - Sections: commands are grouped under their section; while searching, the section with the best match comes first.
- Recent commands (
RecentCommandsStore): the last five commands you ran, listed first while the search field is empty. Stored inUserDefaultsby default, or wherever yourRecentCommandsStorageputs them. - Keyboard navigation (
CommandPaletteSelection): ↑ and ↓ skip section headers and wrap around, ↩ runs, ⎋ closes. A click or tap runs a command too. - Shortcut hints (
CommandShortcut):"⇧⌘N"or"cmd+shift+n", shown as key caps in menu order, convertible to SwiftUI'sKeyboardShortcut. - ⌘K anywhere (
.commandPaletteShortcut): the shortcut on its own, for a palette you present yourself. Works on the Mac and on iPad with a hardware keyboard. - Liquid Glass on macOS 26 and iPadOS 26, a thick material on earlier systems. Every 26 API is behind an availability check.
- Swift 6 strict concurrency, zero dependencies, the search, ranking, selection and recents logic tested with Swift Testing.
In Xcode choose File › Add Package Dependencies… and enter:
https://github.com/halilozel1903/CommandPalette
Or add it to Package.swift:
dependencies: [
.package(url: "https://github.com/halilozel1903/CommandPalette", from: "1.0.0")
]import CommandPalette
let newNote = Command(
"New Note", // the title, searched first
id: "new-note", // stable: recents are remembered by it
subtitle: "Start a blank note in this folder",
section: "Notes", // the section header
symbol: "square.and.pencil", // an SF Symbol
keywords: ["create", "add"], // other words that find it
shortcut: CommandShortcut("⌘N") // shown as a hint
) {
model.newNote() // runs on the main actor after the palette closes
}id defaults to "<section>/<title>". Give commands a fixed id when their title can change, for example with localization. The order of the array is the order of the list while the search field is empty.
struct NotesView: View {
@StateObject private var model = NotesModel()
@State private var showsPalette = false
var body: some View {
EditorView(model: model)
.toolbar {
Button("Commands", systemImage: "command") { showsPalette = true } // for touch
}
.commandPalette(
isPresented: $showsPalette,
commands: model.commands,
recents: nil, // RecentCommandsStore.shared
shortcut: .commandK, // nil for no shortcut
placeholder: "Search for a command…"
)
}
}The commands are read every time the view updates, so build them from your current state: show "Pin Note" or "Unpin Note", or leave out commands that do not apply.
To start with a query, select a command or skip the focus, put your own CommandPaletteView in the same presentation:
.commandPalette(isPresented: $showsPalette) {
CommandPaletteView(
commands: model.commands,
recents: recents,
placeholder: "Go to…",
initialQuery: "",
initialSelection: "new-folder", // a command id
autofocus: true,
onDismiss: { showsPalette = false }
)
}CommandPaletteView also works on its own, for example in a sheet or a popover.
ContentView()
.commandPaletteShortcut(isPresented: $showsPalette) // ⌘K toggles it
.commandPaletteShortcut(CommandShortcut("⇧⌘P")!) { openPalette() }let recents = RecentCommandsStore() // UserDefaults.standard, 5 commands
let recents = RecentCommandsStore(storage: UserDefaultsRecentCommandsStorage(key: "recents", defaults: groupDefaults), limit: 8)
let recents = RecentCommandsStore(storage: InMemoryRecentCommandsStorage(["new-note"])) // tests, previews
recents.record(command) // the palette does this when it runs a command
recents.ids // newest first
recents.clear()Store them anywhere else by conforming to RecentCommandsStorage:
@MainActor
final class CloudRecents: RecentCommandsStorage {
func loadRecentCommandIDs() -> [String] { … }
func saveRecentCommandIDs(_ ids: [String]) { … }
}FuzzyMatcher.match("nn", in: "New Note") // score 98, ranges [0..<1, 4..<5]
FuzzyMatcher.match("note", in: "Rename Note") // ranges [7..<11]: the word, not the "n" of "Rename"
FuzzyMatcher.match("cafe", in: "Café Recipes") // diacritics and case are ignored
FuzzyMatcher.match("wen", in: "New Note") // nil: the letters are out of orderRanges are character offsets, easy to turn into an AttributedString. The weights are listed in FuzzyMatcher.Weight:
| Points | |
|---|---|
| Each matched character | 16 |
| … at the start of a word | +30 |
… at a lower-to-upper case change (tableView) |
+20 |
| … right after the previous match | +32 |
| … the first character of the title | +15 |
| Each skipped character between matches | −3 |
| Each character before the first match | −1 (at most −10) |
| The title starts with the query | +50 |
| The title is the query | +100 |
CommandPaletteResults.sections(for:in:recentIDs:) turns a query into the palette's sections. Results are ordered by score, then shorter title, then the order of your array. A keyword match counts half and highlights nothing.
CommandShortcut("⇧⌘N")?.displayString // "⇧⌘N"
CommandShortcut("cmd+shift+n")?.keyCaps // ["⇧", "⌘", "N"]
CommandShortcut("ctrl+option+space") // ⌃⌥Space
CommandShortcut("n", modifiers: [.command, .option])
CommandShortcut.commandK.keyboardShortcut // KeyboardShortcut("k", modifiers: .command)Shortcuts in the palette are hints. To make a command's shortcut work while the palette is closed, give the same shortcut to a menu item or button: .keyboardShortcut(shortcut.keyboardShortcut).
| macOS 26, iPadOS 26 | macOS 14 and 15, iPadOS 17 and 18 | |
|---|---|---|
| Palette background | .glassEffect(.regular) |
.thickMaterial with a hairline border |
- Search is a small dynamic program over the query and the title, so the highlighted letters are the best-scoring fit, not the first letters that happen to match. It runs on every keystroke and is fast for hundreds of commands with short titles.
- Keys come from
onKeyPresson the search field (↑, ↓, ⎋) andonSubmit(↩); on the MaconExitCommandhandles ⎋ as well. - ⌘K is an invisible button with a
keyboardShortcut, so it works whenever the view is on screen, without a menu item. - Running a command records it as recent, closes the palette, then calls the command's action.
The Example folder contains Inkwell, a made-up notes app for the Mac and iPad with 21 commands in five sections. It uses XcodeGen so no project file has to live in the repo:
brew install xcodegen
cd Example && xcodegen generate
open CommandPaletteDemo.xcodeproj # schemes CommandPaletteDemoMac and CommandPaletteDemoPadPress ⌘K, or use the search field in the toolbar.
The screenshots come from scripts/screenshots-mac.sh and scripts/screenshots-ipad.sh. Launched with -screenshot <scene> (palette or search), the app opens the palette with fixed content: fixed recent commands, and for search the query "new" with New Folder selected. On the Mac the scene is shown in a borderless window and captured with screencapture -l; the app can also render that window into a PNG itself (-render-screenshot <file>), which is the fallback. On iPad the simulator is captured with simctl io screenshot. Both scripts fail instead of saving a blank capture.
- Xcode 26 or later (Swift 6.2 toolchain)
- macOS 14+ or iOS/iPadOS 17+ (Liquid Glass automatically on 26)
Issues and pull requests are welcome. Please run swift test before opening a PR.
CommandPalette is available under the MIT license. See LICENSE.



