In this article, we will look at the main navigation patterns used in iOS apps: NavigationStack, modal presentation, TabBar navigation, and bottom sheets.
We will compare how they behave, when they are useful, and what can go wrong when too many navigation patterns are mixed together. I will also share my own recommendations based on experience building mobile apps and dealing with flows that looked simple in design but became much more complicated during implementation.
When working on a new process, standard, or feature, I often ask the UI/UX team the same question:
How do you want to show these screens to the user?
In almost every case the answer is:
Normal.
Which is, of course, the most precise answer known to mankind.
The follow-up question is usually: what does “normal” mean?
And that is where things get interesting. Because on iOS “showing a screen” can mean many things. We can push it onto a navigation stack. We can present it modally. We can wrap it in a full-screen flow. We can show a sheet. We can hide the TabBar, show the TabBar, animate the TabBar, fight the TabBar, cry because of the TabBar…
You get the idea.
So let’s talk about the basic navigation building blocks on iOS, how they behave, and why the “normal” way is not always the same thing for every process.
NavigationStack
Mobile navigation on iOS is commonly handled by a NavigationStack.
It is the main tool for moving through screens in SwiftUI. By default, it gives us a lot of useful behavior:
- back navigation
- swipe-to-go-back gesture
- long-press back button history(breadcrumbs)
- system animations
- predictable navigation hierarchy
Of course, we can customize it.
Of course, we can also break half of those features while doing so.
That is not a threat. That is just iOS development.
Let’s look at a simple example.
We have a standard view inside a NavigationStack. When the user taps something, we push a new screen. Then another one. Then another one. The navigation stack works by stacking views on top of each other.
Think of it like plates.
The main app screen is a big plate. When the user goes deeper into the app, we put smaller plates on top of it. Screen 2 goes on top of screen 1. Screen 3 goes on top of screen 2. Screen 4 goes on top of screen 3.
And so on.
Now, if we want to go back from plate 5 to plate 3, we need to remove plate 5 and plate 4. That is fine. That is how stacks work.
This concept comes from algorithms and data structures.
A stack is a linear data structure that follows the Last In, First Out principle. LIFO. The last element added is the first one removed.
Simple.
Elegant.
Until the product requirement says:
After completing step 7, redirect the user to the dashboard, but only if they came from the notification, unless they started from settings, in which case go back to the list.
Yours truly PO
And now we are no longer talking about plates.
We are talking about archaeology.
It is also important to mention that an app can have many navigation stacks. A modal flow can have its own stack. Each tab can have its own stack. A feature can own its own path. This is not a problem by itself. It becomes a problem when nobody knows which stack owns which part of the user journey.
FullScreenCover aka Modal
The navigation stack is not the only way to show a view.
We can also present a view modally.
Before iOS 13, modal presentation usually meant that the new screen flew in from the bottom and covered the whole screen(In SwiftUI is called FullScreenCover). With iOS 13, Apple changed the default behaviour. Many modal presentations started looking more like cards or sheets, with the previous screen still visible underneath.


