- Published on
- Updated Nov 5, 2026
- Tested on Xcode 27.1
- 8 min read
> Managing Keyboard Toolbar Items in SwiftUI (iOS 15+)
To add buttons above the keyboard in SwiftUI, use .toolbar { ToolbarItemGroup(placement: .keyboard) { ... } } on iOS 15 or later, and pair it with @FocusState so a Done button can dismiss the keyboard. You don't need UIKit's inputAccessoryView for it.
I ran every example below in the sample app on an iPhone 17 simulator running iOS 27.0, built with Xcode 27.1, and drove it with a UI test that typed into each field and tapped each toolbar button. The screenshots in this post come from that run.
| What you want | How it behaved on iOS 27.0 |
|---|---|
| Done button | focusedField = nil dismissed the keyboard and the toolbar together |
| Buttons that depend on the focused field | Contents switched as soon as focus moved between fields |
| Previous / Next | Setting @FocusState moved focus; .disabled greyed the chevron at each end |
| Live values (counter) | A Text in the toolbar updated on every keystroke |
What it looks like on iOS 27
If you remember the old full-width grey bar above the keyboard, the current look is different. On iOS 27.0 there's no bar at all: each group of items floats above the keyboard as its own rounded glass capsule. A Spacer() splits items into separate capsules on the left and right, and buttons placed next to each other share one capsule.

The Traditional Problem
Before iOS 15, adding buttons above the keyboard required either:
- Using
UITextFieldorUITextViewwithinputAccessoryView(UIKit bridge) - Complex positioning with keyboard notification observers
- Third-party libraries
All of these approaches were cumbersome and didn't feel native to SwiftUI.
The Modern Solution: Keyboard Toolbar
iOS 15+ provides .toolbar with .keyboard placement:
import SwiftUI
struct KeyboardToolbarExample: View {
@State private var text = ""
@FocusState private var isFocused: Bool
var body: some View {
TextEditor(text: $text)
.focused($isFocused)
.toolbar {
ToolbarItemGroup(placement: .keyboard) {
Spacer()
Button("Done") {
isFocused = false
}
.bold()
}
}
.padding()
}
}
How It Works
The keyboard toolbar uses SwiftUI's toolbar system:
.toolbar: Declares toolbar items for the viewToolbarItemGroup(placement: .keyboard): Places items above the keyboard@FocusState: Tracks and controls keyboard focus- Automatic animations: SwiftUI handles appearance/dismissal
The toolbar only appears when a focused text field brings up the keyboard.
Done Button Pattern
The most common use case - dismissing the keyboard:
struct DoneButtonExample: View {
@State private var email = ""
@State private var password = ""
@FocusState private var focusedField: Field?
enum Field {
case email, password
}
var body: some View {
Form {
TextField("Email", text: $email)
.focused($focusedField, equals: .email)
.textContentType(.emailAddress)
.autocapitalization(.none)
SecureField("Password", text: $password)
.focused($focusedField, equals: .password)
.textContentType(.password)
}
.toolbar {
ToolbarItemGroup(placement: .keyboard) {
Spacer()
Button("Done") {
focusedField = nil
}
.foregroundColor(.blue)
}
}
}
}
Multiple Toolbar Items
Add several buttons to the keyboard toolbar:
struct RichTextEditor: View {
@State private var text = ""
@FocusState private var isFocused: Bool
var body: some View {
VStack {
TextEditor(text: $text)
.focused($isFocused)
.border(Color.gray.opacity(0.3), width: 1)
.frame(height: 200)
.padding()
Spacer()
}
.toolbar {
ToolbarItemGroup(placement: .keyboard) {
// Formatting buttons
Button {
insertMarkdown("**")
} label: {
Image(systemName: "bold")
}
Button {
insertMarkdown("*")
} label: {
Image(systemName: "italic")
}
Button {
insertMarkdown("`")
} label: {
Image(systemName: "chevron.left.forwardslash.chevron.right")
}
Spacer()
// Done button
Button("Done") {
isFocused = false
}
.bold()
}
}
}
func insertMarkdown(_ marker: String) {
text += marker
}
}
Field-Specific Toolbars
Show different toolbar buttons based on which field is focused:
struct ConditionalToolbar: View {
@State private var title = ""
@State private var amount = ""
@State private var notes = ""
@FocusState private var focusedField: Field?
enum Field {
case title, amount, notes
}
var body: some View {
Form {
Section("Expense Details") {
TextField("Title", text: $title)
.focused($focusedField, equals: .title)
TextField("Amount", text: $amount)
.focused($focusedField, equals: .amount)
.keyboardType(.decimalPad)
TextEditor(text: $notes)
.focused($focusedField, equals: .notes)
.frame(height: 100)
}
}
.toolbar {
ToolbarItemGroup(placement: .keyboard) {
// Show number shortcuts only for amount field
if focusedField == .amount {
HStack(spacing: 12) {
ForEach(["10", "25", "50", "100"], id: \.self) { value in
Button(value) {
amount = value
}
.buttonStyle(.bordered)
}
}
}
Spacer()
Button("Done") {
focusedField = nil
}
}
}
}
}
In the run, focusing Title showed only Done. Focusing Amount brought up the decimal pad with 10, 25, 50 and 100 in one capsule on the left, and tapping 25 filled the field. The .bordered style on each button didn't draw separate bordered pills: they rendered as tinted text inside the shared capsule.

