Tab Links New
A .tab-links bar that looks like
Tabs but navigates. The
items are links, the one matching the current page carries
aria-current, and the highlight is a pseudo element so
it can animate from one position to the next.
Live demo
Every bar on this page switches in place. A click handler cancels the
navigation and hands the change to
document.startViewTransition(), so the highlight slides
instead of jumping.
Every tab here is an <a>. The click handler cancels the
navigation and moves aria-current inside
document.startViewTransition(), so this panel and the
highlight change in the same frame.
Nothing was loaded to get here. The browser took one snapshot before the swap and one after, then interpolated between them.
The active state is still only aria-current in the HTML.
Move the attribute and the highlight follows, because it is anchored to
whichever link carries it.
<nav class="tab-links" aria-label="Demo views">
<a href="/overview" aria-current="page">Overview</a>
<a href="/activity">Activity</a>
<a href="/settings">Settings</a>
</nav>
Markup
-
Wrap the links in a
nav.tab-linkswith anaria-label.
A list of links to other pages is navigation, not a tab widget, so there is norole="tablist"here. Screen readers announce it as a labelled navigation landmark. -
Put
aria-current="page"on the link for the current URL.
This is the only piece of state. The highlight is anchored to that link withanchor-name, so moving the attribute moves the highlight. With no current link the highlight is hidden. -
Use
aria-disabled="true"to soften a link you do not want followed yet.
There is nodisabledattribute on<a>. Drop thehrefas well if the link should be unreachable by keyboard.
View transitions
The highlight is a ::before on the bar rather than a
background on the active link. One element, present in both the old
and the new state, in a different place each time. That is exactly
what a view transition needs to interpolate.
The component names it and tags it with a view transition class. It
also names each link, which matters more than it sounds: a named
element is lifted out of the page snapshot and drawn on top of it, so
if only the highlight were named it would slide over the
labels for the length of the transition. Naming the links lifts them
out too, and because they paint after the highlight they land back on
top of it. They use view-transition-name: match-element,
so the names stay unique however many bars a page holds.
Switching in place
Move aria-current inside
document.startViewTransition(). This is the handler every
demo on this page runs.
bar.addEventListener("click", (event) => {
const link = event.target.closest("a[href]");
if (!link || link.hasAttribute("aria-current")) return;
event.preventDefault();
document.startViewTransition(() => {
for (const a of bar.querySelectorAll("a"))
a.removeAttribute("aria-current");
link.setAttribute("aria-current", "page");
// ...and render whatever the tab controls.
});
});
A router does the same thing with the route change in the callback. Tune the motion through the view transition classes, which every bar and every link share:
/* the sliding highlight */
::view-transition-group(.ui-tab-links) {
animation-duration: 400ms;
animation-timing-function: var(--ease-snap);
}
/* the labels riding above it */
::view-transition-group(.ui-tab-link),
::view-transition-old(.ui-tab-link),
::view-transition-new(.ui-tab-link) {
animation-duration: 400ms;
}
Across documents
The bar also works with no JavaScript at all. Let the links navigate,
render aria-current on the server, and add the
@view-transition opt-in to both documents.
<!-- in the head of every page that shows the bar -->
<style>
@view-transition {
navigation: auto;
}
</style>
<!-- optional: hold the first render until the bar has been parsed.
Name an element that comes after the bar, not the bar itself. -->
<link rel="expect" blocking="render" href="#after-views" />
Put the opt-in in the <head>. The
browser captures the incoming page right before its first rendering
opportunity, so it has to know the page opted in by then. A
<style> further down the body is a race, and it is
the kind of race that flips depending on how fast the stylesheets
load. A transition that runs with devtools open and not without it is
almost always this: an empty cache slows the first paint down enough
for the parser to reach the rule in time, and a warm cache does not.
<link rel="expect"> covers the other end of the
same problem. It holds the first render until the named element shows
up, so the browser cannot capture the new page before the bar exists.
Without it, a bar far down a long page can be missing at capture time
and the highlight fades instead of gliding. Point it at an element
that comes after the bar: the block lifts as soon as the
named element itself lands in the DOM, which for the bar would be
before its links have been parsed.
With icons
Links accept an inline icon next to the label.
<nav class="tab-links" aria-label="Views">
<a href="/board" aria-current="page"
><svg><!-- icon --></svg> Board</a
>
<a href="/table"
><svg><!-- icon --></svg> Table</a
>
<a href="/chart"
><svg><!-- icon --></svg> Chart</a
>
</nav>
Disabled link
Add aria-disabled="true" and remove the
href.
<nav class="tab-links" aria-label="Dashboard">
<a href="/overview" aria-current="page">Overview</a>
<a href="/analytics">Analytics</a>
<a aria-disabled="true">Reports</a>
</nav>
Several bars per page
A view transition name has to be unique in a document. Every bar uses
ui-tab-links by default, so give each extra bar its own
name through --ui-tab-links-name, or set it to
none to leave that bar out of the transition entirely.
Each bar on this page carries its own name, which is why all of them animate.
<nav class="tab-links" aria-label="Folders">
<a href="/inbox" aria-current="page">Inbox</a>
<a href="/archive">Archive</a>
</nav>
<nav class="tab-links" aria-label="Sort" style="--ui-tab-links-name: ui-sort">
<a href="/inbox?sort=new">Newest</a>
<a href="/inbox?sort=old" aria-current="page">Oldest</a>
</nav>