swiftcnearly preview
Component library

Combobox

Native combobox composition, package imports, and editable Swift source.

Preview

Combobox rendered by native SwiftUI.Combobox rendered by native SwiftUI. Dark appearance.
Captured from the native SwiftUI catalog on macOS.

Use it

Import SwiftCN to use CNCombobox directly. This complete example also runs in the native component gallery.

ComboboxExample.swift
import SwiftUI
import SwiftCN

struct ComboboxExample: View {
    @State private var optionalSelection: String? = nil
    private let options = [CNOption("design", title: "Design system"), CNOption("mobile", title: "Mobile app"), CNOption("archive", title: "Archived", isDisabled: true)]

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            CNCombobox("Choose a workspace", options: options, selection: $optionalSelection)

        }
    }
}

Native behavior

A searchable custom SwiftUI popover selects stable option IDs. Empty results and disabled options are handled. The selected value remains in the caller binding. Hosted popovers keep their intrinsic size until the host bounds limit them. A native scroll viewport makes oversized content reachable without replacing child controls during resizing.

Style and compose

Visual recipes use .tw classes. Append classes: to override a recipe locally, or change the recipe in TWGlobalRules.named for your subtree. Native layout and interaction modifiers remain SwiftUI APIs. For control colors, append cn-tint-[primary], another theme token, or an interpolated Color.

Motion recipes are editable too: control-motion uses a 150 ms smooth response, disclosure-motion uses a 250 ms spring, and feedback-motion uses a 200 ms smooth response. Set a recipe to animate-none in your global rules to turn it off. Presets respect Reduce Motion. Static components stay still; native scrolling, drag tracking, and platform presentation keep their own gestures.

Action groups and card, dialog, and questionnaire footers stack when their content does not fit. The native layout preserves the same child controls during this change.

View builders preserve native child content. Native Button roles, labels, keyboard shortcuts, submit actions, and presentation modifiers still work. A string recipe does not replace a native control API or a typed chart mark.

Own the source

Copy the complete file below into your app alongside import SwiftCN. The file contains related composable parts from the same family. Its dependencies use public package APIs, so copied and imported components can coexist.

To own the styling engine too, download the complete source bundle. It contains every component, the .tw engine, its recipes, and the MIT license. Keep those files in one module and remove import SwiftCN from the usage example.

Command.swift
import SwiftUI
import SwiftCN

public enum CNOptionSearch {
    public static func filter<ID>(_ options: [CNOption<ID>], query: String) -> [CNOption<ID>] {
        let query = query.trimmingCharacters(in: .whitespacesAndNewlines)
        return query.isEmpty ? options : options.filter {
            $0.title.localizedStandardContains(query) || ($0.detail?.localizedStandardContains(query) ?? false)
        }
    }
}

/// Keyboard and pointer navigation share one enabled, stable option identity.
enum CNCommandNavigation {
    static func reconcile<ID>(_ options: [CNOption<ID>], highlighted: ID?) -> ID? {
        let enabled = options.filter { !$0.isDisabled }
        return enabled.first { $0.id == highlighted }?.id ?? enabled.first?.id
    }
    static func move<ID>(_ options: [CNOption<ID>], highlighted: ID?, direction: Int) -> ID? {
        let enabled = options.filter { !$0.isDisabled }
        guard !enabled.isEmpty else { return nil }
        let index = highlighted.flatMap { id in enabled.firstIndex { $0.id == id } }
        guard let index else { return direction > 0 ? enabled.first?.id : enabled.last?.id }
        return enabled[min(enabled.count - 1, max(0, index + (direction > 0 ? 1 : -1)))].id
    }
}

/// Pointer bookkeeping does not invalidate the view on every mouse movement.
@MainActor final class CNCommandPointer {
    private var location: CGPoint?
    func moved(to next: CGPoint) -> Bool {
        defer { location = next }
        return location != nil && location != next
    }
}

/// Search native buttons with one shared pointer/keyboard highlight and stable IDs.
public struct CNCommand<ID: Hashable & Sendable>: View {
    private let options: [CNOption<ID>]
    @Binding private var selection: ID?
    private let classes: TWClasses
    private let onSelect: (ID) -> Void
    private let query: Binding<String>?
    public init(_ options: [CNOption<ID>], selection: Binding<ID?>, query: Binding<String>? = nil,
                classes: TWClasses = "", onSelect: @escaping (ID) -> Void = { _ in }) {
        self.options = options; _selection = selection; self.query = query
        self.classes = classes; self.onSelect = onSelect
    }
    public var body: some View {
        itemContent { option, selected in CNCommandLabel(option: option, selected: selected) }
    }
    /// Customize native button labels; filtering, highlight, activation, and IDs stay shared.
    public func itemContent<Item: View>(@ViewBuilder _ item: @escaping (CNOption<ID>, Bool) -> Item) -> some View {
        itemContent(item, empty: { CNCommandEmpty() })
    }
    public func itemContent<Item: View, Empty: View>(
        @ViewBuilder _ item: @escaping (CNOption<ID>, Bool) -> Item,
        @ViewBuilder empty: () -> Empty
    ) -> some View {
        CNCommandBody(options: options, selection: $selection, boundQuery: query,
                      classes: classes, onSelect: onSelect, item: item, empty: empty())
    }
}

