Link

Link is the only way to move between pages. It wraps next/link and routes every click through the app's shared navigate function. That one path gives every link the same behavior: the page dims into the pending pulse, then the new route slides in. A raw <a> or a bare next/link skips all of that, so neither belongs in azucar for an internal route.

import Link from "@haloid/azucar/ui/link";

What it changes about next/link

On its own, next/link pushes the route itself. This component cancels that. Its onNavigate handler calls event.preventDefault() and passes the href to useNavigation().navigate, which wraps router.push in a React transition. While the transition is pending, PageTransition reads isPending and pulses the page. When the route lands, the new page slides in from the correct side.

The anchor stays a real anchor. Prefetch works as in next/link. A modified click never reaches onNavigate, so cmd-click opens a new tab and right-click copies a real address. The interception applies only to a plain left click in the current tab.

Two next/link props are excluded on purpose: replace and scroll. navigate always pushes, always with default scroll. To accept the props would drop them silently. If a call site needs them, extend navigate(href, opts) first, then widen the props here. onNavigate is also not on the surface. The component owns it. href narrows to a plain string. The UrlObject form is out.

<Link href="/reconciliation/overview">Overview</Link>
<Link href="/reconciliation/queue?facility=TOR5">
Discrepancies at TOR5
</Link>

Wrap the visible element

Link renders a bare anchor with no classes of its own. Style it through className, or put the visible element inside it. The app uses both forms.

The sidebar wraps a ghost Button in a Link. The item looks like every other action but navigates like a link:

// packages/azucar/src/sidebar.tsx, trimmed
<Link key={item.path} href={item.path}>
<Button variant="ghost">{item.title}</Button>
</Link>

CardAction does the same with its header chip. With an href it becomes a real link. With onClick it stays a button:

// packages/azucar/src/ui/card.tsx
if (!props.disabled && props.href !== undefined) {
return (
<Link href={props.href}>
<HeaderChip>{props.children}</HeaderChip>
</Link>
);
}

The third form styles the anchor directly. ProseA in src/app/(docs)/_components/prose.tsx passes underline classes through className for internal hrefs, and falls back to a plain <a target="_blank"> for external ones. Link assumes an internal route. External URLS get a plain anchor.

The demo below points at the page you are reading, so a click goes nowhere new. A live Link with a real href navigates. Hover it to see the address, or cmd-click to open these docs in a second tab.

<Link
href="/components/link"
className="text-fg-4 underline decoration-fg-1 underline-offset-2 hover:decoration-fg-4 transition-base"
>
This very page
</Link>

API

// every next/link prop except the four below (prefetch, className, children, ...)
type Props = Omit<
ComponentProps<typeof NextLink>,
"href" | "onNavigate" | "replace" | "scroll"
> & {
href: string; // internal route; plain string, no UrlObject
};
// onNavigate owned by the component; it cancels the push and calls navigate(href)
// replace excluded; navigate() always pushes
// scroll excluded; navigate() always uses default scroll

Not supported: replace, scroll, external URL handling, per-link pending state. isPending lives on the navigation provider and dims the whole page, so a single link never shows its own spinner.