How to Build a Tag Input in SwiftUI
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
-
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. -
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 bybounds. -
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. -
FocusState border animation —
@FocusState private var isInputFocuseddrives the stroke color and line width on the surroundingRoundedRectangle, giving a native-feeling focus ring. Tapping anywhere in the container forwards focus to the field via.onTapGesture { isInputFocused = true }. - 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
-
⚠️ Layout protocol requires iOS 16+ — the
Layoutprotocol isn't available below iOS 16. If you still target iOS 15, fall back to aLazyVGridwithGridItem(.adaptive(minimum: 80))or measure chip widths manually in aGeometryReader. On iOS 17+ you get the stableLayoutcaching API. -
⚠️ TextField frame inside FlowLayout — without
.frame(minWidth: 80), the text field collapses to zero width when empty and becomes un-tappable. Always set a minimum width; adjust it upward if you want the cursor to be more visible on first focus. -
⚠️ VoiceOver ordering — by default VoiceOver reads children in layout order. Add
.accessibilityElement(children: .contain)to theFlowLayoutcontainer and ensure eachTagChipremove button has an.accessibilityLabel("Remove \(tag) tag")so users can navigate and delete tags without visual context.
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.