• 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.

    <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-links with an aria-label.
      A list of links to other pages is navigation, not a tab widget, so there is no role="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 with anchor-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 no disabled attribute on <a>. Drop the href as 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>

    Search 5021 icons

    Type a name to find icons from the Tabler icon set.