Link & router
StableA small router with no dependencies: Link changes page without a reload, useRouter says where you are, in history or hash mode.
RouterProvider holds the current path and matches it against your route patterns; Link is a real <a> (it takes every <a> attribute except href) that navigates in place; useRouter() returns the path, params and navigate(). History mode uses real paths; hash mode (#library.inputs.button) works where the page can’t own the address bar. It saves and restores scroll on Back, jumps to #anchors, can load a page’s code before showing it, and cross-fades with a View Transition when motion is allowed.
Links to real pages
import { Icon } from '@rchursin/product/icons/Icon'import { Link } from '@rchursin/product/lib/router'
/** * Real pages of this site: each Link changes page without a reload. The one you are on gets * aria-current="page", drawn here as a solid square. A full URL renders a plain <a>. */const pages = [ { to: '/', label: 'Home' }, { to: '/about', label: 'About' }, { to: '/library', label: 'Library' }, { to: '/library/navigation', label: 'Navigation' }, { to: '/library/navigation/link', label: 'Link & router' },]
export default function LinkList() { return ( <div className="flex w-full max-w-sm flex-col gap-3"> <ul className="flex flex-col"> {pages.map((p) => (Usage
import { Link, RouterProvider, useRouter } from '@rchursin/product/lib/router'const routes = ['/', '/about', '/projects/:slug']
// Once, around the app. mode="hash" where you can't own the address bar<RouterProvider routes={routes} mode="history" onNavigate={focusNewPage}> <App /></RouterProvider>
// Anywhere inside<Link to="/about">About</Link>const { path, params, navigate } = useRouter()Examples
useRouter
This page’s own location, live. The buttons call navigate() with an anchor and with a path.
import { Fragment } from 'react'import { useRouter } from '@rchursin/product/lib/router'import { Button } from '@rchursin/product/ui'
/** * useRouter() says where you are and moves you. These are this page's own values: jump to the * API and watch `anchor` change, or go to the Tabs page. */export default function UseRouterReadout() { const { path, pattern, params, anchor, mode, navigate } = useRouter() const rows = [ ['path', path], ['pattern', pattern || '(no match)'], ['params', JSON.stringify(params)], ['anchor', anchor || '(none)'], ['mode', mode], ]
return ( <div className="flex w-full max-w-md flex-col gap-4"> <dl className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-5 gap-y-2 rounded-xl bg-sunken p-4 font-mono text-[12.5px] ring-1 ring-line ring-inset"> {rows.map(([k, v]) => ( <Fragment key={k}> <dt className="text-fg-3">{k}</dt> <dd className="break-all text-fg">{v}</dd> </Fragment> ))} </dl> <div className="flex flex-wrap gap-2"> <Button size="sm" variant="secondary" icon="hash" onClick={() => navigate('#api')}> Jump to the API </Button> <Button size="sm" variant="ghost" iconRight="arrow-right" onClick={() => navigate('/library/navigation/tabs')}> Go to Tabs </Button> </div> </div> )}Path helpers
normalize, pathToHash, hashToPath and matchPath are plain functions: type a path and see what each returns.
import { useState } from 'react'import { hashToPath, matchPath, normalize, pathToHash } from '@rchursin/product/lib/router'import { Field, TextInput } from '@rchursin/product/ui'
/** * The pure helpers under the router, live. Type a path: normalize() tidies it, pathToHash() turns * it into the hash-mode token and back, and matchPath() finds the first pattern that fits. */const patterns = ['/', '/about', '/projects/:slug', '/library/:category', '/library/:category/:slug']
export default function PathHelpers() { const [input, setInput] = useState('library//navigation/link/') const path = normalize(input) const hash = pathToHash(path) const match = patterns.map((p) => ({ pattern: p, params: matchPath(p, path) })).find((m) => m.params)
const rows = [ ['normalize', path], ['pathToHash', `#${hash}`], ['hashToPath', hashToPath(hash)], ['matchPath', match ? `${match.pattern}\n${JSON.stringify(match.params)}` : 'null: no pattern matches'], ]
return ( <div className="flex w-full max-w-md flex-col gap-4"> <Field label="Path" hint="Try extra slashes, a trailing slash, or /projects/rchursin-ui."> {(p) => <TextInput {...p} value={input} onChange={(e) => setInput(e.target.value)} spellCheck={false} autoComplete="off" autoCapitalize="off" className="font-mono" />} </Field> <dl className="flex flex-col gap-2.5 rounded-xl bg-sunken p-4 text-[12.5px] ring-1 ring-line ring-inset"> {rows.map(([k, v]) => ( <div key={k} className="flex flex-col gap-0.5 sm:flex-row sm:gap-4"> <dt className="w-24 shrink-0 font-mono text-fg-3">{k}()</dt> <dd className="font-mono break-all whitespace-pre-wrap text-fg">{v}</dd> </div> ))} </dl> </div> )}API
Link props
| Prop | Type | Default | Description |
|---|---|---|---|
torequired | string | – | A site path ("/about"), a path with an anchor ("/library#inputs"), an anchor on this page ("#props"), or a full URL, which renders a plain <a>. |
replace | boolean | false | Replace the current history entry instead of adding one. |
RouterProvider props
| Prop | Type | Default | Description |
|---|---|---|---|
routesrequired | string[] | – | Route patterns, most specific first, e.g. "/library/:category/:slug". |
mode | 'history' | 'hash' | 'history' | history: real paths. hash: #about, #library.inputs.button (path segments joined with dots). |
initialPath | string | '/' | The path to render on the server (prerender), where there is no window. |
onNavigate | (location: Location) => void | – | Called after each change of page. Use it to move focus to the new <h1> and announce the title. |
prepare | (location: Location) => Promise<unknown> | – | Runs before a new page shows, e.g. to load its code. The old page stays until it settles. |
prepareTimeout | number | 2500 | Show the new page after this many milliseconds even if prepare hasn’t settled. |
childrenrequired | ReactNode | – | Your app. |
useRouter() returns
| Prop | Type | Default | Description |
|---|---|---|---|
path | string | – | The current path, always normalised, e.g. "/library/inputs/button". |
anchor | string | – | The #anchor inside the page, without #, or "". |
params | Record<string, string> | – | Values from the matched pattern, e.g. { category: "inputs", slug: "button" }. |
pattern | string | – | The pattern that matched, or "" when none did (show your 404). |
mode | 'history' | 'hash' | – | The provider’s mode. |
navigate | (to: string, options?: { replace?: boolean }) => void | – | Go to a path or anchor from code. |
href | (to: string) => string | – | The href for a path in the current mode ("/about" or "#about"). |
prefetch | (to: string) => void | – | Run prepare for a path early. Link calls it on hover and focus. |
Path helper functions
| Prop | Type | Default | Description |
|---|---|---|---|
normalize(path) | string | – | One leading slash, no doubled or trailing slashes: "library//inputs/" → "/library/inputs". |
matchPath(pattern, path) | Record<string, string> | null | – | Params when the path fits the pattern (":name" segments), otherwise null. |
pathToHash(path) | string | – | "/library/inputs/button" → "library.inputs.button"; "/" → "home". |
hashToPath(hash) | string | – | The reverse: "library.inputs.button" → "/library/inputs/button"; "" or "home" → "/". |
Accessibility
Keyboard
| Key | What it does |
|---|---|
| Enter | Follows the link. With ⌘, Ctrl or Shift held, the browser opens it in a new tab or window instead. |
- Link renders a real <a href>, so it is announced as a link, can be bookmarked, and middle-click, modifier clicks and target="_blank" all go to the browser.
- A Link to the page you are on gets aria-current="page" (unless it has an #anchor), so screen readers say “current page”.
- Jumping to an #anchor on the same page moves focus to that element as well as scrolling, the way a native link does.
- After a change of page the provider scrolls to the top (or restores the old position on Back) and calls onNavigate. Moving focus to the new <h1> and announcing the title is up to you there: this site does both in App.tsx.
- The cross-fade between pages only runs when the visitor allows motion.
- External links with target="_blank" get rel="noreferrer"; say “opens in a new tab” in the label.
Guidelines
Do
- Use Link for every page inside the app, and a plain <a> or Link with a full URL for other sites.
- Pass onNavigate to move focus to the new page’s heading.
- Write link text that makes sense on its own: “Read the Tabs docs”.
Don’t
- Don’t call navigate() from a button when a link would do; links can be opened in a new tab.
- Don’t nest a second RouterProvider inside the first.
In use
Related
- Site headerThe home pages’ sticky header in three styles: a full bar, a floating glass pill and a minimal mono row.
- Command paletteOne search field over every page and action, opened with ⌘K, grouped and ranked, run with Enter.
- Button linkA link cut from the same lit metal as Button, for calls to action that go somewhere.
- Drawn underlinesLink underlines that draw themselves in on hover and focus, or slide away from links that are underlined at rest.
Source
The component’s own files, exactly as they run on this site.
/** * A small router for the site, with no dependencies. * * Two modes, same API: * - history: real paths (/about, /library/inputs/button). Used on your own host. Every route is * also prerendered to static HTML at build time (scripts/prerender.ts), so search engines and * first paint get real pages. * - hash: #about, #library.inputs.button. Used where the page can't own the address bar (the * claude.ai preview only passes plain #tokens: letters, digits, . _ ~ -), so path segments are * joined with dots. * * <RouterProvider routes={patterns} mode="history"> … <Link to="/about">About</Link> … </RouterProvider> * const { path, params, navigate } = useRouter() * * After a navigation the router scrolls to the top (or to the #anchor), Back restores the old * scroll position, and with motion allowed (and the API present) pages cross-fade with a View * Transition. Focus and announcements are yours: onNavigate fires once the new page has * rendered, and the site uses it to focus the new <h1> (tabIndex -1) and announce its title. * prepare(location) lets you load the next page's code before the switch, so nothing flashes. */