This changed how apps felt.
Sometimes for the better.
Sometimes… not.
Let’s go back to our plate example.
Instead of pushing a process onto the existing navigation stack, we can present it modally and give that modal its own NavigationStack.
So now the process is not another small plate on top of the existing pile. It is more like putting a new big plate next to the old one. It has its own smaller plates. Its own navigation. Its own beginning and end.
This works very well for flows that are self-contained.
For example:
- onboarding
- editing a profile
- creating a new item
- handling a push notification flow
- multi-step confirmation
- checkout-like processes
The user enters the process, goes through a few screens, completes the task, and then we dismiss the whole thing.
Done.
No need to pop five screens. No need to fake a back animation. No need to reconstruct the main app state with duct tape and hope.
When we dismiss the modal, the user lands back where they started.
Predictable.
And predictable is good.
In my opinion, this is much cleaner for flows that have a clear start and end. The user is temporarily doing a focused job. When the job is done, the whole process disappears.
What drives me crazy is when apps use modal presentation as just another way of showing a normal next screen. This is especially visible in older apps where UIKit patterns grew over the years like mushrooms after rain.
You tap something.
A modal appears.
Inside the modal another modal appears.
Then something is pushed.
Then a sheet appears.
Then there is a close button, a back button, and maybe a cancel button. All on one screen.
Wonderful.
With SwiftUI, the basic navigation path should usually be built with NavigationStack and navigationDestination. Modal presentation should have a reason. It should not be “because the animation looked nice in the prototype” or it feels more lightweight when it comes from the bottom.
TabBar
A TabBar is the bottom bar that gives the user access to the main areas of the app.
Usually up to five entry points.
Five is already a lot. More than five and we enter the magical land of “More” tabs, hidden features, and users never finding anything.
The TabBar should be used for the main sections of the app. The most important, most used places. It should not be used for temporary processes. It should not be used as a dumping ground for everything that did not fit into the first navigation idea.
The placement of the TabBar inside the app hierarchy is crucial.
Depending on how developers place it, the behaviour changes.
Let’s imagine two situations.
In the first one, we have a TabBar and inside each tab we have a NavigationStack. When we push a new screen, the TabBar can stay visible. This is common when the pushed screen still belongs to the current tab context.
In the second one, we have a NavigationStack outside and the TabBar inside it. When we push a new screen above the TabBar, the new screen can cover it.
Both approaches can be valid.
The problem starts when the design expects both behaviours at the same time.
On this screen the TabBar should be visible.
On this one it should be hidden.
But when going back with the gesture it should animate nicely.
Also the TabBar should reappear halfway through the swipe.
Also no bugs please.
Yes.
Sure.
SwiftUI is not always very happy when we hide and show the TabBar during interactive navigation gestures. It can be done, but it requires care. Sometimes custom transitions. Sometimes custom state handling. Sometimes accepting that the default system behaviour exists for a reason.
The TabBar is also different on iPadOS. Depending on the platform and size class, navigation can adapt visually. So when designing the hierarchy, it is worth remembering that “bottom bar on iPhone” is not always “bottom bar everywhere”.
Sadly.
Or happily.
Depends on the day.
Bottom Sheets
Oh boy.
The new favourite shiny toy.
Bottom sheets are useful. I agree. They can provide better context. They can keep the user aware of where they are in the app. They can make quick actions feel lightweight. They can be great.
But come on.
Not every process needs to live in a sheet.
Sometimes I see flows where everything is a sheet. A sheet opens another sheet. That sheet has a navigation stack. Then another sheet appears from inside it. Some can be dismissed by dragging. Some require tapping Close. Some have Cancel. Some have Done. Some just disappear if you look at them wrong.
At that point the user is not navigating.
The user is spelunking.
Even Apple apps sometimes go wild with this. Apple Maps is probably the easiest example where different sheet-like surfaces can stack, resize, collapse, expand, and generally behave like a small UI ecosystem living at the bottom of the screen.
During WWDC 2026(Yea I will brag about it 🙃), I asked about this. Why so much push toward sheets? What is the recommendation for how many should be stacked?
The main argument was user awareness. The user sees the parent context behind the sheet, so they better understand where they are. That makes sense.
The recommendation I heard was: avoid going beyond two stacked sheets.
But in the Human Interface Guidelines, Apple is more strict:
Display only one sheet at a time from the main interface. When people close a sheet, they expect to return to the parent view or window. If closing a sheet takes people back to another sheet, they can lose track of where they are in your app. If something people do within a sheet results in another sheet appearing, close the first sheet before displaying the new one. If necessary, you can display the first sheet again after people dismiss the second one.
Apple HIG
And honestly?
That is good advice.
Now, take this part with a grain of salt, because this is also my little rant.
From my experience building mobile apps, unpredictable flows usually appear when we mix too many navigation patterns:
- push
- modal
- sheet
- nested sheet(Bottom sheet on top of other one)
- hidden TabBar
- custom back button
- custom close button
- gesture dismiss
- disabled gesture dismiss
- surprise redirect at the end
Each of those can be correct in isolation.
Together they can create a user experience that feels like a haunted house.
There is also the close button placement.

Good old days.
When HIG was not just a suggestion you ignored and then rediscovered three years later during a redesign.
My recommendation
These recommendations are based on my own experience and on what feels natural for users after working with mobile apps for a while.
They are not laws.
But they might save you from creating a navigation monster.
Use NavigationStack for normal hierarchy
If the user is moving deeper into a section, use NavigationStack.
A list opens details. Details open more details. Settings open a subpage. A product opens reviews. A conversation opens a profile.
That is normal navigation.
The user should be able to go forward and back. The swipe-back gesture should work. The system back button should work. The navigation history should make sense.
This is the happy path.
Do not fight the happy path unless you have a very good reason.
Use full-screen modal for contained processes
When the user starts a process that has a clear beginning and end, consider presenting it as a full-screen modal with its own navigation stack.
Examples:
- editing something
- creating something
- onboarding
- handling a notification-driven flow
- multi-step forms
- flows where completion should return to the original screen
The user can still move through the process normally, screen by screen. But when the process is completed, we can dismiss the whole flow.
Clean.
Predictable.
No need to pop half the app.
Use sheets for quick actions
Bottom sheets are great for quick, contextual actions.
For example:
- choosing a filter
- selecting an option
- showing a small amount of related information
- confirming a lightweight action
- presenting temporary controls
A sheet should usually feel temporary. It should not feel like a second app hiding inside your app.
If you need a navigation stack inside a sheet, stop for a moment and ask:
Is this still a sheet, or did we just hide a full process in a smaller container?
Sometimes a navigation stack in a sheet is fine. But for longer flows, be careful – especially if users can accidentally dismiss the sheet and lose progress.
I have seen processes with 3 or 4 steps inside a sheet where a user could accidentally drag it down and lose everything.
This is not UX.
This is a trap.
If a sheet contains important progress, either disable interactive dismissal or show a confirmation before closing it.
Yes, it is one more dialog.
No, it is not worse than losing user input.
To wrap it up
Navigation is not only a developer problem.
It is a product problem.
It is a design problem.
It is a user-expectation problem.
When we say “show the screen normally”, we should know what “normal” means in that specific context.
A pushed screen is good for hierarchy.
A modal is good for a focused process.
A TabBar is good for top-level app sections.
A sheet is good for quick contextual work.
Can we mix them?
Yes.
Should we mix them randomly?
Please no.
The system already gives us a lot of behaviour for free. Back gestures. Navigation history. Presentation animations. Platform conventions. User expectations built over years of using iOS apps.
Use them.
Because the best navigation is the one the user does not need to think about.
And the second best navigation is the one that does not make the developer open 3 Jira tickets titled “Navigation state broken after dismissing sheet from modal flow”.
Which, sadly, is also a very real navigation pattern.

Szymon Szysz is a ex-Apple hater and has come a long way. Now making apps for iOS since 2017. Likes good design and amazing UX. Likes to focus on code quality and does not hesitate to say that something “is not yes” (*ponglish, “is not correct”).

