A lightweight SwiftUI navigation library that decouples routing logic from views. Built on @Observable for iOS 17+.
Most SwiftUI routing libraries scope navigation to a single NavigationStack. Router goes further:
- Any screen, from anywhere — Define a single route enum wrapping per-feature routes, and any screen can be pushed, presented as a sheet, or shown as a full-screen cover from any tab. No passing routers between views, no manual wiring.
- Hierarchical navigation — Routers form a parent-child chain when modals are presented.
NavigationTargetlets you direct actions to any point in the hierarchy — present on the root, push on the parent, or stack on the deepest child. - Modern Swift — Built on
@Observableand@Environment, not legacyObservableObjectand@EnvironmentObject. - Deep linking with tab support — Handle deep links that switch tabs and navigate within them, using a single
.onDeepLinkmodifier.
- Type-safe routing via
Routableenums — each case maps to a view - Push, sheet, and full-screen cover navigation with one generic
Router<Destination> - NavigationSplitView support —
SplitRouteris aRouterdriving the detail column, withsidebara fullRouterfor the other, so both columns take the same verbs. It owns the split view's layout state and reportsisCollapsedfor compact width - Presentation without a stack —
.routerPresentations(_:)hosts router-driven modals on any view that already has navigation above it - Destinations that own their navigation — a route declaring
ownsNavigationis presented bare, without aRoutingView: split screens, views with their ownNavigationStack, UIKit controllers - NavigationTarget — route to
.current,.parent,.root, or.deepestrouter in a hierarchy - Cross-tab routing — routers injected via
@Environment, accessible from any child view - Sheet presentation options — detents, drag indicator, interactive dismiss
- Push falls back to a sheet on bare presentations — a bare presentation hosts no stack, so
pushon its router (for example viatarget: .deepest) presents a sheet instead of showing nothing - Tab bar hiding — a route declaring
hidesTabBarhides the tab bar while it is on top of a pushed stack - Zoom transitions — a push, sheet, or cover zooms out of the view that opened it:
zoomSource(id:)on the source,transition: .zoom(sourceID:)on the call - Configurable dismiss buttons — chosen per presentation: none, leading or trailing, and on pushed views within modals
- Deep linking —
.onDeepLinkmodifier handles both external URLs and internalopenURLcalls - Automatic child router management — modals get their own router, cleaned up on dismiss
- iOS 17+
- macOS 14+
- Swift 6.0+
Add to your Package.swift:
dependencies: [
.package(url: "https://github.com/alexookah/Router.git", from: "1.0.0")
]Or in Xcode: File > Add Package Dependencies and paste the repository URL.
import Router
enum HomeRoute: Routable {
case home
case detail(String)
case settings
func destination() -> some View {
switch self {
case .home: HomeView()
case let .detail(id): DetailView(id: id)
case .settings: SettingsView()
}
}
}destination() is main-actor isolated, so it can build view models as freely as views. Conformances need no annotation in any language mode or default isolation.
struct ContentView: View {
@State var router = Router<HomeRoute>()
var body: some View {
RoutingView(router, root: .home)
}
}The closure form, RoutingView(router) { $0.start(.home) }, is there for roots that need more than a route.
The router is automatically injected into the SwiftUI environment:
struct HomeView: View {
@Environment(Router<HomeRoute>.self) var router
var body: some View {
Button("Show Detail") {
router.push(route: .detail("123"))
}
Button("Open Settings Sheet") {
router.presentSheet(
route: .settings,
options: .init(detents: [.medium, .large])
)
}
Button("Open Settings Full Screen") {
router.present(route: .settings)
}
}
}router.push(route: .detail("123"))
router.push(route: .detail("123"), target: .root) // push on root routerrouter.presentSheet(route: .settings)
router.presentSheet(
route: .settings,
dismiss: .visible,
options: .init(detents: [.medium, .large], dragIndicator: .visible)
)
present()is iOS only. macOS has no full-screen cover equivalent — usepresentSheet(...)on macOS.
router.present(route: .settings)
router.present(
route: .settings,
dismiss: .init(
dismissButtonPosition: .left,
showDismissButtonOnPush: true // show X on views pushed within the modal
)
)
// A route whose `ownsNavigation` is true goes up bare, no RoutingView around it
router.present(route: .workspace)router.pop() // go back one
router.pop(last: 3) // go back three
router.popToRoot() // clear the stack
router.dismissChild() // dismiss current sheet/fullScreenCover
router.dismiss() // ask parent to dismiss this modal
router.dismissOrPopToRoot() // smart dismiss
router.dismissSelfAndParent() // close the modal this modal was opened from (or just this one)
router.dismissAllFromRoot() // dismiss entire hierarchy; returns whether anything was dismissedrouter.replaceStack(with: [.home, .detail("1"), .detail("2")])
router.replace(with: .detail("3")) // swap the top
router.replace(last: 2, with: .detail("3")) // collapse the last two
router.lastPathIs(.detail("3")) // true
router.rootDestination // what the stack was started with
router.currentDestination // top of the path, else the root
router.previousDestination // what pop() would reveal
router.currentDestinationIs(.detail("3"))A push, sheet, or cover can zoom out of the view that opened it. Mark the source with zoomSource(id:) and pass .zoom with the same id; the RoutingView supplies the namespace both ends share:
Button { router.push(route: .detail("42"), transition: .zoom(sourceID: "card")) } label: {
CardLabel()
}
.zoomSource(id: "card")
router.present(route: .photo(id), transition: .zoom(sourceID: id))
router.presentSheet(route: .photo(id), transition: .zoom(sourceID: id))Zooms work from iOS 18; earlier systems ignore them. A presentation keeps its transition through replace; a push keeps it while the route is on the path. Push transitions are keyed by route value, so two equal routes on the path share one. The source must sit inside the RoutingView (or routerPresentations) that shows the destination.
When you present a sheet or full-screen cover, Router automatically creates a child router for the modal. This forms a parent-child chain:
Root Router (tab)
└── Child Router (sheet)
└── Child Router (full-screen cover inside the sheet)
Each child has a reference to its parent. When a modal is dismissed, its child router is automatically cleaned up.
NavigationTarget lets you direct navigation actions to any point in this hierarchy:
| Target | Description |
|---|---|
.current |
This router (default) |
.parent |
The parent router |
.child |
The child router |
.root |
The top-most router in the chain |
.deepest |
The furthest child (leaf) in the chain |
// From inside a sheet, push on the parent's navigation stack
router.push(route: .detail("1"), target: .parent)
// From anywhere, present on the root router
router.presentSheet(route: .settings, target: .root)
// Stack a modal on top of an existing modal
router.presentSheet(route: .profile, target: .deepest)This enables cross-tab routing and modal stacking without passing routers around manually.
A SplitRouter is a Router — the one driving the detail column — that gained a sidebar:
enum AppRoute: Routable { ... }
let appRouter = SplitRouter<AppRoute>()
SplitRoutingView(appRouter, sidebar: .folders, detail: .overview)So every surface uses Router's own API, with no extra names to learn:
appRouter.push(route: .article(id)) // detail column
appRouter.presentSheet(route: .settings) // modal over the screen
appRouter.present(route: .editor) // full-screen cover (iOS)
appRouter.dismissChild()
appRouter.path // the detail stack
appRouter.sidebar.push(route: .folder(id)) // sidebar column
appRouter.sidebar.presentSheet(route: .filters)
appRouter.sidebar.replaceStack(with: [.folder(a), .folder(b)])
appRouter.sidebar.pop() // …pop (clamped)
appRouter.popAllToRoot() // both columns at onceBoth columns are full routers and take the same verbs — there is one navigation vocabulary, Router's. sidebar is the sidebar's; detail names the split router itself, so appRouter.detail.push(route:) is appRouter.push(route:). sidebarPath remains as plain array access to sidebar.path, for reading and binding.
A view inside a column can also reach its own column's router without knowing which one it is — its RoutingView puts it in the environment:
struct FoldersView: View {
@Environment(Router<AppRoute>.self) private var columnRouter
var body: some View {
Button("Filters") { columnRouter.presentSheet(route: .filters) }
}
}Both columns share one Destination, so any route can go to either.
A column's view is its route's ViewType, so a route value is all a root can be — there's one initializer, no closure form. Pick a root conditionally with an expression (sidebar: hasFolders ? .folders : .empty), and let the route's own destination read live state for anything dynamic.
At compact width SwiftUI collapses the split view into a single stack, formed by concatenating the columns:
sidebar root → detail root → detail path…
Two consequences worth knowing before you ship an iPhone build:
- The detail root is a real screen.
appRouter.push(route:)from the sidebar lands two levels deep, so going back reaches the detail root rather than the sidebar. On iPad you never notice, because the detail root is permanently on screen in the other column. horizontalSizeClassis the wrong tool inside a column. A column reports its own width, so a narrow sidebar on a full-size iPad reads as.compactwhile both panes are visible.
SplitRoutingView sits outside the split view, so it can read the window's size class and publish it as isCollapsed. Branch on that when a push should read as one level to the user:
if appRouter.isCollapsed {
appRouter.sidebar.push(route: .article(id)) // one stack: stays one level
} else {
appRouter.push(route: .article(id)) // detail column
}isCollapsed, preferredCompactColumn and columnVisibility live on the router, not as @State alongside the view, so layout can be driven from wherever the router reaches — a coordinator, a deep link handler — without threading bindings:
appRouter.preferredCompactColumn = .detail // reveal the detail pane
appRouter.columnVisibility = .allThey default to SwiftUI's own .sidebar and .automatic, and SwiftUI writes back to both as the user navigates. One gotcha worth knowing: a bound .automatic is not the same as an unbound NavigationSplitView — on iPad it starts the sidebar hidden. Set columnVisibility = .all when you want both columns from launch.
Set preferredCompactColumn explicitly alongside navigation that should be visible: SwiftUI does not reliably follow a column's own selection when that column has its own NavigationStack.
Present them on the split router. Two things make that work as well as a dedicated surface would, both measured rather than assumed:
- A collapsed split view keeps both columns in the hierarchy, so the presentation shows whichever pane is visible.
- A full-screen cover presented from a column covers the whole window, not just that column.
Only one modal is on screen at a time regardless: UIKit presents one per view-controller chain, so a sheet raised while the other column already has one showing will not appear. Stack them with target: .deepest, which presents on the child router of the one already up.
Sidebar rows: prefer
List(selection:). In compact width (iPhone, iPad Split View),NavigationSplitViewonly switches to the detail pane automatically when navigation comes from aListselection change. PlainButtonrows swap state without moving the user, which reads as "nothing happened" on iPhone — if you use them, drive thepreferredCompactColumnbinding yourself. A clean pattern that keeps your coordinator in charge is a custom binding:List(selection: Binding( get: { coordinator.selection }, set: { coordinator.select($0) } // choreography lives in one place )) { ... }Also note a column reports its own width: an iPad sidebar is narrow enough to report
horizontalSizeClass == .compactwhile the split view is showing both panes. Read the size class outside the split view if you need the window's.
RoutingView bundles a NavigationStack with modal hosting. When the presenting view already sits inside navigation — a subview deep in a column, a TabView, a hand-rolled split view — attach just the modal hosting:
struct PartDetailView: View {
@State private var photoRouter = Router<PhotoRoute>()
var body: some View {
content
.routerPresentations(photoRouter) // no extra NavigationStack
.environment(photoRouter)
}
}
photoRouter.present(route: .camera(partId: id))Presented routes are normally wrapped in a RoutingView so they can push and present further. If a destination is a navigation container (a NavigationSplitView, or a view that builds its own NavigationStack), its route says so with ownsNavigation, and the router presents it as-is:
enum AppRoute: Routable {
var ownsNavigation: Bool {
switch self {
case .fullScreenWorkspace, .taskTeam: true
default: false
}
}
}
router.present(route: .fullScreenWorkspace) // bare
router.presentSheet(route: .taskTeam) // bareThe same goes for UIKit controllers wrapped in a UIViewControllerRepresentable that bring their own bar — QLPreviewController, PHPickerViewController, EKEventEditViewController, mail and message composers, UIActivityViewController. Inside a RoutingView they would sit under a NavigationStack they never asked for.
It is a property of the route, not of the call site: a destination either owns its container or it does not. A wrapper enum (the cross-tab AppRoute below) forwards ownsNavigation to its child route, like any per-route property; the default is false. dismiss: is ignored for such a route, since there is no bar to put the button in.
replace keeps the navigation it was opened with — swap only between routes that agree on it.
A bare presentation still gets a child router, injected as @Environment(Router<AppRoute>.self), hosting that router's sheets and covers. So it can present on top of itself and close with router.dismiss() like any other presented screen; what it does not get is a NavigationStack.
This is also the shape for presenting a whole split screen: the destination owns a SplitRouter and composes the SplitRoutingView itself. That wrapper view is not boilerplate — it is the session scope: the one place above both columns where objects the columns share can be created, injected, and torn down with the presentation.
struct WorkspaceView: View {
@State private var router = SplitRouter<AppRoute>()
@State private var session = WorkspaceSession()
var body: some View {
SplitRoutingView(router, sidebar: .folders, detail: .overview)
.environment(session) // visible to both columns
}
}
appRouter.present(route: .workspace) // `.workspace` owns its navigationUse a single route enum wrapping per-feature routes. Each tab gets its own router, and any view can navigate across tabs:
// Define a top-level route
enum AppRoute: Routable {
case home(HomeRoute)
case profile(ProfileRoute)
case search(SearchRoute)
func destination() -> some View {
switch self {
case let .home(route): route.destination()
case let .profile(route): route.destination()
case let .search(route): route.destination()
}
}
}
typealias AppRouter = Router<AppRoute>
// One router per tab
struct MainTabView: View {
@State var homeRouter = AppRouter()
@State var profileRouter = AppRouter()
var body: some View {
TabView {
Tab("Home", systemImage: "house") {
RoutingView(homeRouter, root: .home(.home))
}
Tab("Profile", systemImage: "person") {
RoutingView(profileRouter, root: .profile(.profile))
}
}
}
}
// From any child view — present a profile screen from the home tab
struct HomeView: View {
@Environment(AppRouter.self) var router
var body: some View {
Button("View Profile") {
router.presentSheet(route: .profile(.profile), target: .root)
}
}
}The .onDeepLink modifier handles URLs from both external sources (Safari, push notifications) and internal openURL calls. Return true if the URL was handled, false to pass it to the system.
TabView(selection: $selectedTab) {
// tabs...
}
.onDeepLink { url in
guard url.scheme == "myapp",
let host = url.host else { return false }
switch host {
case "home":
selectedTab = .home
if let id = url.pathComponents.dropFirst().first {
homeRouter.push(route: .home(.detail(id)))
}
case "profile":
selectedTab = .profile
default:
return false
}
return true
}The presenter chooses the dismiss button for the modal it shows, so pass the
options when presenting. nil is no button; .visible is a leading one:
// Full-screen cover with dismiss button on the left (.visible is the default)
router.present(route: .settings)
// Sheet with a dismiss button (sheets default to nil — they swipe away)
router.presentSheet(route: .settings, dismiss: .visible)
// Dismiss button on the right
router.present(route: .settings, dismiss: .init(dismissButtonPosition: .right))
// Cover without a button — the content closes itself
router.present(route: .settings, dismiss: nil)The options describe a button that is shown — its position, and whether pushed views inside the modal get it too. There is no hidden flag, so "no button" has one spelling and cannot disagree with on-push settings.
A route that owns its navigation gets no dismiss button — it owns its chrome,
and closes itself with its own control, router.dismiss(), or
@Environment(\.dismiss).
present(route:dismissOptions:)andpresentSheet(route:options:dismissOptions:)becamepresent(route:dismiss:)andpresentSheet(route:dismiss:options:)..sheetDismissOptionsis the defaultnil,.fullScreenDismissOptionsis the default.visible, andshowDismissButton: falseisnil.replaceLast(with:)isreplace(with:); replacing a presented modal now swaps its content in place.presentingSheetandpresentingFullScreenCoverhold aPresentedRoute; readpresentingSheet?.routewhere you read the route before.Routableno longer requiresIdentifiable, and its defaultidis gone; define one if your code usedroute.id.- A route that builds its own navigation container, or wraps a UIKit controller with its own bar, declares
ownsNavigation; it is presented without aRoutingView. RoutingView(router) { $0.start(.home) }still compiles;RoutingView(router, root: .home)is the shorter form.
The ExampleRouterDemo Xcode project demonstrates all features with a 5-tab app:
- Home — push navigation, full-screen covers, a pushed screen that hides the tab bar via
hidesTabBar, zoom transitions from a row, cross-tab routing, UIKit controllers (share sheet, photo picker) presented bare viaownsNavigation, and a sheet whose content you can swap two ways:replace(same identity, keeps the sheet and its detent) or re-present (a new sheet) — the presented controller's address shows which - Stacking — present sheets on top of sheets using
target: .deepest, dismiss all withdismissAllFromRoot() - Profile — full-screen cover with dismiss button positioning
- Split —
SplitRoutingViewwith a button perSplitRouterAPI: column pushes, sheets and covers from either column, andpopAllToRoot(). Run it on iPad and on iPhone to see that a column's presentations work in both layouts - Deep Links — tappable deep link URLs that trigger tab switching and navigation
To run it, open ExampleRouterDemo/ExampleRouterDemo.xcodeproj — the Router package is already included as a local dependency.
MIT
If you find Router useful, give it a ⭐ — it helps others discover the project.
