Links and routers

Chameleon links should change the page through your router in a single-page app instead of reloading the page. We show you here how to do that for each framework. Each link stays a real link throughout: it opens in a new tab on a middle click, shows its address on hover, and screen readers announce it as a link.

These components become links when you set href: the action card, button, icon button, assist chip, suggestion chip, input chip, list item, menu item, and navigation tab. Each one also takes target, rel, and download, which mean what they mean on an HTML anchor.

A disabled or soft-disabled link doesn't navigate. Every link opens in the same tab unless you set target.

Why your router needs to be told⁠

A router that handles links by itself listens for clicks on the page and looks for the clicked anchor. A Chameleon component keeps its anchor inside itself, where that search can't reach, so without the steps below every click is a full page load. The page still works; it just reloads.

How you hand the click to your router depends on your framework.

Framework What to use
React ChameleonRouterProvider once, around your app
Blazor Nothing: links inside the app go through NavigationManager already
Web components routeNavigations, or a link-activate listener

React⁠

Wrap your app in ChameleonRouterProvider and give it your router's navigate function. Every link component beneath it then navigates through the router when its link points inside the app, meaning the same origin and the same tab. The browser handles any other link, and any click with a modifier key held.

The provider calls navigate with the path, query, and hash, as a string.

React Router⁠

import { Outlet, useNavigate } from "react-router";
import { ChameleonRouterProvider } from "@materia-technologies-inc/chameleon-components-react";

export function Layout() {
  const navigate = useNavigate();
  return (
    <ChameleonRouterProvider navigate={navigate}>
      <Outlet />
    </ChameleonRouterProvider>
  );
}
content_copy

useNavigate works only inside the router, so the provider goes in a layout route rather than above the router.

Next.js⁠

The App Router's useRouter is client-only, so the provider lives in a client component that your root layout renders.

"use client";
import { useRouter } from "next/navigation";
import { ChameleonRouterProvider } from "@materia-technologies-inc/chameleon-components-react";

export function ChameleonRouting({ children }) {
  const router = useRouter();
  return <ChameleonRouterProvider navigate={router.push}>{children}</ChameleonRouterProvider>;
}
content_copy

TanStack Router⁠

TanStack's navigate takes an options object, so pass the path as href.

import { Outlet, useNavigate } from "@tanstack/react-router";
import { ChameleonRouterProvider } from "@materia-technologies-inc/chameleon-components-react";

export function Layout() {
  const navigate = useNavigate();
  return (
    <ChameleonRouterProvider navigate={(path) => navigate({ href: path })}>
      <Outlet />
    </ChameleonRouterProvider>
  );
}
content_copy

Every link component also takes onNavigate. Where you set it, it replaces the provider's navigation for that one link, and Chameleon calls it only for a link inside the app. By the time it runs, Chameleon has already canceled the browser's navigation.

<CrListItem href="/orders/42" onNavigate={(detail) => openOrderPanel(detail.href)}>
  Order 42
</CrListItem>
content_copy

onLinkActivate runs first, for every link followed in the same tab. Call preventDefault() on its event to stop both the browser and the router.

Blazor⁠

Blazor needs no setup. A link component whose Href points inside the app (the same base address, opening in the same tab) cancels the browser's navigation and calls NavigationManager.NavigateTo. Your routes render as they would for a NavLink.

To navigate differently for one link, set OnNavigate. It replaces the NavigateTo call and Chameleon calls it only for a link inside the app.

<CbListItem TItem="string" Href="/orders/42" OnNavigate="OpenOrderPanel">Order 42</CbListItem>

@code {
    private string? _selectedOrder;

    private void OpenOrderPanel(LinkActivateEventArgs args) => _selectedOrder = args.Href;
}
content_copy

The component raises OnLinkActivate first, for every link followed in the same tab, with the same Href, Target, Download, and Rel.

Web components⁠

With the Navigation API⁠

routeNavigations gives your router every navigation inside the app, from any link on the page, Chameleon's or your own. It returns a function that stops the routing.

import { routeNavigations } from "@materia-technologies-inc/chameleon-components-web";

const stop = routeNavigations((url) => router.render(url));
content_copy

The Navigation API is in Chrome and Edge from version 102, Safari from 26.2, and Firefox from 147. In an older browser, routeNavigations does nothing, and links load pages normally until you add the listener below.

By the time your function runs, the address bar already shows the new address. Render the page for it. If your router insists on updating history itself, have it replace the current entry, not push a new one, or the back button needs two presses.

routeNavigations leaves to the browser everything that isn't a page change inside the app:

  • Links to other origins
  • Downloads
  • Links within the page
  • Form posts
  • Back and forward
  • Reloads
  • History updates your router makes itself

Pass a filter to leave more to the browser:

routeNavigations((url) => router.render(url), {
  filter: (url) => !url.pathname.startsWith("/api/"),
});
content_copy

Every link component fires link-activate just before the browser follows it in the same tab. The event bubbles out of the component and you can cancel it, so one listener on the document handles every Chameleon link on the page.

document.addEventListener("link-activate", (event) => {
  const url = new URL(event.detail.href, location.href);
  if (url.origin !== location.origin) {
    return;
  }
  event.preventDefault();
  router.navigate(url.pathname + url.search + url.hash);
});
content_copy

A component doesn't fire it for a click with a modifier key or the middle button, a link that opens in another tab, a download, or a disabled link. The browser handles those as it would for any anchor.

event.detail carries:

Field What it holds
href The address, as written on the component
target Where the link opens; empty means the same tab
download The file name to download under; null means the link navigates
rel The link's rel, as written
altKey, ctrlKey, metaKey, shiftKey Which modifier keys were held
originalEvent The click that followed the link, from a pointer or the Enter key

If your router already has a click listener on the document, resolveChameleonLink(event) finds the Chameleon link a click came through, in place of the anchor the router would look for. It returns null when there's none, or when the link can't navigate: it's disabled or soft-disabled, or it's a navigation tab in a tab list.

import { resolveChameleonLink } from "@materia-technologies-inc/chameleon-components-web";

document.addEventListener("click", (event) => {
  const link = resolveChameleonLink(event);
  if (link && link.target === "" && link.download === null) {
    // link.href is the address; link.host is the component
  }
});
content_copy

After the page changes⁠

A full page load moves focus to the new page and scrolls it to the top, so a screen reader announces the change. A routed navigation does only what your router does, so check what yours does.

Route Focus and scroll
routeNavigations The browser handles both: once your function finishes, focus returns to the start of the page, and the page scrolls to the top or to its #hash
A link-activate listener or resolveChameleonLink Your router handles both, because the browser's own navigation is canceled
React Your router decides. Next.js scrolls to the top on push and announces the new page to screen readers. TanStack Router scrolls to the top after each navigation. React Router restores scroll with <ScrollRestoration> in its framework and data modes, and leaves it to you in declarative mode. None of them moves focus, so move it to the new page's heading yourself
Blazor The same as for a NavLink. Blazor's project templates put FocusOnNavigate in the router, which moves focus to the new page's h1; add it if your router lacks it