private struct CNCommandBody<ID: Hashable & Sendable, Item: View, Empty: View>: View {
    let options: [CNOption<ID>]
    @Binding var selection: ID?
    let boundQuery: Binding<String>?
    let classes: TWClasses
    let onSelect: (ID) -> Void
    let item: (CNOption<ID>, Bool) -> Item
    let empty: Empty
    @State private var localQuery = ""
    @State private var highlighted: ID?
    @State private var pointer = CNCommandPointer()
    @Namespace private var pointerSpace
    @FocusState private var searchFocused: Bool
    private var query: Binding<String> { boundQuery ?? $localQuery }
    private var results: [CNOption<ID>] { CNOptionSearch.filter(options, query: query.wrappedValue) }
    public var body: some View {
        let results = self.results
        VStack(alignment: .leading, spacing: 0) {
            CNInputGroup("command-search", focus: $searchFocused) {
                Image(systemName: "magnifyingglass").tw("text-sm text-mutedForeground").accessibilityHidden(true)
                CNInputGroupField("Search…", text: query).accessibilityLabel("Search commands")
                    .onSubmit(activateHighlighted)
            }
            CNSeparator()
            ScrollViewReader { scroll in
                ScrollView {
                    LazyVStack(spacing: 2) {
                        if results.isEmpty {
                            empty
                        }
                        ForEach(results) { option in
                            Button { activate(option) } label: {
                                item(option, selection == option.id)
                            }
                            .buttonStyle(.tw(cn("command-item w-full", highlighted == option.id ? "bg-accent" : "")))
                            .disabled(option.isDisabled).id(option.id)
                            .onContinuousHover(coordinateSpace: .named(pointerSpace)) { phase in
                                if case .active(let location) = phase {
                                    // Opening or scrolling beneath a stationary pointer must not replace keyboard focus.
                                    if pointer.moved(to: location) && !option.isDisabled { highlighted = option.id }
                                }
                            }
                            .accessibilityAddTraits(selection == option.id ? .isSelected : [])
                        }
                    }.tw("p-[6]")
                }
                .coordinateSpace(name: pointerSpace)
                .onChange(of: results, initial: true) { _, options in
                    highlighted = CNCommandNavigation.reconcile(options, highlighted: highlighted)
                    if let highlighted { scroll.scrollTo(highlighted) }
                }
                .onChange(of: highlighted) { _, id in if let id { scroll.scrollTo(id) } }
            }
        }.tw(cn("command min-h-[180]", classes))
            .onAppear { searchFocused = true }
            .onKeyPress(.downArrow) { move(1); return .handled }
            .onKeyPress(.upArrow) { move(-1); return .handled }
    }
    private func activate(_ option: CNOption<ID>) {
        guard !option.isDisabled else { return }
        selection = option.id; onSelect(option.id)
    }
    private func activateHighlighted() {
        if let id = CNCommandNavigation.reconcile(results, highlighted: highlighted),
           let option = results.first(where: { $0.id == id }) { activate(option) }
    }
    private func move(_ direction: Int) {
        highlighted = CNCommandNavigation.move(results, highlighted: highlighted, direction: direction)
    }
}
private struct CNCommandEmpty: View {
    var body: some View { Text("No results found.").tw("w-full py-6 text-sm text-mutedForeground") }
}
private struct CNCommandLabel<ID: Hashable & Sendable>: View {
    let option: CNOption<ID>
    let selected: Bool
    var body: some View {
        HStack(spacing: 10) {
            if let symbol = option.systemImage {
                Image(systemName: symbol).tw("w-[20] text-mutedForeground").accessibilityHidden(true)
            }
            VStack(alignment: .leading, spacing: 3) {
                Text(option.title).lineLimit(1)
                if let detail = option.detail { Text(detail).tw("text-xs text-mutedForeground").lineLimit(1) }
            }
            Spacer(minLength: 8)
            Image(systemName: "checkmark").tw("text-xs text-mutedForeground")
                .opacity(selected ? 1 : 0).accessibilityHidden(true)
        }
    }
}

public struct CNCombobox<ID: Hashable & Sendable>: View {
    private let title: String
    private let options: [CNOption<ID>]
    @Binding private var selection: ID?
    private let classes: TWClasses
    @State private var isPresented = false
    public init(_ title: String, options: [CNOption<ID>], selection: Binding<ID?>, classes: TWClasses = "") {
        self.title = title; self.options = options; _selection = selection; self.classes = classes
    }
    public var body: some View {
        CNPopover(isPresented: $isPresented, classes: cn("combobox", classes)) {
            CNCommand(options, selection: $selection, classes: "w-[300] h-[320]") { _ in isPresented = false }
        } label: {
            HStack {
                Text(options.first { $0.id == selection }?.title ?? title)
                Image(systemName: "chevron.up.chevron.down").accessibilityHidden(true)
            }
        }.accessibilityLabel(title).accessibilityValue(options.first { $0.id == selection }?.title ?? "No selection")
    }
}

On this page