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> - 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
- Configurable dismiss buttons — show/hide, left/right position, show 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()
}
}
}struct ContentView: View {
@State var router = Router<HomeRoute>()
var body: some View {
RoutingView(router) { router in
router.start(.home)
}
}
}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,
options: .init(detents: [.medium, .large], dragIndicator: .visible),
dismissOptions: .init(showDismissButton: true)
)
present()is iOS only. macOS has no full-screen cover equivalent — usepresentSheet(...)on macOS.
router.present(route: .settings)
router.present(
route: .settings,
dismissOptions: .init(
showDismissButton: true,
dismissButtonPosition: .left,
showDismissButtonOnPush: true // show X on views pushed within the modal
)
)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.dismissAllFromRoot() // dismiss entire hierarchyrouter.replaceStack(with: [.home, .detail("1"), .detail("2")])
router.replaceLast(with: .detail("3"))
router.lastPathIs(.detail("3")) // trueWhen 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.
Use 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) { $0.start(.home(.home)) }
}
Tab("Profile", systemImage: "person") {
RoutingView(profileRouter) { $0.start(.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
}Control the dismiss button on modals:
// Full-screen cover with dismiss button on the left (default)
router.present(route: .settings)
// Dismiss button on the right
router.present(
route: .settings,
dismissOptions: .init(dismissButtonPosition: .right)
)
// Show dismiss button on pushed views within a modal
router.present(
route: .settings,
dismissOptions: .init(
showDismissButton: true,
showDismissButtonOnPush: true
)
)
// Sheet with dismiss button (sheets hide it by default since they have swipe-to-dismiss)
router.presentSheet(
route: .settings,
dismissOptions: .init(showDismissButton: true)
)The ExampleRouterDemo Xcode project demonstrates all features with a 4-tab app:
- Home — push navigation, full-screen covers, cross-tab routing
- Stacking — present sheets on top of sheets using
target: .deepest, dismiss all withdismissAllFromRoot() - Profile — full-screen cover with dismiss button positioning
- 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.