Navigation Between Fields
Add Previous/Next buttons to move between text fields:
struct NavigableFields: View {
@State private var field1 = ""
@State private var field2 = ""
@State private var field3 = ""
@FocusState private var focusedField: Field?
enum Field: Int, CaseIterable {
case field1, field2, field3
}
var body: some View {
Form {
TextField("First Name", text: $field1)
.focused($focusedField, equals: .field1)
TextField("Last Name", text: $field2)
.focused($focusedField, equals: .field2)
TextField("Email", text: $field3)
.focused($focusedField, equals: .field3)
}
.toolbar {
ToolbarItemGroup(placement: .keyboard) {
Button {
moveToPrevious()
} label: {
Image(systemName: "chevron.up")
}
.disabled(!hasPrevious)
Button {
moveToNext()
} label: {
Image(systemName: "chevron.down")
}
.disabled(!hasNext)
Spacer()
Button("Done") {
focusedField = nil
}
}
}
}
var hasPrevious: Bool {
guard let current = focusedField else { return false }
return current.rawValue > 0
}
var hasNext: Bool {
guard let current = focusedField else { return false }
return current.rawValue < Field.allCases.count - 1
}
func moveToPrevious() {
guard let current = focusedField,
current.rawValue > 0,
let previous = Field(rawValue: current.rawValue - 1) else { return }
focusedField = previous
}
func moveToNext() {
guard let current = focusedField,
current.rawValue < Field.allCases.count - 1,
let next = Field(rawValue: current.rawValue + 1) else { return }
focusedField = next
}
}
With Email focused, the down chevron was disabled and the up chevron active, and both chevrons sat together in one capsule.

