SwiftUI Model Picker Component

Model selection in plain language: every row carries a subtitle saying what the trade is and a badge for fast, balanced or powerful, with the raw identifier kept in monospace for the people who want it. Locked models are shown rather than hidden, because a model the user cannot reach is the most persuasive upgrade prompt an AI app has.

Haiku 4.5Fast
Quick answers and short edits.
Sonnet 5Balanced
The default. Handles most work.
Opus 5Powerful
Long documents and hard reasoning.

Variants: Button + sheet, Inline list

Install the free SwiftUI model picker

  1. Add SwiftKitUI.swift to your app once. It holds the tokens and motion curves.
  2. Copy SKModelPicker.swift below into your project. It needs nothing else.

Usage

Usage.swift
SKModelPicker(models: models, selection: $model)
SKModelPicker(models: models, selection: $model, style: .list)

Full source · 271 lines · MIT

SKModelPicker.swift
//
//  SKModelPicker.swift
//  Swift Kit UI
//
//  Requires: SwiftKitUI.swift
//
//  Choosing a model, without making the user learn your model line-up.
//
//  The mistake here is listing identifiers. `gpt-4o-mini-2024-07-18` tells a
//  normal person nothing, so every row carries a plain-language subtitle and a
//  single badge saying what the trade is — fast, balanced, powerful. The
//  identifier stays available in monospace for the people who want it, at a
//  size that says "you may ignore this".
//
//  Locked models are shown, not hidden. A model the user cannot reach is the
//  most persuasive upgrade prompt an AI app has, and hiding it means they never
//  learn the better thing exists. Tapping one calls `onLocked` rather than
//  silently doing nothing.
//
//  Usage:
//      SKModelPicker(models: models, selection: $model)
//      SKModelPicker(models: models, selection: $model, style: .list)
//

import SwiftUI

public struct SKModel: Identifiable, Sendable, Hashable {
    public enum Badge: Sendable, Hashable {
        case fast, balanced, powerful, new

        var label: String {
            switch self {
            case .fast: "Fast"
            case .balanced: "Balanced"
            case .powerful: "Powerful"
            case .new: "New"
            }
        }

        var tint: Color {
            switch self {
            case .fast: SK.Palette.success
            case .balanced: SK.Palette.accent
            case .powerful: SK.Palette.warning
            case .new: SK.Palette.accent
            }
        }

        var softTint: Color {
            switch self {
            case .fast: SK.Palette.successSoft
            case .balanced: SK.Palette.accentSoft
            case .powerful: SK.Palette.warningSoft
            case .new: SK.Palette.accentSoft
            }
        }
    }

    public let id: String
    public let name: String
    public let subtitle: String
    public let badge: Badge?
    public let isLocked: Bool

    public init(
        id: String,
        name: String,
        subtitle: String,
        badge: Badge? = nil,
        isLocked: Bool = false
    ) {
        self.id = id
        self.name = name
        self.subtitle = subtitle
        self.badge = badge
        self.isLocked = isLocked
    }
}

public struct SKModelPicker: View {
    public enum Style: Sendable {
        /// A compact button that opens the list in a sheet.
        case button
        /// The rows on their own, for embedding in an existing settings screen.
        case list
    }

    private let models: [SKModel]
    @Binding private var selection: SKModel.ID
    private let style: Style
    private let onLocked: ((SKModel) -> Void)?

    @State private var presenting = false

    public init(
        models: [SKModel],
        selection: Binding<SKModel.ID>,
        style: Style = .button,
        onLocked: ((SKModel) -> Void)? = nil
    ) {
        self.models = models
        self._selection = selection
        self.style = style
        self.onLocked = onLocked
    }

    private var selected: SKModel? {
        models.first { $0.id == selection }
    }

    public var body: some View {
        switch style {
        case .list: rows
        case .button: trigger
        }
    }

    // MARK: Trigger

    private var trigger: some View {
        Button {
            presenting = true
            SK.Haptic.play(.light)
        } label: {
            HStack(spacing: SK.Space.sm) {
                Image(systemName: "sparkles")
                    .font(SK.Icon.symbol(SK.Icon.xs, weight: .semibold))
                    .foregroundStyle(SK.Palette.accent)
                Text(selected?.name ?? "Select model")
                    .font(SK.Type_.caption)
                    .foregroundStyle(SK.Palette.label)
                    .lineLimit(1)
                Image(systemName: "chevron.up.chevron.down")
                    .font(SK.Icon.symbol(SK.Icon.xs, weight: .bold))
                    .foregroundStyle(SK.Palette.labelTertiary)
            }
            .padding(.horizontal, SK.Space.md)
            .padding(.vertical, SK.Space.sm)
            .background(SK.Palette.fill, in: Capsule())
            .overlay { Capsule().strokeBorder(SK.Palette.separator, lineWidth: SK.Stroke.thin) }
        }
        .buttonStyle(.skPress(scale: 0.96, haptic: nil))
        .accessibilityLabel("Model: \(selected?.name ?? "none selected")")
        .sheet(isPresented: $presenting) {
            NavigationStack {
                ScrollView { rows.padding(SK.Space.lg) }
                    .background(SK.Palette.surface)
                    .navigationTitle("Model")
                    #if os(iOS)
                    .navigationBarTitleDisplayMode(.inline)
                    #endif
            }
            // Sized to content rather than a full sheet: a five-row list
            // presented full-height reads as a screen change, not a choice.
            .presentationDetents([.medium, .large])
            .presentationDragIndicator(.visible)
        }
    }

