Navigation

Segmented nav

Links between sibling pages or views, drawn like tabs or a segmented control, that navigate for real.

Built on
<nav><a href>aria-current
Client JavaScript
None
Works without React
Yes, as HTML and CSS
Source, gzipped
1.7 kB
Built from
Icon
Try its options

Loading its options.

Code
import { SegmentedNav } from "@/components/equip/segmented-nav";

export default function Demo() {
  return (
    <SegmentedNav
      aria-label="Billing"
      items={[
        { label: "Plan", href: "#plan", current: true },
        { label: "Usage", href: "#usage" },
        { label: "Invoices", href: "#invoices" },
      ]}
    />
  );
}
Your versionchange its options in Look, This part
HTMLworks in any framework, with the part's CSS
<nav aria-label="Billing" class="eq-segmented-nav" data-variant="segmented">
  <ul class="eq-segmented-nav-list">
    <li class="eq-segmented-nav-item">
      <a href="#plan" class="eq-segmented-nav-link" aria-current="page">
        <span class="eq-segmented-nav-label">Plan</span>
      </a>
    </li>
    <li class="eq-segmented-nav-item">
      <a href="#usage" class="eq-segmented-nav-link">
        <span class="eq-segmented-nav-label">Usage</span>
      </a>
    </li>
    <li class="eq-segmented-nav-item">
      <a href="#invoices" class="eq-segmented-nav-link">
        <span class="eq-segmented-nav-label">Invoices</span>
      </a>
    </li>
  </ul>
</nav>

Examples

Project sections

The underline variant suits the row under a page title; each link is a separate page, so the browser's back button walks through them.

Code
import { SegmentedNav } from "@/components/equip/segmented-nav";

export default function Example() {
  return (
    <SegmentedNav
      aria-label="Project"
      variant="underline"
      style={{ width: "100%" }}
      items={[
        { label: "Overview", href: "#overview", icon: "layout", current: true },
        { label: "Deployments", href: "#deployments", icon: "rocket", count: 128 },
        { label: "Logs", href: "#logs", icon: "terminal" },
        { label: "Analytics", href: "#analytics", icon: "bar-chart" },
        { label: "Settings", href: "#settings", icon: "settings" },
      ]}
    />
  );
}
HTMLworks in any framework, with the part's CSS
<nav aria-label="Project" style="width:100%" class="eq-segmented-nav" data-variant="underline">
  <ul class="eq-segmented-nav-list">
    <li class="eq-segmented-nav-item">
      <a href="#overview" class="eq-segmented-nav-link" aria-current="page">
        <svg aria-hidden="true" focusable="false" class="eq-icon" data-size="s" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
          <path d="M6 4h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2z" />
          <path d="M4 9.5h16" />
          <path d="M9.5 9.5V20" />
        </svg>
        <span class="eq-segmented-nav-label">Overview</span>
      </a>
    </li>
    <li class="eq-segmented-nav-item">
      <a href="#deployments" class="eq-segmented-nav-link">
        <svg aria-hidden="true" focusable="false" class="eq-icon" data-size="s" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
          <path d="M12 2.5c3 2 4.5 5.5 4.5 9.5v4.5h-9V12c0-4 1.5-7.5 4.5-9.5z" />
          <path d="M7.5 12.5 5 15v3.5h2.5M16.5 12.5 19 15v3.5h-2.5" />
          <path d="M10.5 9a1.5 1.5 0 1 0 3 0a1.5 1.5 0 1 0 -3 0" />
          <path d="M12 19.25v2" />
        </svg>
        <span class="eq-segmented-nav-label">Deployments</span>
        <span class="eq-segmented-nav-count">128</span>
      </a>
    </li>
    <li class="eq-segmented-nav-item">
      <a href="#logs" class="eq-segmented-nav-link">
        <svg aria-hidden="true" focusable="false" class="eq-icon" data-size="s" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
          <path d="M5 4.5h14a2 2 0 0 1 2 2v11a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-11a2 2 0 0 1 2 -2z" />
          <path d="M7 9.5l3 2.5-3 2.5" />
          <path d="M12.5 15h4.5" />
        </svg>
        <span class="eq-segmented-nav-label">Logs</span>
      </a>
    </li>
    <li class="eq-segmented-nav-item">
      <a href="#analytics" class="eq-segmented-nav-link">
        <svg aria-hidden="true" focusable="false" class="eq-icon" data-size="s" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
          <path d="M3.5 20h17" />
          <path d="M7.5 20v-7.5M12 20V4M16.5 20V9.5" />
        </svg>
        <span class="eq-segmented-nav-label">Analytics</span>
      </a>
    </li>
    <li class="eq-segmented-nav-item">
      <a href="#settings" class="eq-segmented-nav-link">
        <svg aria-hidden="true" focusable="false" class="eq-icon" data-size="s" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
          <path d="M18.44 10.57L20.9 10.67A9 9 0 0 1 20.9 13.33L18.44 13.43A6.6 6.6 0 0 1 17.57 15.55L19.23 17.35A9 9 0 0 1 17.35 19.23L15.55 17.57A6.6 6.6 0 0 1 13.43 18.44L13.33 20.9A9 9 0 0 1 10.67 20.9L10.57 18.44A6.6 6.6 0 0 1 8.45 17.57L6.65 19.23A9 9 0 0 1 4.77 17.35L6.43 15.55A6.6 6.6 0 0 1 5.56 13.43L3.1 13.33A9 9 0 0 1 3.1 10.67L5.56 10.57A6.6 6.6 0 0 1 6.43 8.45L4.77 6.65A9 9 0 0 1 6.65 4.77L8.45 6.43A6.6 6.6 0 0 1 10.57 5.56L10.67 3.1A9 9 0 0 1 13.33 3.1L13.43 5.56A6.6 6.6 0 0 1 15.55 6.43L17.35 4.77A9 9 0 0 1 19.23 6.65L17.57 8.45A6.6 6.6 0 0 1 18.44 10.57z" />
          <path d="M9.25 12a2.75 2.75 0 1 0 5.5 0a2.75 2.75 0 1 0 -5.5 0" />
        </svg>
        <span class="eq-segmented-nav-label">Settings</span>
      </a>
    </li>
  </ul>
