npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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.

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/router

react 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 true decides. 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:5180

It 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 source

ROUTER_CONTRACT.md documents the invariants the contract tests protect.

License

MIT