Usage
Everything you need to drop pokenav into a React app. Prefer picking sprites visually? Use the playground and copy the config it generates.
Install#
npm install pokenavreact and react-dom (>=18) are peer dependencies. Import the stylesheet once, anywhere in your app — in the Next.js App Router, your root layout:
import 'pokenav/styles.css';Own the source with shadcn#
Use the registry when you want to restyle or change the component in your own repo. The base item is lightweight and accepts custom spriteUrls; it does not install the package or its sprite catalogue.
npx shadcn@latest add https://pokenav.devanshsoni.com/r/pokenav.jsonIt installs a client component into your configured components directory. ImportPokenav from @/components/pokenav/Pokenav (or the matching alias in your project). For pokemonId, install the adapter instead; it adds pokenav@^0.3.1 for the maintained catalogue and lazy sprite loader.
npx shadcn@latest add https://pokenav.devanshsoni.com/r/pokenav-pokemon.jsonRegistry installs are source snapshots: future package releases do not overwrite your local copy. Use npm install pokenav when you want the maintained package instead. Machine-readable usage guidance is available at /llms.txt.
Minimal example#
The smallest config that renders: a position, an orientation, and some items. Every other field has a default.
import { Pokenav } from 'pokenav';
import 'pokenav/styles.css';
<Pokenav
position="left"
orientation="vertical"
items={[
{ label: 'Home', href: '/', spriteUrl: '/icons/star.svg' },
{ label: 'Work', href: '/work', spriteUrl: '/icons/leaf.svg' },
{ label: 'Contact', href: '/contact', spriteUrl: '/icons/bolt.svg' },
]}
activeHref={pathname}
/>activeHref is a plain string you supply — the component compares it to each item's href and imports no router. Use usePathname() in the App Router, useRouter().pathname in Pages, useLocation().pathname in React Router, or your own scroll-spy.
Wiring up scroll-based active state#
On a one-page site the nav items are #anchors rather than routes, and both activeHref and scrollProgress come from the scroll position. The component computes neither itself — it takes them as props, because scroll-spy is app-specific: your sticky header, section heights, and layout vary. What follows is a reference to adapt, not something to import.
'use client';
import { useEffect, useState } from 'react';
const HEADER_HEIGHT = 80; // your sticky header's height, in px
/**
* Reference scroll-spy for a one-page anchor nav. Copy this into your own
* project and adapt it — it is example code, not an import from 'pokenav'.
*
* The activation line is the vertical CENTER of the viewport, not the top
* edge or a sticky header's bottom. With a top-of-viewport or header-bottom
* line, a tall section (a full-viewport hero) stays "current" until it has
* scrolled entirely off-screen, and the highlight lags one section behind
* what the reader is actually looking at.
*/
export function useSectionTrail(ids: readonly string[]) {
const [trail, setTrail] = useState(() => ({
activeHref: ids[0] !== undefined ? `#${ids[0]}` : undefined,
scrollProgress: 0,
}));
useEffect(() => {
let frame = 0;
const read = () => {
frame = 0;
const line = HEADER_HEIGHT + window.innerHeight / 2;
// Scroll offset at which each section crosses the line, read fresh every
// frame — section tops move after images load or webfonts settle.
const tops = ids
.map((id) => document.getElementById(id))
.filter((el): el is HTMLElement => el !== null)
.map((el) => el.getBoundingClientRect().top + window.scrollY - line);
if (tops.length === 0) return;
let index = 0;
for (let i = 0; i < tops.length; i += 1) {
if (tops[i] <= window.scrollY) index = i;
}
// A short final section (a footer) may never cross the center line;
// bottoming out the page should still activate it.
if (window.scrollY + window.innerHeight >= document.documentElement.scrollHeight - 2) {
index = tops.length - 1;
}
// Section-space progress: (index + fraction through this section) /
// (sections - 1). Pokenav's highlight reads it back with
// floor(progress * (sections - 1)), so this is the exact inverse and
// the reached node is always the section you are in.
const maxScroll = Math.max(
0,
document.documentElement.scrollHeight - window.innerHeight,
);
const start = tops[index];
const end = tops[index + 1] ?? Math.max(maxScroll, start);
const extent = end - start;
const fraction = extent > 0 ? clamp01((window.scrollY - start) / extent) : 0;
const scrollProgress = (index + fraction) / (tops.length - 1);
const activeHref = ids[index] !== undefined ? `#${ids[index]}` : undefined;
setTrail((prev) =>
prev.activeHref === activeHref && prev.scrollProgress === scrollProgress
? prev
: { activeHref, scrollProgress },
);
};
const onScroll = () => {
if (!frame) frame = window.requestAnimationFrame(read);
};
read();
window.addEventListener('scroll', onScroll, { passive: true });
window.addEventListener('resize', onScroll, { passive: true });
return () => {
if (frame) window.cancelAnimationFrame(frame);
window.removeEventListener('scroll', onScroll);
window.removeEventListener('resize', onScroll);
};
// ids is a fresh array each render; its joined value is the stable identity.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ids.join(',')]);
return trail;
}
function clamp01(value: number) {
return Math.min(1, Math.max(0, value));
}Feed the two returned values straight into the component, one source for both — the highlight and aria-current then cannot disagree:
const sections = ['intro', 'work', 'writing', 'contact'];
function Nav() {
const trail = useSectionTrail(sections);
return (
<Pokenav
position="left"
orientation="vertical"
items={sections.map((id) => ({
href: `#${id}`,
label: labels[id],
spriteUrl: icons[id],
}))}
activeHref={trail.activeHref}
scrollProgress={trail.scrollProgress}
/>
);
}The one thing to get right is the activation line — how far below the top of the viewport a section has to reach before it counts as current. A naive spy uses the top of the viewport or the bottom of a sticky header. With a tall section, such as a full-viewport hero, that line is too high: the hero stays highlighted until it has scrolled entirely off-screen, so the highlight lags one section behind what the reader is actually looking at. The fix is the vertical center of the viewport — the HEADER_HEIGHT + viewport / 2 in the hook above — so a section lights up as soon as its top passes the middle of the screen. Set scroll-padding-top on html to the same line —scroll-padding-top: calc(var(--header-height) + 50vh) — and a clicked nav item lands its target there too.
Adapt this, don't import it. The hook is example code, and the header height, section ids, and scroll container are yours to own. The package does ship useSectionProgress, which returns both values as one reading — but its activation line defaults to scroll-padding-top, the header-bottom line that exhibits the lag above. If you want the center line, adapt this hook.
Two entry points#
pokenav resolves spriteUrl. pokenav/pokemon resolves spriteUrl and pokemonId. Same component, same props, same styling — the difference is what ends up in your build.
| Import | Resolves | Use when |
|---|---|---|
pokenav | spriteUrl | Default. Your own artwork, any image source. Renders sprites in server HTML. |
pokenav/pokemon | spriteUrl + pokemonId | You want the bundled Pokémon catalogue. |
Reach for pokenav unless you need pokemonId. The core entry ships no catalogue.json and no dynamic-import context over the 898 bundled sprites, so your bundler emits nothing for them. Adding pokenav/pokemon brings a separately-loadable chunk per sprite, because the import context is built statically and your bundler cannot know which numeric ids a runtime config will pick. Those chunks are never downloaded unless a config names them, but they occupy build output, and no runtime flag removes them.
import { Pokenav } from 'pokenav/pokemon';
import 'pokenav/styles.css';
<Pokenav
position="left"
orientation="vertical"
items={[
{ label: 'Home', href: '/', pokemonId: 133 },
{ label: 'Work', href: '/work', pokemonId: 81 },
{ label: 'Contact', href: '/contact', pokemonId: 185 },
]}
theme={{ accentColor: '#8b5cf6' }}
activeHref={pathname}
/>Want a handful of the bundled sprites without the catalogue? Import them directly — import eevee from 'pokenav/sprites/133.png' — and pass them as spriteUrl. That form is statically analyzable, so your bundler emits exactly the sprites you named, and it server-renders.
NavConfig reference#
| Prop | Type | Default | Description |
|---|---|---|---|
position | 'left' | 'center' | 'right' | required | Which edge the trail anchors to. Also sets which side labels and the pokéball button sit on; center follows left there. |
orientation | 'vertical' | 'horizontal' | required | Which axis the trail runs along. Independent of position. |
items | NavItem[] | required | One node per item, in trail order. |
theme | NavTheme | {} | Optional styling. See below. |
matchActive | 'exact' | 'prefix' | (item, active) => boolean | exact | How activeHref is compared to each item's href. See below. |
NavItem
| Field | Type | Description |
|---|---|---|
label | string | Visible label, and the section half of the sprite's alt text. |
href | string | Link destination, and what activeHref is compared against. |
spriteUrl | string | { src: string } | Any custom image, or a static import — Next.js hands back StaticImageData rather than a string, and both work. Wins over pokemonId and skips the catalogue entirely. |
alt | string? | Explicit accessible name for the sprite. Without it, the name depends on the resolution path — pokemonId gives "Eevee — Home", spriteUrl gives "Home". Set '' to mark the sprite decorative and let the visible label carry the name. |
pokemonId | number? | National Dex id, 1–898. Resolved from the bundled catalogue, loaded lazily. Requires the pokenav/pokemon entry point. |
NavTheme
| Field | Type | Default | Description |
|---|---|---|---|
accentColor | string | #64748b | Single source of truth for the active ring, hover ring, inactive ring, trail and focus ring. No color is hardcoded. |
surfaceColor | string | Canvas | The background you paint behind the nav. Only ringStyle: pokeball reads it — an inactive node recedes by mixing its halves toward this color rather than dropping opacity, which is what stops a white half reading as a glow on a dark surface. The default follows the page's color-scheme. |
ringStyle | 'solid' | 'pokeball' | solid | pokeball draws a red/white split ring with a button on the seam. |
trailPath | 'straight' | 'wavy' | straight | wavy draws the trail as a curve through the node centers instead of a straight line. |
dotStyle | 'dotted' | 'dashed' | 'solid' | dotted | How the connecting trail is stroked. |
font | string | inherit | CSS font-family applied to labels. |
Component props beyond NavConfig
| Prop | Type | Description |
|---|---|---|
activeHref | string? | Current route. Omit and no node is active. |
scrollProgress | number? | 0–1. Fills the trail and moves the highlight. useScrollProgress() is exported for the page-scroll case. |
ariaLabel | string? | Landmark name. Defaults to 'Site navigation'. |
className | string? | Merged onto the root <nav>. |
Centered horizontal nav#
position and orientation are independent, so center works as a top nav. In horizontal, labels always sit below the node so the trail never runs through them.
<Pokenav
position="center"
orientation="horizontal"
items={[
{ label: 'Home', href: '/', pokemonId: 25 },
{ label: 'Work', href: '/work', pokemonId: 133 },
{ label: 'Writing', href: '/writing', pokemonId: 143 },
{ label: 'Contact', href: '/contact', pokemonId: 448 },
]}
theme={{ accentColor: '#16a34a', trailPath: 'wavy' }}
activeHref={pathname}
/>Active route matching#
By default activeHref must equal an item's href exactly. Set matchActive="prefix" to keep a section's node lit on its sub-pages — /blog stays active on /blog/some-post.
<Pokenav {...config} activeHref={pathname} matchActive="prefix" />Prefix matching handles the cases a bare startsWith gets wrong: / matches only / rather than lighting up on every page, /blog does not claim /blogroll, and trailing slashes, query strings and hashes are normalized away. For anything else — locale prefixes, hash routing — pass a function (itemHref, activeHref) => boolean.
Fixed rail#
The layout this component was built for: pinned to the side of the page, vertically centered in the space below a sticky header, hidden where there is no room for it.
<div className="nav-rail">
<Pokenav
position="left"
orientation="vertical"
items={items}
activeHref={usePathname()}
matchActive="prefix"
/>
</div>.nav-rail {
position: fixed;
left: 2rem;
/* Centered below the header, not in the viewport, so it never sits
behind it. dvh so collapsing mobile browser chrome doesn't shift it. */
top: calc(var(--header-height) + (100dvh - var(--header-height)) / 2);
transform: translateY(-50%);
z-index: 10;
}
@media (max-width: 900px) {
/* display:none, not opacity/visibility — it must leave the tab order too. */
.nav-rail { display: none; }
}Tab order follows the DOM, not the screen. A fixed rail is usually mounted at the end of a layout with the other overlays, which puts it after the entire page in the tab sequence even though it reads as the first thing on screen. Render it before <main> in source order, or pair it with a skip link.
Pick sprites visually#
Writing Dex numbers by hand is no fun. The playground lets you search all 898 sprites, filter by generation, assign them to nav items, and copy out the finished config.
Accessibility#
A pokemonId sprite carries alt text in the form "{Pokémon name} — {section name}"; a spriteUrl sprite has no species name, so it carries the label alone. Set alt on an item to override both and get one consistent name whichever path it uses. Either way the visible label is hidden from assistive technology while a named sprite is present, so the section is not announced twice — set alt="" to mark the sprite decorative and hand the name back to the label.
Nodes are ordinary links, so keyboard navigation works by default, with a deliberate focus ring drawn in your accent color. All motion — hover bounce, ring transitions, trail fill — is suppressed under prefers-reduced-motion, while state that carries meaning, like the active node's scale and the scroll fill position, is kept. If you position the nav as a fixed rail, check where it lands in the tab order.
Sprite licensing#
The code is MIT. The bundled Pokémon sprites are fan-derived artwork, not covered by that license, and rely on the same fan-tolerance precedent as PokéAPI — a precedent, not a guarantee. Read SPRITES-NOTICE.md before shipping anything commercial, and remember that spriteUrl lets you avoid the question entirely.