@novice1-react/react-router-paneling
v1.1.0
Published
A library for that turns each segment of a URL path into a panel. Stack multiple panels by simply navigating to deeper paths, each segment mapping to a panel component.
Maintainers
Readme
@novice1-react/react-router-paneling
A library for React Router that turns each segment of a URL path into a panel. Stack multiple panels by simply navigating to deeper paths, each segment mapping to a panel component.
Install
npm install @novice1-react/react-router-panelingOverview
The library works with both Data Mode and Framework Mode of React Router.
| Mode | Function | Description |
|------|----------|-------------|
| Data Mode | createPaneling | Creates a complete route object with loader, action, and component |
| Framework Mode | createClientLoader | Creates a client-side loader to use in your route's clientLoader export |
How it works
Each URL path segment is parsed into a panel. Panels are registered by name, and each segment can optionally include an ID and key-value extras, separated by a configurable extrasSeparator (default :).
URL structure:
/<panelName><sep><id><sep><key>=<value><sep><key>=<value>/...Example (with default separator :):
/user:abc123:role=admin/settingsThis produces two panels:
- user -
id: 'abc123',extras: { role: 'admin' } - settings - no id, no extras
Your panel components receive these values as props (id, extras, currentPath, previousPath, etc.).
Extras separator
The extrasSeparator option controls the character used to separate segments within a single panel path (name, id, and extras). The default is :.
For URLs where : might conflict, you can use another URL-safe character like ;:
createPaneling({
panels: { /* ... */ },
extrasSeparator: ';'
})With ; as extras separator, URLs look like:
/user;abc123;role=admin/settings;456Note: Keys in extras cannot contain
=or the extras separator character. Values can contain=.
Extras (key-value parameters)
Each panel segment can carry additional parameters after the ID. These are parsed as key-value pairs joined by =:
/profile:id123:theme=dark:lang=enThe panel component receives:
{
id: 'id123',
extras: { theme: 'dark', lang: 'en' }
}If a key has no =, it defaults to an empty string:
/profile:id123:verbose
// extras: { verbose: '' }Data Mode
Use createPaneling to create a route object for Data Mode. Place this in your main.jsx or main.tsx file:
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { createBrowserRouter, Link, Outlet, RouterProvider } from 'react-router';
import { createPanel, createPaneling } from '@novice1-react/react-router-paneling';
function LinksMenu({ basePath }: { basePath?: string }) {
return <ul>
<li>
<Link to={`${basePath || ''}/a`}>Open panel 'a'</Link>
</li>
<li>
<Link to={`${basePath || ''}/b`}>Open panel 'b'</Link>
</li>
</ul>
}
function Root() {
return <div>
<LinksMenu />
<div>
<Outlet />
</div>
</div>;
}
function ErrorPage() {
return <h1>Error</h1>;
}
function PanelAContent({ currentPath }: { currentPath: string }) {
return <div>
<h1>Panel A: {currentPath}</h1>
<LinksMenu basePath={currentPath} />
</div>
}
function PanelBContent({ currentPath }: { currentPath: string }) {
return <div>
<h1>Panel B: {currentPath}</h1>
<LinksMenu basePath={currentPath} />
</div>
}
const router = createBrowserRouter([
{
path: '/',
Component: Root,
errorElement: <ErrorPage />,
children: [
createPaneling({
panels: {
a: createPanel(PanelAContent),
b: createPanel(PanelBContent)
},
max: 5
})
]
},
]);
createRoot(document.getElementById('root')!).render(
<StrictMode>
<RouterProvider router={router} />
</StrictMode>,
)With this setup:
/arendersPanelAContent/brendersPanelBContent/a/brendersPanelAContentthenPanelBContent/b/arendersPanelBContentthenPanelAContent
The max: 5 option limits the stack to 5 panels. If the URL contains more segments, the user is redirected to show only the allowed maximum.
Instead of createPanel, you can use createCustomPanel to provide your own panel layout component and style them however you want (example).
More Data Mode examples:
Framework Mode
Use createClientLoader in Framework Mode. You need to:
- Register your route with a
*wildcard - Export a
clientLoaderthat callscreateClientLoader - Render the panels in your route component
1. Register the route
// routes.ts
import { type RouteConfig, index, route } from "@react-router/dev/routes";
export default [
// ...
route('paneling/*', 'routes/paneling.tsx')
] satisfies RouteConfig;2. Create the client loader
Important: Define the panel configuration object at module scope (outside
clientLoader). In Framework Mode,clientLoaderruns on every navigation. If you callcreateCustomPanelinsideclientLoader, it creates new component references each time, causing React to unmount and remount all panels on every navigation, losing any local state (e.g., form inputs, titles) in those panels. By defining the configuration at module scope, component references are created once and remain stable across navigations.
// routes/paneling.tsx
import type { Route } from "./+types/paneling";
import {
createClientLoader,
createCustomPanel,
} from "@novice1-react/react-router-paneling";
import CustomPanel from "~/components/panels/CustomPanel";
import IndexPage from "~/components/IndexPage";
import ErrorContent from "~/components/contents/ErrorContent";
import InfoContent from "~/components/contents/InfoContent";
import ExtraContent from "~/components/contents/ExtraContent";
// Define panel configuration at module scope to keep component references stable
const panelingConfig = {
// repeat the route path here (with or without '*')
path: 'paneling',
// component displayed when no panels are active
indexComponent: IndexPage,
// component displayed for unknown panel names
errorComponent: createCustomPanel(
ErrorContent,
CustomPanel
),
// maximum number of panels in the stack
max: 8,
// character used to separate the panel name, id and extras within a URL segment (default ':')
extrasSeparator: ':',
// registered panels
panels: {
// simple panel at /paneling/info
info: createCustomPanel(
InfoContent,
CustomPanel
),
// panel with ID support at /paneling/extra:<id>
'extra:': createCustomPanel(
ExtraContent,
CustomPanel
)
}
};
export function meta({ }: Route.MetaArgs) {
return [
{ title: "Paneling" },
{ name: "description", content: "Welcome to React Router Paneling!" }
];
}
export async function clientLoader(args: Route.ClientLoaderArgs) {
const clientData = await createClientLoader(panelingConfig)(args)
if (clientData instanceof Response) return clientData
return { ...clientData };
}
export function HydrateFallback() {
return <div>Loading...</div>;
}
export default function Paneling() {
// we will update this after defining the panels
return <div>Paneling</div>
}Panel name convention: A panel registered as
'extra:'(with a trailing:) expects an ID segment. A panel registered as'info'(without trailing:) does not.
3. Extending panel props
If you need to pass additional props to your panels (e.g., the panel's index), define extension types:
// types.ts
import type { IPanelContentProps, IPanelProps } from "@novice1-react/react-router-paneling"
export type PanelPropsExtension = {
panelIndex: number,
}
export type ContentPropsExtension = {
panelIndex: number
}
export type PanelProps = IPanelProps<ContentPropsExtension> & PanelPropsExtension
export type ContentProps = IPanelContentProps & ContentPropsExtensionThen use those types in your components:
// components/panels/CustomPanel.tsx
import type { PanelProps } from '../../types'
export default function CustomPanel ({
panelIndex,
content,
children
}: PanelProps) {
return <div>
{content ? content({ panelIndex }) : children}
</div>
}// components/contents/ExtraContent.tsx
import type { ContentProps } from '../../types'
export default function ExtraContent ({
panelIndex,
id
}: ContentProps) {
return (
<div>
<h1>Extra panel</h1>
<p>My ID is {id}</p>
<p>My index is "{panelIndex}"</p>
</div>
);
}// components/contents/InfoContent.tsx
import { Link } from 'react-router'
import type { ContentProps } from '../../types'
export default function InfoContent ({
panelIndex,
currentPath
}: ContentProps) {
return (
<div>
<h1>Info panel</h1>
<p>My index is "{panelIndex}"</p>
<div>
<Link to={currentPath + '/extra:4448-927777-633-3444666'}>
open extra panel 4448-927777-633-3444666
</Link>
</div>
</div>
);
}// components/contents/ErrorContent.tsx
import type { ContentProps } from '../../types'
export default function ErrorContent ({
panelIndex
}: ContentProps) {
return (
<div>
<h1>Error panel</h1>
<p>My index is "{panelIndex}"</p>
</div>
);
}// components/IndexPage.tsx
import { Link } from 'react-router'
export default function IndexPage () {
return (
<div>
<h1>Index page</h1>
<div>
<Link to='info'>open info panel</Link>
</div>
</div>
);
}4. Rendering panels
In Framework Mode, your route component handles the rendering. You can use one of three approaches:
Note: Render only one instance of
Panelingor callpaneling()fromusePanelingonly once per page. Multiple instances would render duplicate panel stacks.
With Paneling
// routes/paneling.tsx
import {
// ...
Paneling as ReactRouterPaneling,
type FunctionExtension
} from "@novice1-react/react-router-paneling";
import type {
PanelPropsExtension
} from '../types';
// ...
export default function Paneling() {
const extension: FunctionExtension<PanelPropsExtension> = (panelIndex) => {
return {
panelIndex
}
}
return <div>
<ReactRouterPaneling extension={extension} />
</div>
}With usePaneling
// routes/paneling.tsx
import {
// ...
usePaneling,
type FunctionExtension
} from "@novice1-react/react-router-paneling";
import type {
PanelPropsExtension,
ContentPropsExtension
} from '../types';
// ...
export default function Paneling() {
const extension: FunctionExtension<PanelPropsExtension> = (panelIndex) => {
return {
panelIndex
}
}
const { paneling } = usePaneling({ extension })
// or strictly typed:
// const { paneling } = usePaneling<ContentPropsExtension, PanelPropsExtension>({ extension })
return <div>{paneling()}</div>
}With LoaderData
For full control over rendering:
// routes/paneling.tsx
import {
createElement
} from 'react';
import {
useLoaderData,
useLocation
} from "react-router";
import {
// ...
createCustomPanelProps,
type LoaderData
} from "@novice1-react/react-router-paneling";
import type {
PanelPropsExtension,
ContentPropsExtension
} from '../types';
// ...
export default function Paneling() {
const { splat, stack } = useLoaderData<LoaderData<ContentPropsExtension, PanelPropsExtension>>();
const { pathname } = useLocation()
return <div>
{
stack.flatMap((c, i) => [
createElement(
c.component,
{
key: `panel-${i}`,
...createCustomPanelProps(c, splat, pathname, {
panelIndex: i
}),
},
null
)
])
}
</div>
}5. Programmatic navigation with usePanelNav
The usePanelNav hook provides programmatic navigation between panels. It returns:
createPanelPath(segments)- builds a panel path string from an array of segments.navigate(segments, navigateTo?)- navigates to the generated panel path. Optionally accepts a custom navigation function.basePath- the base URL prefix for the panels.
Example:
import { usePanelNav } from "@novice1-react/react-router-paneling";
function MyComponent() {
const { navigate, createPanelPath, basePath } = usePanelNav();
function openUser() {
// Navigates to <basePath>/user:abc123:role=admin/settings
navigate([
{ panel: 'user', id: 'abc123', extras: { role: 'admin' } },
{ panel: 'settings' }
]);
}
// You can also just build the path without navigating
const path = createPanelPath([{ panel: 'info' }]); // "info"
return <button onClick={openUser}>Open user panel</button>;
}More Framework Mode examples:
