Components PulseCommandPalette
PulseCommandPalette
Power-user navigation in one keystroke.
interactive demo
Search commands⌘ K
+New projectEnter
◎Open settings
A live browser stand-in using the exact tokens, motion and API of the SwiftUI component.
Want all components at once?
Open the studio
Power-user navigation in one keystroke.
A focused command surface for search, navigation and actions with keyboard-first behavior.
Searchable actions
Escape to dismiss
Keyboard navigation on macOS
VoiceOver labels
Usage
PulseCommandPalette(isPresented: $show, actions: commands) Install
$ pulse add command_palette Source files
Sources/PulseUI/Components/PulseCommandPalette/PulseCommandPalette.swift
Full source Sources/PulseUI/Components/PulseCommandPalette/PulseCommandPalette.swift
import SwiftUI
// MARK: - PulseCommandPalette
public struct PulseCommandAction: Identifiable {
public let id: String
public let title: String
public let subtitle: String?
public let systemImage: String
public let action: () -> Void
public init(
id: String,
title: String,
subtitle: String? = nil,
systemImage: String = "command",
action: @escaping () -> Void
) {
self.id = id
self.title = title
self.subtitle = subtitle
self.systemImage = systemImage
self.action = action
}
}
/// A keyboard-first command surface for navigation and power-user actions.
public struct PulseCommandPalette: View {
@Environment(\.pulseTheme) private var theme
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@Binding private var isPresented: Bool
private let title: String
private let actions: [PulseCommandAction]
@State private var query = ""
@State private var selectedIndex = 0
@FocusState private var searchFocused: Bool
public init(
isPresented: Binding<Bool>,
title: String = "Search commands",
actions: [PulseCommandAction]
) {
self._isPresented = isPresented
self.title = title
self.actions = actions
}
public var body: some View {
if isPresented {
ZStack {
Color.black.opacity(0.28)
.ignoresSafeArea()
.onTapGesture { dismiss() }
palette
.frame(maxWidth: 560)
.padding(.horizontal, theme.spacing.lg)
.transition(
reduceMotion
? .opacity
: .scale(scale: 0.96).combined(with: .opacity)
)
}
.zIndex(100)
.onAppear { searchFocused = true }
#if os(macOS)
.onMoveCommand { direction in
switch direction {
case .down: selectedIndex = min(selectedIndex + 1, filteredActions.count - 1)
case .up: selectedIndex = max(selectedIndex - 1, 0)
default: break
}
}
#endif
.animation(reduceMotion ? .none : theme.motion.springSnappy, value: isPresented)
}
}
private var filteredActions: [PulseCommandAction] {
guard !query.isEmpty else { return actions }
return actions.filter {
$0.title.localizedCaseInsensitiveContains(query)
|| ($0.subtitle?.localizedCaseInsensitiveContains(query) ?? false)
}
}
private var palette: some View {
VStack(spacing: 0) {
HStack(spacing: theme.spacing.sm) {
Image(systemName: "magnifyingglass")
.foregroundStyle(theme.colors.foregroundTertiary)
TextField(title, text: $query)
.textFieldStyle(.plain)
.font(theme.typography.body.font)
.focused($searchFocused)
.onSubmit { runSelected() }
Button("Close", systemImage: "xmark") { dismiss() }
.labelStyle(.iconOnly)
.buttonStyle(.plain)
.foregroundStyle(theme.colors.foregroundSecondary)
.keyboardShortcut(.escape)
}
.padding(theme.spacing.md)
Divider()
if filteredActions.isEmpty {
ContentUnavailableView("No commands", systemImage: "magnifyingglass")
.frame(minHeight: 120)
} else {
ScrollView {
LazyVStack(spacing: 4) {
ForEach(Array(filteredActions.enumerated()), id: \.element.id) { index, item in
Button {
item.action()
dismiss()
} label: {
HStack(spacing: theme.spacing.md) {
Image(systemName: item.systemImage)
.frame(width: 24)
VStack(alignment: .leading, spacing: 2) {
Text(item.title)
.font(theme.typography.body.font)
if let subtitle = item.subtitle {
Text(subtitle)
.font(theme.typography.caption.font)
.foregroundStyle(theme.colors.foregroundSecondary)
}
}
Spacer()
if index == selectedIndex {
Image(systemName: "return")
.font(.caption)
.foregroundStyle(theme.colors.foregroundTertiary)
}
}
.foregroundStyle(theme.colors.foreground)
.padding(.horizontal, theme.spacing.md)
.padding(.vertical, 10)
.background(
index == selectedIndex
? theme.colors.accent.opacity(0.14)
: .clear
)
.clipShape(RoundedRectangle(cornerRadius: theme.radius.md, style: .continuous))
}
.buttonStyle(.plain)
}
}
.padding(theme.spacing.sm)
}
.frame(maxHeight: 360)
}
}
.background(theme.colors.surfaceElevated)
.clipShape(RoundedRectangle(cornerRadius: theme.radius.xl, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: theme.radius.xl, style: .continuous)
.stroke(theme.colors.border.opacity(0.8), lineWidth: 1)
}
.pulseGlass(cornerRadius: theme.radius.xl)
.accessibilityElement(children: .contain)
.accessibilityLabel(title)
}
private func runSelected() {
guard filteredActions.indices.contains(selectedIndex) else { return }
filteredActions[selectedIndex].action()
dismiss()
}
private func dismiss() {
searchFocused = false
isPresented = false
query = ""
selectedIndex = 0
}
}