@sigx/lynx-navigation
v0.34.0
Published
Type-first native navigator for sigx-lynx — Stack, Tabs, Drawer, modals, lazy routes, deep links
Downloads
1,714
Maintainers
Readme
@sigx/lynx-navigation
Type-first native navigator for SignalX on
Lynx. Define routes once with defineRoutes, augment the Register
interface, and every navigator API — useNav, useParams, useSearch,
<Link>, <Tabs.Screen>, <Drawer> — picks up precise per-route
param/search inference.
The navigator ships native UI primitives (Stack, Tabs, Drawer, modal and bottom-sheet presentation), focus hooks, deep-link integration, lazy routes, screen options, and persistence — all reactive via sigx signals, all typed.
Status — 1.0 candidate. Public surface is frozen; every export is locked by the test suite in
__tests__/public-surface.test.ts.
📚 Documentation
Full guides, the complete API reference, presentation modes, nested stacks and live examples → sigx.dev/lynx/modules/navigation/overview
Install
pnpm add @sigx/lynx-navigationPeer-deps: @sigx/lynx, @sigx/lynx-icons, @sigx/lynx-motion, @sigx/lynx-sheet, and
@sigx/lynx-linking (URL parsing,
hardware back and deep-link wiring). Recommended:
@sigx/lynx-storage for stack persistence.
A taste
// src/routes.ts
import { defineRoutes } from '@sigx/lynx-navigation';
import { z } from 'zod';
import { Home } from './screens/Home';
import { Profile } from './screens/Profile';
export const routes = defineRoutes({
home: { component: Home },
profile: {
component: Profile,
params: z.object({ id: z.string() }),
path: '/users/:id',
},
});
declare module '@sigx/lynx-navigation' {
interface Register { routes: typeof routes }
}// src/App.tsx
import { NavigationRoot, Stack } from '@sigx/lynx-navigation';
import { routes } from './routes';
export const App = () => (
<NavigationRoot routes={routes} initialRoute="home">
<Stack />
</NavigationRoot>
);From there: typed useNav() / <Link> navigation, per-tab nested stacks, modal/sheet presentation, focus hooks, deep linking via useLinkingNav, and stack persistence via useNavSerializer. Full reference, prop tables and runtime gotchas live on the docs site.
Bottom sheets (presentation: 'sheet') declare their resting heights as <Screen detents={[{ fraction: 0.4 }, { fraction: 0.9 }]} initialDetentIndex={0}> — @sigx/lynx-sheet DetentSpecs (px or fraction-of-screen; { keyboard } specs resolve via their fallbackPx since route sheets provide no keyboard environment — keyboard-aware detents are a @sigx/lynx-sheet <BottomSheet> feature), resolved to px; the drag/snap/dismiss mechanics run on that package's shared sheet engine. Sheets drag from anywhere on their surface by default, with drag↔scroll arbitration: taps, input focus and horizontal gestures pass through, and scrollable content coordinates automatically when wrapped in @sigx/lynx-gestures' <ScrollView> (below the max detent the sheet owns drags and content scroll is locked; at the max detent content scrolls, and pulling down from the top hands the gesture back to the sheet). For raw <scroll-view>/<list> content that can't coordinate, set <Screen dragMode="grabber"> (drag only from the top strip zone; grabberPx sizes the strip, default 28) — or dragMode="none" for backdrop/programmatic dismiss only.
useSheetHeight() returns a bindable SharedValue<number> of the top sheet's live visible height in px (0 when none, tracking the finger as the sheet drags). Bind it to animate a sibling to the sheet — e.g. a chat composer bar that must sit above whichever is taller, the keyboard or the sheet: useDerivedValue([keyboardLift, useSheetHeight()], 'max') (see @sigx/lynx-motion). Returns a constant 0 under animated={false}.
<Screen backdrop={false}> makes a sheet non-modal / inline: no dim, and the region above the sheet surface passes taps straight through to the screen below (the sheet's own layer is translated down to its top edge, so only the backdrop ever covered that region). Use it for a keyboard-accessory panel — an emoji picker sheet under a chat composer whose input must stay tappable while the sheet is open. backdropDismiss is then moot (no backdrop to tap); dismiss by dragging down or nav.pop(). Default true (the modal bottom-sheet look).
Animated transitions pre-stage the work they'd otherwise compete with: a push commits its render immediately, parks the incoming screen off-screen, and holds the slide until the runtime goes quiet — the screen's mount, its post-mount effect flushes, native layout, and any <list> initial cell builds all land while the outgoing screen is still presented (bounded, so a screen that never settles still transitions promptly). Pops do the same for the reveal relayout of the underneath screen. Heavy destinations (long lists, dense forms) therefore slide in smoothly instead of stuttering mid-transition; the cost is a short, capped delay before the motion starts. No API — this is how push/pop behave.
push(name, params, search, { animated: false }) presents a sheet AT its initial detent instantly — no slide. Use it to reveal a sheet by some other motion: e.g. open an emoji sheet behind the soft keyboard, then blur the input so the keyboard's own dismissal uncovers the sheet (the app animates nothing). useSheetHeight reads the detent height from the first frame, so a bar bound to max(keyboardLift, sheetHeight) never dips at the swap. A non-animated dismiss (pop(1, { animated: false })) returns the height to 0 the same way.
Transition geometry follows device rotation (#856): the card/modal slide distances, the route-sheet detents and the edge-back commit threshold all read the live screen size (useScreen() / useScreenMT() from @sigx/lynx) at plan-build time, rather than a value snapshotted when the bundle loaded. A push while the device is in landscape slides the full landscape width.
The iOS-style edge-swipe back (a 20px strip on the left edge of a card screen; opt out with <NavigationRoot edgeSwipeEnabled={false}>) follows the finger and, on release, commits when the drag passed a third of the screen width or the flick was faster than 300 px/s. Otherwise it springs back.
Errors
Everything this package throws is a SigxError from @sigx/lynx-core, with the message [@sigx/lynx-navigation] <action> failed: <detail> and a stable code — branch on the code, never on the message:
import { isSigxError } from '@sigx/lynx-core';
import { hrefFor, type NavigationErrorCode } from '@sigx/lynx-navigation';
try {
const href = hrefFor('profile', { id: incomingId });
} catch (e) {
if (isSigxError(e) && e.code === ('route_not_registered' satisfies NavigationErrorCode)) {
// deep link naming a screen this build doesn't have → fall back home
}
}Codes: no_navigator (a navigator hook used outside the component that provides it), no_route_registry, route_not_registered, invalid_params, invalid_search, invalid_path, invalid_stack, unsupported_schema. The single exception is compilePath() given a non-string template, which stays a TypeError.
Diagnostics that don't throw — a useNavSerializer write that failed, a rejected exitApp() — go to the lynx-navigation logger namespace and stream to the sigx dev terminal.
Inline <BottomSheet> — moved to @sigx/lynx-sheet
The inline (route-free) <BottomSheet> now lives in @sigx/lynx-sheet — the unified sheet package whose engine also powers presentation: 'sheet' above. It supersedes the component this package used to export, adding DetentSpec geometry ({ keyboard: true }, { fraction }, topOffset caps), dismissible, backdrop, and full-surface drag with scroll arbitration. Import it from there:
import { BottomSheet } from '@sigx/lynx-sheet';License
MIT
