A navigation bar has to show where you are. <NavLink> is <Link> plus that knowledge: it compares its to against the current location and, on a match, adds the class active and the attribute aria-current="page". Two more classes appear in data and framework modes — pending while a loader for the destination is running, transitioning during a view transition. For anything they cannot express, className, style and children each accept a function receiving { isActive, isPending, isTransitioning }.
Matching is by prefix, so <NavLink to="/tasks"> stays active on /tasks/7 — what a section tab wants and never what a "Home" link wants, since every URL starts with /. end restricts the match to the exact path.
<style>body{font:15px/1.6 system-ui;margin:12px}nav a{margin-right:14px;text-decoration:none}
a.active{color:#b3261e;font-weight:700}</style>
<div id="root"></div>
<script type="importmap">{"imports":{"react":"https://esm.sh/react@19.3.0",
"react-dom/client":"https://esm.sh/react-dom@19.3.0/client?external=react",
"react-router":"https://esm.sh/react-router@8.4.0?external=react"}}</script>
<script src="https://cdn.jsdelivr.net/npm/@babel/standalone@7/babel.min.js"></script>
<script type="text/babel" data-type="module">
import React from 'react';
import { createRoot } from 'react-dom/client';
import { MemoryRouter, Routes, Route, NavLink, Link, useMatch } from 'react-router';
function Tab({ to, end, children }) { // a hand-built NavLink
const match = useMatch(end ? to : `${to}/*`); // a trailing /* makes it a prefix match
return <Link to={to} style={{ color: match ? '#1565c0' : '#444' }}>
{match ? '› ' : ''}{children}</Link>;
}
function App() {
return <MemoryRouter initialEntries={['/tasks/7']}>
<nav><NavLink to="/" end>Home</NavLink><NavLink to="/tasks">Tasks</NavLink>
<NavLink to="/help">Help</NavLink></nav>
<nav><Tab to="/" end>Home</Tab> <Tab to="/tasks">Tasks</Tab></nav>
<Routes>
<Route path="/" element={<h3>Home</h3>} />
<Route path="/tasks/:id" element={<h3>Task detail</h3>} />
</Routes>
</MemoryRouter>;
}
createRoot(document.getElementById('root')).render(<App />);
</script>
The second row is the custom-link pattern: useMatch(pattern) tests any pattern against the current URL and returns a match object or null, so a breadcrumb, a highlighted sidebar row or a disabled tab can be built from the same signal <NavLink> uses internally. Build your own only when the match drives something other than styling — a marker glyph, aria-disabled, a badge — because <NavLink> gets aria-current right and a hand-built link will not unless you remember it.