Skip to content

Command Palette

A searchable sheet of commands with shortcut hints and live filtering as you type.

Command Palette, playing
  • Menus
  • iOS 18.0+
  • intermediate
Download .swift
Origin
Original work
Found by
command palette · search · actions · shortcuts · filter · sheet · command k · paleta de comandos · búsqueda · acciones · atajos · filtrar · hoja

A searchable list of commands, filtered as you type. Show it in a sheet.

The search field takes focus when it appears, and Return runs the first match. Running a command closes the sheet first.

struct CommandPaletteDemo: View {
@State private var isOpen = false
@State private var lastCommand = "Nothing yet"
private var commands: [CommandPaletteCommand] {
[
command("New Note", section: "Create", image: "square.and.pencil", shortcut: "⌘N"),
command("New Folder", section: "Create", image: "folder.badge.plus", shortcut: "⇧⌘N"),
command("Share Note", section: "Actions", image: "square.and.arrow.up", shortcut: "⌘S"),
command("Duplicate", section: "Actions", image: "plus.square.on.square", shortcut: "⌘D"),
command("Delete", section: "Actions", image: "trash", shortcut: "⌫"),
command("Settings", section: "Go to", image: "gearshape", shortcut: "⌘,"),
command("Trash", section: "Go to", image: "trash.circle"),
command("Help", section: "Go to", image: "questionmark.circle", shortcut: "⌘?"),
]
}
var body: some View {
ZStack {
LinearGradient(colors: [.indigo, .blue], startPoint: .top, endPoint: .bottom)
.ignoresSafeArea()
VStack(spacing: 22) {
Text(lastCommand)
.font(.title2.bold())
.foregroundStyle(.white)
.multilineTextAlignment(.center)
Button {
isOpen = true
} label: {
Label("Open commands", systemImage: "command")
.font(.headline)
.padding(.horizontal, 18)
.padding(.vertical, 12)
.background(.white, in: .capsule)
.foregroundStyle(.indigo)
}
}
.padding(24)
}
.sheet(isPresented: $isOpen) {
CommandPalette(commands: commands)
}
}
private func command(_ title: String, section: String, image: String, shortcut: String? = nil) -> CommandPaletteCommand {
CommandPaletteCommand(id: title, title: title, section: section, systemImage: image, shortcut: shortcut) {
lastCommand = title
}
}
}
ParameterTypeDefault
commands[CommandPaletteCommand]Required

Copy this code into your project, or download the file.