</nav>

Filtered views

Each view is a URL with a query, so it can be shared and bookmarked; when the row is wider than its box it scrolls sideways inside itself.

Code
import { SegmentedNav } from "@/components/equip/segmented-nav";

export default function Example() {
  return (
    <SegmentedNav
      aria-label="Issues"
      items={[
        { label: "Open", href: "#issues?state=open", count: 24, current: true },
        { label: "Assigned to me", href: "#issues?assignee=me", count: 3 },
        { label: "Closed", href: "#issues?state=closed", count: 412 },
      ]}
    />
  );
}
HTMLworks in any framework, with the part's CSS
<nav aria-label="Issues" class="eq-segmented-nav" data-variant="segmented">
  <ul class="eq-segmented-nav-list">
    <li class="eq-segmented-nav-item">
      <a href="#issues?state=open" class="eq-segmented-nav-link" aria-current="page">
        <span class="eq-segmented-nav-label">Open</span>
        <span class="eq-segmented-nav-count">24</span>
      </a>
    </li>
    <li class="eq-segmented-nav-item">
      <a href="#issues?assignee=me" class="eq-segmented-nav-link">
        <span class="eq-segmented-nav-label">Assigned to me</span>
        <span class="eq-segmented-nav-count">3</span>
      </a>
    </li>
    <li class="eq-segmented-nav-item">
      <a href="#issues?state=closed" class="eq-segmented-nav-link">
        <span class="eq-segmented-nav-label">Closed</span>
        <span class="eq-segmented-nav-count">412</span>
      </a>
    </li>
  </ul>
</nav>

Props

PropTypeDefaultWhat it does
items{ label: ReactNode; href: string; current?: boolean; icon?: IconName | ReactElement; count?: ReactNode }[]noneThe sibling pages in order. Any other anchor attribute on an item goes to its link.
variant"segmented" | "underline""segmented"segmented fills the current page inside a track; underline draws an ink line under it, for the row under a page title.
aria-labelstringnoneNames the landmark, such as "Project". Give one whenever a page has more than one nav.
...restComponentProps<"nav">noneEvery native nav attribute.

Anatomy

root.eq-segmented-navA nav landmark with data-variant.
list.eq-segmented-nav-listScrolls sideways inside itself when the links do not fit.
item.eq-segmented-nav-item
link.eq-segmented-nav-linkThe current page has aria-current="page".
label.eq-segmented-nav-label
count.eq-segmented-nav-count

Keyboard

TabMoves through the links in order.
EnterFollows the focused link.

Notes

  • Reach for Tabs when the panels live on one page and switch in place; reach for this when each item is its own URL.
  • There are no tab roles and no arrow-key handling, because these are links: the browser's back button, new tabs and bookmarks all work.
  • The current page is marked with aria-current="page", so screen readers say which one you are on.
  • A count is part of the link text, so "Deployments 128" is what is read; keep it short.
  • Renders on the server with no client JavaScript.

Source

Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.

components/equip/segmented-nav.tsx44 lines
import type { ComponentProps, ReactElement, ReactNode } from "react";
import { Icon, type IconName } from "./icon";
import "./segmented-nav.css";

export type SegmentedNavItem = Omit<ComponentProps<"a">, "children"> & {
  label: ReactNode;
  href: string;
  /** Marks the page the person is on with aria-current="page". */
  current?: boolean;
  /** An Icon name, or your own 16px element, before the label. */
  icon?: IconName | ReactElement;
  /** A count after the label, such as open issues. */
  count?: ReactNode;
};

export type SegmentedNavProps = ComponentProps<"nav"> & {
  /** The sibling pages or sections, in order. */
  items: SegmentedNavItem[];
  /** segmented draws a track with the current page filled; underline draws a line under it. */
  variant?: "segmented" | "underline";
};

