SwiftUI Expandable TextField with Minimum Height and Tap-to-Focus

No imageSilviu V.
51 Sept, 2025

When working with SwiftUI’s TextField starting from iOS 16, Apple introduced the axis to the initializer. 🎉

swift
init( _ titleKey: LocalizedStringKey, text: Binding<String>, axis: Axis )

By setting it to .vertical, you can make the text field expand as the user types more content — provided you also set lineLimit(nil) and there’s available space in the layout.

Take this simple example

swift
struct BorderedTextField: View { @State private var text: String = "" var body: some View { LabeledContent { TextField("", text: $text, axis: .vertical) .lineLimit(nil) .padding(10) .overlay( RoundedRectangle(cornerRadius: 4) .stroke(.gray, lineWidth: 1) ) } label: { Text("Description") .foregroundStyle(.secondary) } .labeledContentStyle(TopLabeledStyleConfig()) } }

Here we have a custom BorderedTextField that can be reused across the app. We set the axis to .vertical, lineLimit to nil, add a border, and that is pretty much it.

The code above will produce the following result:

SwiftUI Text field with no text inside image
Text Field with no text inside
SwiftUI Text field with no text inside image
Text Field with longer text inside

Obviously, you may have noticed that I used LabeledContent so we can add a label to our text field.

By default, the LabeledContent places the label horizontally next to the text field.

That’s why I added a custom .labeledContentStyle that changes this behavior, aligning the label vertically instead.

All you have to do is add this to the code base:

swift
struct TopLabeledStyleConfig: LabeledContentStyle { func makeBody(configuration: Configuration) -> some View { VStack(alignment: .leading) { configuration.label configuration.content } } }

Now that we have our UI in place, let’s make the default height of the text field bigger than a single row when it’s empty.

For example, we might want the height to match what it would be with 3 or 4 lines of text, even when no text is actually present.

You might even be tempted to solve this problem by just changing this:

swift
.lineLimit(nil)

to this:

swift
.lineLimit(4, reservesSpace: true)

And you wouldn’t be mistaken!

The default height of the text field does increase when it’s empty.

However, once the text exceeds 4 lines, it will stop expanding and start scrolling inside, just like a TextEditor would.

And this scenario has its place and can be very useful in many cases where that behavior is desired.

but...

The purpose of this article is to show how to set a minimum height while still allowing the text field to expand infinitely if vertical space allows.

Usually, you would place this in a ScrollView, so the text field can grow without limits.

An alternative

We can take advantage of the .frame modifier to add a minimum height to the text field.

This will instantly make the text field bigger while still allowing it to expand as the user types, beyond that minimum height.

swift
TextField("", text: $text, axis: .vertical) .lineLimit(nil) .frame(minHeight: 120, alignment: .top) //<-- .padding(10) .overlay( RoundedRectangle(cornerRadius: 4) .stroke(.gray, lineWidth: 1) )

Everything is nice and good...

but let’s add a .background(.green) for debugging this approach, placed exactly after .lineLimit(nil).

SwiftUI Text field with green debug background

Hmm, that’s probably not ideal.

The reason I say this is that if the green background is gone, users will naturally expect that tapping anywhere inside the bordered text field area should still bring up the keyboard and allow typing.

Sadly, this isn’t the case for our current implementation. 😞

Luckily, since iOS 15 , we can take advantage of the @FocusState property wrapper.

and...

With a little clever combination of that wrapper and a background modifier, we can trigger the keyboard or typing mode whenever the user taps anywhere inside the bordered text field.

The final code now looks something like this:

swift
struct BorderedTextField: View { @State private var text: String = "" @FocusState private var isFocused: Bool // 1 var body: some View { LabeledContent { TextField("", text: $text, axis: .vertical) .lineLimit(nil) .frame(minHeight: 120, alignment: .top) .padding(10) .background(.white) // 2 .focused($isFocused) // 3 .onTapGesture() { // 4 isFocused = true } .overlay( RoundedRectangle(cornerRadius: 4) .stroke(.gray, lineWidth: 1) ) } label: { Text("Description") .foregroundStyle(.secondary) } .labeledContentStyle(TopLabeledStyleConfig()) } }
  1. We added a boolean @FocusState property that controls when the text field is focused.
  2. Added a background that the user can tap to trigger the focus state. In a production app, I recommend passing the background color, instead of hardcoding it. It’s also better to use asset colors that adapt to light and dark mode.
  3. Attached that focus state to our text field.
  4. Added a simple tap gesture that triggers the focus state.

Thanks for stopping by! 🪴