    // MARK: Rows

    private var rows: some View {
        VStack(spacing: SK.Space.sm) {
            ForEach(models) { model in
                row(model)
            }
        }
    }

    private func row(_ model: SKModel) -> some View {
        let isSelected = model.id == selection

        return Button {
            guard !model.isLocked else {
                onLocked?(model)
                SK.Haptic.play(.warning)
                return
            }
            selection = model.id
            SK.Haptic.play(.selection)
            presenting = false
        } label: {
            HStack(alignment: .top, spacing: SK.Space.md) {
                VStack(alignment: .leading, spacing: 3) {
                    HStack(spacing: SK.Space.sm) {
                        Text(model.name)
                            .font(SK.Type_.callout)
                            .foregroundStyle(model.isLocked ? SK.Palette.labelSecondary : SK.Palette.label)

                        if let badge = model.badge {
                            Text(badge.label)
                                .font(SK.Type_.caption)
                                .foregroundStyle(badge.tint)
                                .padding(.horizontal, SK.Space.sm)
                                .padding(.vertical, 1)
                                .background(badge.softTint, in: Capsule())
                        }

                        if model.isLocked {
                            Image(systemName: "lock.fill")
                                .font(SK.Icon.symbol(SK.Icon.xs))
                                .foregroundStyle(SK.Palette.labelTertiary)
                        }
                    }

                    Text(model.subtitle)
                        .font(SK.Type_.caption)
                        .foregroundStyle(SK.Palette.labelTertiary)
                        .multilineTextAlignment(.leading)
                        .fixedSize(horizontal: false, vertical: true)

                    Text(model.id)
                        .font(SK.Type_.monoCaption)
                        .foregroundStyle(SK.Palette.labelTertiary)
                        .opacity(0.65)
                }

                Spacer(minLength: 0)

                Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
                    .font(SK.Icon.symbol(SK.Icon.lg))
                    .foregroundStyle(isSelected ? SK.Palette.accent : SK.Palette.separator)
                    .opacity(model.isLocked ? 0.4 : 1)
            }
            .padding(SK.Space.md)
            .background(
                isSelected ? SK.Palette.accentSoft : SK.Palette.surfaceElevated,
                in: RoundedRectangle(cornerRadius: SK.Radius.md, style: .continuous)
            )
            .overlay {
                RoundedRectangle(cornerRadius: SK.Radius.md, style: .continuous)
                    .strokeBorder(
                        isSelected ? SK.Palette.accent : SK.Palette.separator,
                        lineWidth: isSelected ? SK.Stroke.thick : SK.Stroke.thin
                    )
            }
            .opacity(model.isLocked ? 0.75 : 1)
            .contentShape(Rectangle())
        }
        .buttonStyle(.skPress(scale: 0.99, haptic: nil))
        .skAnimation(.snap, value: isSelected)
        .accessibilityElement(children: .combine)
        .accessibilityLabel("\(model.name). \(model.subtitle)")
        .accessibilityValue(model.isLocked ? "Locked" : (isSelected ? "Selected" : ""))
    }
}

#Preview("SKModelPicker") {
    @Previewable @State var selection = "claude-sonnet-5"

    let models = [
        SKModel(id: "claude-haiku-4-5", name: "Haiku 4.5",
                subtitle: "Quick answers and short edits. Cheapest per message.",
                badge: .fast),
        SKModel(id: "claude-sonnet-5", name: "Sonnet 5",
                subtitle: "The default. Handles most work without thinking about cost.",
                badge: .balanced),
        SKModel(id: "claude-opus-5", name: "Opus 5",
                subtitle: "Long documents, hard reasoning, code that has to be right.",
                badge: .powerful, isLocked: true),
    ]

    VStack(alignment: .leading, spacing: SK.Space.xl) {
        SKModelPicker(models: models, selection: $selection, onLocked: { _ in })
        SKModelPicker(models: models, selection: $selection, style: .list, onLocked: { _ in })
    }
    .padding(SK.Space.lg)
    .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
    .background(SK.Palette.surface)
}

Questions

Is this SwiftUI model picker free for commercial apps?
Yes. Swift Kit UI is MIT licensed, so you can use it in personal and commercial apps with no payment or sign-up. Keep the licence notice with the code.
What does it depend on?
SKModelPicker.swift depends only on SwiftKitUI.swift, a single foundation file with the design tokens and motion curves. There are no third-party packages.
Which iOS versions does it support?
iOS 17 and later, plus macOS 14.

Related components

Building a whole app? The Swift Kit adds auth, payments and onboarding, ready to ship.