export function SegmentedNav({ items, variant = "segmented", className, ...rest }: SegmentedNavProps) {
  return (
    <nav {...rest} className={["eq-segmented-nav", className].filter(Boolean).join(" ")} data-variant={variant}>
      <ul className="eq-segmented-nav-list">
        {items.map(({ label, href, current = false, icon, count, className: linkClass, ...link }) => (
          <li key={href} className="eq-segmented-nav-item">
            <a
              {...link}
              href={href}
              className={["eq-segmented-nav-link", linkClass].filter(Boolean).join(" ")}
              aria-current={current ? "page" : undefined}
            >
              {icon != null && (typeof icon === "string" ? <Icon name={icon} size="s" /> : icon)}
              <span className="eq-segmented-nav-label">{label}</span>
              {count != null && <span className="eq-segmented-nav-count">{count}</span>}
            </a>
          </li>
        ))}
      </ul>
    </nav>
  );
}
components/equip/segmented-nav.css132 lines
@layer equip {
  .eq-segmented-nav {
    min-width: 0;
    max-width: 100%;
    font: var(--eq-weight-strong) var(--eq-text-s) / 1 var(--eq-font);
  }

  /* Scrolls sideways inside itself when the items do not fit, so the page never does.
     The padding keeps focus rings inside the scroll area. */
  .eq-segmented-nav-list {
    box-sizing: border-box;
    display: flex;
    gap: 2px;
    max-width: 100%;
    margin: 0;
    padding: 3px;
    overflow-x: auto;
    overscroll-behavior-x: contain;
    scrollbar-width: none;
    list-style: none;
  }

  .eq-segmented-nav-list::-webkit-scrollbar {
    display: none;
  }

  .eq-segmented-nav[data-variant="segmented"] .eq-segmented-nav-list {
    width: max-content;
    border: 1px solid var(--eq-line-strong);
    border-radius: var(--eq-radius-m);
    background: var(--eq-raised);
  }

  .eq-segmented-nav[data-variant="underline"] .eq-segmented-nav-list {
    gap: var(--eq-space-1);
    padding: 3px 3px 0;
    border-bottom: 1px solid var(--eq-line);
  }

  .eq-segmented-nav-item {
    display: flex;
    flex: none;
  }

  .eq-segmented-nav-link {
    position: relative;
    display: inline-flex;
    align-items: center;
    gap: var(--eq-space-2);
    height: var(--eq-h-s);
    padding-inline: var(--eq-space-3);
    border-radius: var(--eq-radius-s);
    color: var(--eq-ink-2);
    text-decoration: none;
    white-space: nowrap;
    transition:
      background-color var(--eq-dur) var(--eq-ease),
      color var(--eq-dur) var(--eq-ease),
      scale var(--eq-dur) var(--eq-ease);
  }

  .eq-segmented-nav-link > svg {
    flex: none;
    width: 16px;
    height: 16px;
  }

  .eq-segmented-nav-count {
    display: inline-grid;
    place-items: center;
    box-sizing: border-box;
    min-width: 20px;
    height: 20px;
    padding-inline: var(--eq-space-1);
    border-radius: var(--eq-radius-s);
    background: var(--eq-tint-strong);
    color: inherit;
    font-size: var(--eq-text-xs);
    font-variant-numeric: tabular-nums;
  }

  .eq-segmented-nav[data-variant="segmented"] .eq-segmented-nav-link[aria-current="page"] {
    background: var(--eq-solid);
    color: var(--eq-solid-ink);
  }

  .eq-segmented-nav[data-variant="segmented"] .eq-segmented-nav-link[aria-current="page"] .eq-segmented-nav-count {
    background: color-mix(in srgb, var(--eq-solid-ink) 18%, transparent);
  }

  /* Underline: the link sits on the list's line and draws a 2px ink bar over it when current. */
  .eq-segmented-nav[data-variant="underline"] .eq-segmented-nav-link {
    height: var(--eq-h-m);
    padding-inline: var(--eq-space-2);
    border-radius: var(--eq-radius-s) var(--eq-radius-s) 0 0;
  }

  .eq-segmented-nav[data-variant="underline"] .eq-segmented-nav-link::after {
    content: "";
    position: absolute;
    inset: auto var(--eq-space-2) 0;
    height: 2px;
    border-radius: 1px;
    background: var(--eq-ink);
    scale: 0 1;
    transition: scale var(--eq-dur-move) var(--eq-spring);
  }

  .eq-segmented-nav[data-variant="underline"] .eq-segmented-nav-link[aria-current="page"] {
    color: var(--eq-ink);
  }

  .eq-segmented-nav[data-variant="underline"] .eq-segmented-nav-link[aria-current="page"]::after {
    scale: 1 1;
  }

  @media (hover: hover) {
    .eq-segmented-nav-link:hover:not([aria-current="page"]) {
      background: var(--eq-tint);
      color: var(--eq-ink);
    }
  }

  .eq-segmented-nav-link:active {
    scale: var(--eq-press);
  }

  .eq-segmented-nav-link:focus-visible {
    outline: 2px solid var(--eq-focus);
    outline-offset: -2px;
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index