@sigmela/router
v0.7.2
Published
Native-feeling router for React web apps: screen stacks with gesture-driven back, modals, sheets, tab bars, drawers and split views.
Maintainers
Readme
@sigmela/router
A router for React web apps that behave like native apps: screen stacks with animated push/pop, modals and bottom sheets with swipe-to-dismiss, tab bars, drawers and split views — all declared as JSX routes.
Built on wouter for matching and motion for transitions.
npm install @sigmela/routerreact and react-dom (>= 19) are peer dependencies.
Quick start
import { Router, useRouter } from '@sigmela/router';
function App() {
return (
<Router>
<Router.Screen path="/" element={HomeScreen} />
<Router.Stack path="/products">
<Router.Screen element={ProductListScreen} />
<Router.Screen path=":id" element={ProductScreen} />
<Router.Modal path=":id/edit" element={EditProductModal} />
<Router.Sheet path=":id/share" element={ShareSheet} />
</Router.Stack>
</Router>
);
}
function ProductCard({ id }: { id: string }) {
const router = useRouter();
return <button onClick={() => router.navigate(`/products/${id}`)}>Open</button>;
}Styles ship with the bundle: dist/index.js imports router.css, so bundlers
that handle CSS from dependencies (Vite, webpack, Next, Parcel) pick it up
automatically — no extra import needed.
Environments without a CSS loader — plain Node, some SSR pipelines — cannot resolve that import. Load the stylesheet explicitly there:
import '@sigmela/router/router.css';For Vitest with the node environment, inline the package so Vite handles the
CSS import instead of Node:
// vitest.config.ts
test: {
environment: 'node',
server: { deps: { inline: [/@sigmela\/router/] } },
}Route primitives
| Element | Purpose |
| --- | --- |
| Router.Screen | A routed screen inside the current stack |
| Router.Stack | Groups screens that push and pop with a shared animation |
| Router.Redirect | Declarative redirect |
| Router.Modal | Full-cover or content-sized overlay above the current screen |
| Router.Sheet | Bottom sheet, content-sized or fullscreen |
| Router.TabBar / Router.TabBarItem | Tab navigation; selecting a tab routes to its root path |
| Router.Drawer / Router.DrawerGroup / Router.DrawerSection / Router.DrawerItem | Persistent side navigation |
| Router.SplitView | Primary/secondary panes above a width breakpoint |
| Router.Protected | Guarded subtree with a redirect target |
Overlays with their own stack
Router.Modal and Router.Sheet can own a local push stack. The children share
one surface and leave the browser URL on the underlying screen:
<Router.Modal path="/auth/email">
<Router.Screen element={AuthEmailModal} />
<Router.Screen path="verify" element={AuthEmailVerifyModal} />
<Router.Screen path="name" element={AuthEmailNameModal} />
</Router.Modal>navigate() and replace() resolve matching children inside the active
overlay. goBack() pops the local stack first, then closes the overlay from its
root. The close button, backdrop tap, Escape, swipe dismissal and
useCurrentModal().close() all dismiss the whole flow from any depth.
Content-sized modals
A modal covers the viewport by default. Content that renders <Modal> with
presentation="content" sizes the surface to its content instead — floored at
half the viewport, capped at full height, with the page behind left undimmed:
import { Modal } from '@sigmela/router';
function ProductDetail({ product }) {
return (
<Modal presentation={product.image ? 'fullscreen' : 'content'}>
{/* … */}
</Modal>
);
}Guards that are not decided yet
Router.Protected answers three states, not two. A guard whose when is
false redirects; a guard that is still resolving — 'pending', or
undefined so when={user?.isAdmin} works before the user loads — holds the
URL and renders its pending node instead of the subtree:
<Router.Protected
when={contextLoading ? 'pending' : isReady}
redirectTo="/home"
pending={<AppLoadingScreen />}
>
<Router.Screen path="/catalog/products" element={ProductsScreen} />
</Router.Protected>This is what keeps deep links alive while an app boots. Without it a guard has
to answer false while it is still loading, which redirects the visitor away
from the URL they opened — and a catch-all loading screen cannot rescue it,
because the guarded route is the more specific match.
Semantics worth knowing:
- Guards resolve outside-in; the first one that is not
truedecides. An undecided outer guard defers a later denial, since it is not yet known whether that guard would even be reached. - A denial always wins immediately, including on a screen that is already open — revoking access redirects right away.
- A guard that turns undecided again while its screen is on-screen keeps that screen rendered rather than flashing the pending node.
Hooks
| Hook | Returns |
| --- | --- |
| useRouter() | navigate, replace, goBack, and overlay controls |
| useCurrentRoute() | The active route: pathname, search, searchParams |
| useParams() | Path parameters of the current route |
| useSearchParams() | Query string as URLSearchParams |
| useRoute(pattern) | Match test for an arbitrary pattern |
| useHistoryState() | State passed through navigate(path, { state }) |
| useCurrentModal() | close() / id for the overlay a component renders in |
| useDrawer() | Drawer open state and controls |
Theming
The stylesheet reads CSS custom properties, so an app can restyle every surface
without overriding selectors. Set them on :root or on an app shell element:
Base — --router-bg, --router-text, --router-line, --router-scrim,
--router-container, --router-card-shadow, --router-glass-bg,
--router-transparent, --router-divider
Overlay/footer — --router-footer-fade-bg,
--router-footer-fade-transparent
Drawer — --router-drawer-bg, --router-drawer-text,
--router-drawer-hover-bg, --router-drawer-active-bg,
--router-drawer-badge-bg, --router-drawer-badge-text,
--router-drawer-focus-ring, --router-drawer-overlay-color,
--router-drawer-overlay-bg, --router-drawer-overlay-open-bg
Close button — --router-close-button-bg, --router-close-button-text,
--router-close-button-light-text, --router-close-button-glass-bg,
--router-close-button-shadow, --router-close-button-shine,
--router-mask-fill
Typography/layout — --router-title-font-family,
--router-title-font-size, --router-title-line-height,
--router-title-font-weight, --router-safe-area-bottom
Every token falls back to a sensible default, so the router renders correctly with no theming at all.
Scope
The router owns navigation, overlay surfaces and their gestures. It never imports application features, i18n, data layers or design-system components — screens and layouts are supplied by the host app as JSX.
Sandbox
example/ is a Vite playground that exercises every primitive against the
router source, with hot reload:
npm install
npm run example # http://localhost:5180It has three shells you can switch between: Stack & overlays (pushes,
params, search params, fullscreen and content-sized modals, a sheet, and a
modal owning its own three-step stack), Tab bar (per-tab routes, history
state), and Drawer & split (drawer groups and sections, a split view, and a
guarded route). example/src/theme.css shows the --router-* tokens driving
the whole look.
Development
npm install
npm run typecheck
npm test # 58 contract tests
npm run lint
npm run build # dist/index.js + dist/router.css + type declarations
npm run example # Vite sandbox on the sourceROUTER_CONTRACT.md documents the invariants the contract tests protect.
License
MIT
