```html SwiftUI: How to Build Tag Input (iOS 17+, 2026)

How to Build a Tag Input in SwiftUI

iOS 17+ Xcode 16+ Intermediate APIs: FlowLayout / TextField Updated: May 12, 2026
TL;DR

Implement a custom FlowLayout via the Layout protocol, render tag chips inside it, and drop a TextField at the end as the last child. On onSubmit, append the trimmed input to your tags array and clear the field.

struct TagInputView: View {
    @State private var tags: [String] = ["SwiftUI"]
    @State private var input: String = ""

    var body: some View {
        FlowLayout(spacing: 8) {
            ForEach(tags, id: \.self) { tag in
                TagChip(tag: tag) { tags.removeAll { $0 == tag } }
            }
            TextField("Add tag…", text: $input)
                .onSubmit {
                    let t = input.trimmingCharacters(in: .whitespaces)
                    if !t.isEmpty, !tags.contains(t) { tags.append(t) }
                    input = ""
                }
                .frame(minWidth: 80)
        }
        .padding(12)
    }
}

Full implementation

SwiftUI has no built-in wrapping flow layout, so we implement one using the Layout protocol (available since iOS 16, fully stable in iOS 17). The FlowLayout type measures each subview and wraps to a new row whenever adding the next item would exceed the available width. Tags live as TagChip views; the TextField is placed as the final child in the same layout so it flows naturally after all chips. FocusState drives the animated border highlight when the field is active.

import SwiftUI

// MARK: – FlowLayout

struct FlowLayout: Layout {
    var spacing: CGFloat = 8

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

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

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

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

// MARK: – TagChip

struct TagChip: View {
    let tag: String
    let onRemove: () -> Void

    var body: some View {
        HStack(spacing: 4) {
            Text(tag)
                .font(.subheadline)
            Button(action: onRemove) {
                Image(systemName: "xmark.circle.fill")
                    .font(.caption)
                    .foregroundStyle(.secondary)
            }
            .buttonStyle(.plain)
            .accessibilityLabel("Remove \(tag) tag")
        }
        .padding(.horizontal, 10)
        .padding(.vertical, 6)
        .background(Color.accentColor.opacity(0.12), in: Capsule())
        .foregroundStyle(Color.accentColor)
        .transition(.scale.combined(with: .opacity))
    }
}

// MARK: – TagInputView

struct TagInputView: View {
    @State private var tags: [String] = ["SwiftUI", "iOS 17", "Forms"]
    @State private var inputText: String = ""
    @FocusState private var isInputFocused: Bool

    var body: some View {
        VStack(alignment: .leading, spacing: 10) {
            Text("Tags")
                .font(.headline)
                .accessibilityAddTraits(.isHeader)

            FlowLayout(spacing: 8) {
                ForEach(tags, id: \.self) { tag in
                    TagChip(tag: tag) {
                        withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) {
                            tags.removeAll { $0 == tag }
                        }
                    }
                }

                TextField("Add tag…", text: $inputText)
                    .focused($isInputFocused)
                    .textInputAutocapitalization(.never)
                    .autocorrectionDisabled()
                    .submitLabel(.done)
                    .onSubmit { addTag() }
                    .frame(minWidth: 80)
                    .accessibilityLabel("New tag text field")
            }
            .padding(12)
            .background(
                RoundedRectangle(cornerRadius: 14)
                    .stroke(
                        isInputFocused ? Color.accentColor : Color(.systemGray4),
                        lineWidth: isInputFocused ? 2 : 1
                    )
            )
            .contentShape(Rectangle())
            .onTapGesture { isInputFocused = true }
            .animation(.easeInOut(duration: 0.2), value: isInputFocused)

            if !tags.isEmpty {
                Text("\(tags.count) tag\(tags.count == 1 ? "" : "s")")
                    .font(.caption)
                    .foregroundStyle(.secondary)
            }
        }
        .padding()
    }

    private func addTag() {
        let trimmed = inputText.trimmingCharacters(in: .whitespaces)
        guard !trimmed.isEmpty, !tags.contains(trimmed) else {
            inputText = ""
            return
        }
        withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) {
            tags.append(trimmed)
        }
        inputText = ""
    }
}

// MARK: – Preview

#Preview("Tag Input") {
    TagInputView()
        .padding()
        .background(Color(.systemGroupedBackground))
}

#Preview("Many Tags") {
    struct ManyTagsPreview: View {
        @State private var tags = ["Xcode", "Swift", "SwiftUI", "Combine",
                                    "Async/Await", "CloudKit", "WidgetKit",
                                    "AppIntents", "StoreKit"]
        @State private var input = ""
        var body: some View {
            TagInputView()
        }
    }
    return ManyTagsPreview()
}

How it works

  1. FlowLayout.sizeThatFits — iterates every subview, accumulates row widths, and starts a new row whenever rowWidth + size.width > maxWidth. The returned height is the sum of all row heights plus inter-row spacing, so SwiftUI knows exactly how tall the container needs to be.
  2. FlowLayout.placeSubviews — mirrors the measurement pass, calling subview.place(at:proposal:) for each chip and the text field. Coordinates are in the layout's local coordinate space defined by bounds.
  3. TagChip transitions — wrapping state mutations in withAnimation(.spring(...)) combined with .transition(.scale.combined(with: .opacity)) gives chips a satisfying pop-in / shrink-out when added or removed.
  4. FocusState border animation@FocusState private var isInputFocused drives the stroke color and line width on the surrounding RoundedRectangle, giving a native-feeling focus ring. Tapping anywhere in the container forwards focus to the field via .onTapGesture { isInputFocused = true }.
  5. addTag() guard — the function trims whitespace and checks for duplicates before appending. If either condition fails, the input is still cleared, preventing invisible phantom tags.

Variants

Backspace-to-delete last tag

SwiftUI's TextField doesn't expose a backspace key event directly, but you can observe when the field transitions from empty to empty again via onChange and a sentinel approach, or use a lightweight UIViewRepresentable wrapper that intercepts deleteBackward.

// UIViewRepresentable wrapper that fires a closure on backspace
struct BackspaceTextField: UIViewRepresentable {
    @Binding var text: String
    var onBackspace: () -> Void

    class Coordinator: NSObject, UITextFieldDelegate {
        var parent: BackspaceTextField
        init(_ parent: BackspaceTextField) { self.parent = parent }

        func textField(_ tf: UITextField,
                       shouldChangeCharactersIn range: NSRange,
                       replacementString string: String) -> Bool {
            if string.isEmpty, (tf.text ?? "").isEmpty {
                parent.onBackspace()
            }
            return true
        }
    }

    func makeCoordinator() -> Coordinator { Coordinator(self) }

    func makeUIView(context: Context) -> UITextField {
        let tf = UITextField()
        tf.delegate = context.coordinator
        tf.placeholder = "Add tag…"
        tf.autocorrectionType = .no
        tf.autocapitalizationType = .none
        return tf
    }

    func updateUIView(_ uiView: UITextField, context: Context) {
        if uiView.text != text { uiView.text = text }
    }
}

// Usage inside TagInputView body:
BackspaceTextField(text: $inputText) {
    withAnimation(.spring(response: 0.3)) {
        _ = tags.popLast()
    }
}
.frame(minWidth: 80, minHeight: 30)

Suggestion dropdown

Attach a .overlay(alignment: .bottom) to the tag container that renders a filtered list of suggestions based on inputText. Use a ZStack or popover(isPresented:) on iPhone/iPad respectively. Filter with allSuggestions.filter { $0.localizedCaseInsensitiveContains(inputText) } and call addTag() programmatically when a suggestion row is tapped.

Common pitfalls

Prompt this with Claude Code

When using Soarias or Claude Code directly to implement this:

Implement a tag input component in SwiftUI for iOS 17+.
Use a custom FlowLayout (Layout protocol) and TextField.
Chips should animate in/out with .spring().
Make it accessible (VoiceOver labels on each remove button).
Add a #Preview with realistic sample data (5–8 tech-topic tags).

Drop this prompt into Soarias during the Build phase after your screen mockup is approved — Claude Code will scaffold the component, wire it to your @Model entity, and generate the preview in one shot.

Related

FAQ

Does this work on iOS 16?

Yes — the Layout protocol was introduced in iOS 16, so FlowLayout compiles and runs there. However, the #Preview macro and some spring animation overloads require Xcode 15+ / iOS 17+ to compile. If you target iOS 16, replace .spring(response:dampingFraction:) with .interactiveSpring() and swap the #Preview macro for a PreviewProvider.

How do I bind the tag array to a SwiftData model?

Declare a [String] property on your @Model class — SwiftData supports arrays of Codable scalars natively. Pass a Binding into TagInputView via @Binding var tags: [String] and replace the @State declaration. The ModelContext will persist changes automatically when you mutate the array.

What's the UIKit equivalent?

In UIKit you'd typically use a UICollectionView with a UICollectionViewCompositionalLayout in estimated-height mode, adding a custom UICollectionViewCell for each tag chip and a final cell wrapping a UITextField. It's roughly four times as much code. The SwiftUI Layout approach is substantially more concise and easier to animate.

Last reviewed: 2026-05-12 by the Soarias team.

```