CommandPalette.swift
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors
import SwiftUI
struct CommandPaletteCommand: Identifiable {
let id: String
let title: String
let section: String
let systemImage: String
/// A hint only, such as "⌘N".
var shortcut: String?
/// Extra words that find this command, such as synonyms.
var keywords: [String] = []
let action: () -> Void
}
struct CommandPaletteGroup: Identifiable {
let title: String
let commands: [CommandPaletteCommand]
var id: String { title }
}
enum CommandPaletteFilter {
/// Commands matching the query, grouped by section in the order they first appear.
static func groups(_ commands: [CommandPaletteCommand], query: String) -> [CommandPaletteGroup] {
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
let matches = commands.filter { command in
trimmed.isEmpty || ([command.title, command.section] + command.keywords)
.contains { $0.localizedStandardContains(trimmed) }
}
var titles: [String] = []
var bySection: [String: [CommandPaletteCommand]] = [:]
for command in matches {
if bySection[command.section] == nil { titles.append(command.section) }
bySection[command.section, default: []].append(command)
}
return titles.map { CommandPaletteGroup(title: $0, commands: bySection[$0] ?? []) }
}
/// The command at the top of the list for a query, which Return runs. Nothing for an empty query.
static func firstMatch(in commands: [CommandPaletteCommand], query: String) -> CommandPaletteCommand? {
guard !query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil }
return groups(commands, query: query).first?.commands.first
}
}
/// A searchable list of commands, filtered as you type. Show it in a sheet.
///
/// The search field takes focus when it appears, and Return runs the first match.
/// Running a command closes the sheet first.
struct CommandPalette: View {
let commands: [CommandPaletteCommand]
@State private var query = ""
@State private var isSearching = false
@Environment(\.dismiss) private var dismiss
@ScaledMetric(relativeTo: .body) private var iconSize = 32.0
private var groups: [CommandPaletteGroup] {
CommandPaletteFilter.groups(commands, query: query)
}
var body: some View {
NavigationStack {
List(groups) { group in
Section(group.title) {
ForEach(group.commands, content: row)
}
}
.overlay {
if groups.isEmpty {
ContentUnavailableView.search(text: query)
}
}
.navigationTitle("Commands")
.navigationBarTitleDisplayMode(.inline)
.searchable(
text: $query,
isPresented: $isSearching,
placement: .navigationBarDrawer(displayMode: .always),
prompt: "Type a command"
)
// Return runs the first match, as in a command palette on a keyboard.
.onSubmit(of: .search) {
if let first = CommandPaletteFilter.firstMatch(in: commands, query: query) { run(first) }
}
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Close") { dismiss() }
}
}
}
.task { isSearching = true }
}
private func row(_ command: CommandPaletteCommand) -> some View {
Button {
run(command)
} label: {
ViewThatFits(in: .horizontal) {
HStack(spacing: 12) {
titleLabel(command)
Spacer(minLength: 12)
shortcutChip(command.shortcut)
}
titleLabel(command)
}
.contentShape(.rect)
}
.buttonStyle(.plain)
.accessibilityLabel(command.title)
}
private func run(_ command: CommandPaletteCommand) {
dismiss()
command.action()
}
private func titleLabel(_ command: CommandPaletteCommand) -> some View {
HStack(spacing: 12) {
Image(systemName: command.systemImage)
.font(.subheadline.weight(.semibold))
.foregroundStyle(.tint)
.frame(width: iconSize, height: iconSize)
.background(.tint.opacity(0.14), in: .rect(cornerRadius: 8))
.accessibilityHidden(true)
Text(command.title).font(.body)
}
}
@ViewBuilder
private func shortcutChip(_ shortcut: String?) -> some View {
if let shortcut {
Text(shortcut)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.padding(.horizontal, 7)
.padding(.vertical, 3)
.background(.fill.tertiary, in: .rect(cornerRadius: 6))
.accessibilityHidden(true)
}
}
}
// MARK: - Demo
struct CommandPaletteDemo: View {
@State private var isOpen = false
@State private var lastCommand = "Nothing yet"
private var commands: [CommandPaletteCommand] {
[
command("New Note", section: "Create", image: "square.and.pencil", shortcut: "⌘N"),
command("New Folder", section: "Create", image: "folder.badge.plus", shortcut: "⇧⌘N"),
command("Share Note", section: "Actions", image: "square.and.arrow.up", shortcut: "⌘S"),
command("Duplicate", section: "Actions", image: "plus.square.on.square", shortcut: "⌘D"),
command("Delete", section: "Actions", image: "trash", shortcut: "⌫"),
command("Settings", section: "Go to", image: "gearshape", shortcut: "⌘,"),
command("Trash", section: "Go to", image: "trash.circle"),
command("Help", section: "Go to", image: "questionmark.circle", shortcut: "⌘?"),
]
}
var body: some View {
ZStack {
LinearGradient(colors: [.indigo, .blue], startPoint: .top, endPoint: .bottom)
.ignoresSafeArea()
VStack(spacing: 22) {
Text(lastCommand)
.font(.title2.bold())
.foregroundStyle(.white)
.multilineTextAlignment(.center)
Button {
isOpen = true
} label: {
Label("Open commands", systemImage: "command")
.font(.headline)
.padding(.horizontal, 18)
.padding(.vertical, 12)
.background(.white, in: .capsule)
.foregroundStyle(.indigo)
}
}
.padding(24)
}
.sheet(isPresented: $isOpen) {
CommandPalette(commands: commands)
}
}
private func command(_ title: String, section: String, image: String, shortcut: String? = nil) -> CommandPaletteCommand {
CommandPaletteCommand(id: title, title: title, section: section, systemImage: image, shortcut: shortcut) {
lastCommand = title
}
}
}
#Preview {
CommandPaletteDemo()
}