Styled Toolbar Buttons
Customize the appearance of toolbar buttons:
struct StyledToolbar: View {
@State private var text = ""
@FocusState private var isFocused: Bool
var body: some View {
TextEditor(text: $text)
.focused($isFocused)
.toolbar {
ToolbarItemGroup(placement: .keyboard) {
Button {
// Action
} label: {
Label("Bold", systemImage: "bold")
.labelStyle(.iconOnly)
}
.buttonStyle(.bordered)
.tint(.blue)
Button {
// Action
} label: {
Label("Italic", systemImage: "italic")
.labelStyle(.iconOnly)
}
.buttonStyle(.bordered)
.tint(.blue)
Spacer()
Button("Done") {
isFocused = false
}
.buttonStyle(.borderedProminent)
}
}
.padding()
}
}
Clear Text Button
Add a button to quickly clear the text field:
struct ClearableTextField: View {
@State private var searchText = ""
@FocusState private var isFocused: Bool
var body: some View {
TextField("Search", text: $searchText)
.focused($isFocused)
.textFieldStyle(.roundedBorder)
.padding()
.toolbar {
ToolbarItemGroup(placement: .keyboard) {
Button("Clear") {
searchText = ""
}
.disabled(searchText.isEmpty)
Spacer()
Button("Done") {
isFocused = false
}
}
}
}
}
Counter Display
Show character count or word count:
struct CounterToolbar: View {
@State private var text = ""
@FocusState private var isFocused: Bool
let maxCharacters = 280
var remainingCharacters: Int {
maxCharacters - text.count
}
var counterColor: Color {
switch remainingCharacters {
case ..<0: return .red
case 0..<20: return .orange
default: return .secondary
}
}
var body: some View {
VStack {
TextEditor(text: $text)
.focused($isFocused)
.border(Color.gray.opacity(0.3), width: 1)
.padding()
Spacer()
}
.toolbar {
ToolbarItemGroup(placement: .keyboard) {
Text("\(remainingCharacters)")
.foregroundColor(counterColor)
.font(.caption)
.monospacedDigit()
Spacer()
Button("Post") {
submitPost()
}
.disabled(text.isEmpty || remainingCharacters < 0)
.bold()
}
}
}
func submitPost() {
// Submit the post
isFocused = false
}
}
Integration with TextEditor
TextEditor works seamlessly with keyboard toolbars:
struct NoteEditor: View {
@State private var note = ""
@FocusState private var isEditing: Bool
var body: some View {
NavigationView {
TextEditor(text: $note)
.focused($isEditing)
.padding()
.navigationTitle("New Note")
.toolbar {
ToolbarItemGroup(placement: .keyboard) {
Button {
insertTimestamp()
} label: {
Image(systemName: "clock")
}
Spacer()
Button("Done") {
isEditing = false
}
}
}
}
}
func insertTimestamp() {
let timestamp = Date().formatted(date: .abbreviated, time: .shortened)
note += "\n\(timestamp): "
}
}
Important Notes
iOS Version: The .keyboard placement requires iOS 15+. For earlier versions, you'll need UIKit input accessory views.
Focus Management: Always use @FocusState to properly manage keyboard focus. Without it, you can't programmatically dismiss the keyboard.
Toolbar Persistence: The toolbar appears only when the keyboard is visible. It automatically animates in and out with the keyboard.
Multiple Fields: When you have multiple text fields, the keyboard toolbar is shared across all focused fields unless you conditionally change its content.
Sheets: The toolbar also worked inside a NavigationStack presented as a sheet (the note editor example), with the clock and Done buttons in the same floating style.
Testing: I only tested in the simulator with the software keyboard. Check on a real device that the buttons are comfortable touch targets (Apple's guidance is at least 44x44 points).
Best Practices
Do:
- Keep toolbar buttons concise (3-5 buttons maximum)
- Always include a "Done" button for dismissing the keyboard
- Use SF Symbols for icon buttons
- Provide visual feedback (disabled states, colors)
Don't:
- Overcrowd the toolbar with too many buttons
- Use text labels that are too long
- Forget to handle keyboard dismissal
- Rely on toolbar for critical functionality (it's hidden when keyboard is dismissed)
The keyboard toolbar is a clean, native way to enhance text input in your SwiftUI apps without the complexity of UIKit bridges.
// Continue_Learning
Creating an iMessage-Style Input Accessory View in SwiftUI
Learn how to create a custom input accessory view that stays above the keyboard, similar to iMessage's input bar.
Character Limit for TextEditor or TextField in SwiftUI
Learn how to create a modifier that limits the characters that can be entered in a TextEditor or TextField view in SwiftUI.
SwiftUI Form: Sections, FormStyle, and Validation
How Form differs from List, how to structure it with Sections, what .grouped and .columns actually change, and a validation pattern that does not fight the framework.
// Stay Updated
Get notified when I publish new tutorials on Swift, SwiftUI, and iOS development. No spam, unsubscribe anytime.