SwiftUI Prompt Suggestions Component

The starter prompts on an empty assistant screen. They arrive on a 45ms stagger — slow enough to read as sequence, fast enough that nobody waits. Wrapped by default via a custom flow layout, because a horizontal rail hides the fourth suggestion off screen and a suggestion nobody sees does no work.

Summarise this thread
Draft a reply
Find the contract terms
What changed since Friday?

Variants: Wrapped, Rail

Install the free SwiftUI prompt suggestions

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

Usage

Usage.swift
SKPromptSuggestions(["Summarise this thread", "Draft a reply"]) { send($0) }
SKPromptSuggestions(prompts, layout: .rail, onSelect: send)

Full source · 192 lines · MIT

SKPromptSuggestions.swift
//
//  SKPromptSuggestions.swift
//  Swift Kit UI
//
//  Requires: SwiftKitUI.swift
//
//  The starter prompts on an empty assistant screen, and the follow-ups after a
//  reply.
//
//  This is the highest-leverage surface in a chat app and it is almost always
//  an afterthought. A blank composer asks the user to invent a use case for
//  your product on the spot; four concrete suggestions teach it in a second.
//
//  They arrive on a stagger — roughly 45ms apart — which is slow enough to read
//  as sequence and fast enough that nobody waits. Appearing all at once is not
//  wrong exactly, it just throws away the one moment where the eye will follow
//  a list in order.
//
//  `.wrapped` is the default because on a phone a horizontal rail hides the
//  fourth suggestion off-screen, and a suggestion nobody sees does no work.
//
//  Usage:
//      SKPromptSuggestions(["Summarise this thread", "Draft a reply"]) { send($0) }
//      SKPromptSuggestions(prompts, layout: .rail, onSelect: send)
//

import SwiftUI

public struct SKPrompt: Identifiable, Sendable, Hashable {
    public let id: String
    public let text: String
    public let systemImage: String?

    public init(_ text: String, systemImage: String? = nil, id: String? = nil) {
        self.id = id ?? text
        self.text = text
        self.systemImage = systemImage
    }
}

public struct SKPromptSuggestions: View {
    public enum Layout: Sendable { case wrapped, rail }

    private let prompts: [SKPrompt]
    private let layout: Layout
    private let onSelect: (SKPrompt) -> Void

    @Environment(\.accessibilityReduceMotion) private var reduceMotion
    @State private var shown = false

    public init(
        _ prompts: [SKPrompt],
        layout: Layout = .wrapped,
        onSelect: @escaping (SKPrompt) -> Void
    ) {
        self.prompts = prompts
        self.layout = layout
        self.onSelect = onSelect
    }

    public init(
        _ texts: [String],
        layout: Layout = .wrapped,
        onSelect: @escaping (String) -> Void
    ) {
        self.init(texts.map { SKPrompt($0) }, layout: layout) { onSelect($0.text) }
    }

    public var body: some View {
        Group {
            switch layout {
            case .rail:
                ScrollView(.horizontal, showsIndicators: false) {
                    HStack(spacing: SK.Space.sm) { chips }
                        .padding(.horizontal, SK.Space.lg)
                }
                // Cancels the rail's own scroll gesture hijacking the vertical
                // scroll of the transcript it usually sits inside.
                .scrollBounceBehavior(.basedOnSize, axes: .horizontal)

            case .wrapped:
                SKPromptFlow(spacing: SK.Space.sm) { chips }
            }
        }
        .onAppear { shown = true }
    }

    private var chips: some View {
        ForEach(Array(prompts.enumerated()), id: \.element.id) { index, prompt in
            Button {
                onSelect(prompt)
                SK.Haptic.play(.light)
            } label: {
                HStack(spacing: SK.Space.xs + 2) {
                    if let symbol = prompt.systemImage {
                        Image(systemName: symbol)
                            .font(SK.Icon.symbol(SK.Icon.xs, weight: .semibold))
                            .foregroundStyle(SK.Palette.accent)
                    }
                    Text(prompt.text)
                        .font(SK.Type_.caption)
                        .foregroundStyle(SK.Palette.label)
                        .lineLimit(1)
                }
                .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))
            .opacity(shown ? 1 : 0)
            .offset(y: shown ? 0 : 10)
            // `skAnimation` has no delay parameter, so the stagger is applied
            // to the resolved curve directly — still routed through SKMotion,
            // and still nil under Reduce Motion.
            .animation(
                SKMotion.settle.resolved(reduceMotion: reduceMotion)?
                    .delay(Double(index) * 0.045),
                value: shown
            )
        }
    }
}

/// Minimal flow layout: lays chips left to right and wraps.
///
/// `LazyVGrid` cannot do this — its columns are fixed, so "Summarise" and
/// "Draft a reply to the procurement thread" would be given identical widths
/// and the short one would sit in a puddle of empty space.
private struct SKPromptFlow: SwiftUI.Layout {
    var spacing: CGFloat

    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
        let maxWidth = proposal.width ?? .infinity
        var x: CGFloat = 0, y: CGFloat = 0, rowHeight: CGFloat = 0

        for subview in subviews {
            let size = subview.sizeThatFits(.unspecified)
            if x > 0, x + size.width > maxWidth {
                x = 0
                y += rowHeight + spacing
                rowHeight = 0
            }
            x += size.width + spacing
            rowHeight = max(rowHeight, size.height)
        }
        return CGSize(width: proposal.width ?? x, height: y + rowHeight)
    }

    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
        var x = bounds.minX, y = bounds.minY, rowHeight: CGFloat = 0

        for subview in subviews {
            let size = subview.sizeThatFits(.unspecified)
            if x > bounds.minX, x + size.width > bounds.maxX {
                x = bounds.minX
                y += rowHeight + spacing
                rowHeight = 0
            }
            subview.place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size))
            x += size.width + spacing
            rowHeight = max(rowHeight, size.height)
        }
    }
}

#Preview("SKPromptSuggestions") {
    @Previewable @State var picked = "Nothing yet"

    VStack(alignment: .leading, spacing: SK.Space.xl) {
        SKPromptSuggestions([
            SKPrompt("Summarise this thread", systemImage: "text.alignleft"),
            SKPrompt("Draft a reply", systemImage: "arrowshape.turn.up.left"),
            SKPrompt("Find the contract terms", systemImage: "magnifyingglass"),
            SKPrompt("What changed since Friday?", systemImage: "clock.arrow.circlepath"),
        ]) { picked = $0.text }

        Divider().overlay(SK.Palette.separator)

        SKPromptSuggestions(["Explain", "Shorten", "Add sources"], layout: .rail) { picked = $0 }

        Text(picked)
            .font(SK.Type_.caption)
            .foregroundStyle(SK.Palette.labelTertiary)
    }
    .padding(SK.Space.lg)
    .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
    .background(SK.Palette.surface)
}

Questions

Is this SwiftUI prompt suggestions 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?
SKPromptSuggestions.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.