Layout

Sidebar

Application side navigation with sections, badges and a footer, folding down to an icon rail.

Built on
<nav>aria-currentCSS anchor positioninganchor-scope
Client JavaScript
Yes, for state
Works without React
React only (needs JavaScript)
Source, gzipped
4.3 kB
Dependencies
React
Code
"use client";

import { useState, type CSSProperties } from "react";
import { Sidebar, SidebarItem, SidebarSection, SidebarToggle } from "@/components/equip/sidebar";

function Icon({ d }: { d: string }) {
  return (
    <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
      <path d={d} />
    </svg>
  );
}

const pages = [
  { name: "Home", d: "M2.5 7 8 2.5 13.5 7v6.5h-11Z M6.5 13.5V10h3v3.5" },
  { name: "Inbox", d: "M2.5 9.5 4 3.5h8l1.5 6v4h-11Z M2.5 9.5h3.5l.5 1.5h3l.5-1.5h3.5", badge: 3 },
  { name: "Projects", d: "M2.5 4.5h4l1 1.5h6v7h-11Z" },
  { name: "Reports", d: "M3 13V8.5 M6.5 13V3.5 M10 13V6.5 M13.5 13v-3" },
  { name: "Settings", d: "M8 5.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5Z M8 1.5v2 M8 12.5v2 M1.5 8h2 M12.5 8h2" },
];

export default function Demo() {
  const [current, setCurrent] = useState("Inbox");
  return (
    <div style={{ width: 200, height: 124 }}>
      <Sidebar
        label="Main"
        style={{ "--eq-sidebar-width": "200px", "--eq-sidebar-item-height": "24px", gap: 4 } as CSSProperties}
      >
        <SidebarToggle />
        <SidebarSection>
          {pages.map((page) => (
            <SidebarItem
              key={page.name}
              href={`#${page.name.toLowerCase()}`}
              icon={<Icon d={page.d} />}
              badge={page.badge}
              current={current === page.name}
              onClick={(event) => {
                event.preventDefault();
                setCurrent(page.name);
              }}
            >
              {page.name}
            </SidebarItem>
          ))}
        </SidebarSection>
      </Sidebar>
    </div>
  );
}

Examples

Sections and footer

Each section is a list named by its heading; on the rail the heading turns into a hairline but is still read out.

Code
import { Sidebar, SidebarFooter, SidebarItem, SidebarSection, SidebarToggle } from "@/components/equip/sidebar";

function Icon({ d }: { d: string }) {
  return (
    <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
      <path d={d} />
    </svg>
  );
}

export default function Example() {
  return (
    <div style={{ height: 380 }}>
      <Sidebar label="Workspace">
        <SidebarToggle />
        <SidebarSection label="Workspace">
          <SidebarItem href="#issues" icon={<Icon d="M8 2.5a5.5 5.5 0 1 0 0 11 5.5 5.5 0 0 0 0-11Z M8 5.5v3 M8 10.5v.01" />} current badge={12}>
            Issues
          </SidebarItem>
          <SidebarItem href="#cycles" icon={<Icon d="M13 8a5 5 0 1 1-1.5-3.5 M13 2.5v2.5h-2.5" />}>Cycles</SidebarItem>
          <SidebarItem href="#roadmap" icon={<Icon d="M2.5 4h7 M2.5 8h11 M2.5 12h5" />}>Roadmap</SidebarItem>
        </SidebarSection>
        <SidebarSection label="Teams">
          <SidebarItem href="#platform" icon={<Icon d="M3 3h4v4H3Z M9 3h4v4H9Z M3 9h4v4H3Z M9 9h4v4H9Z" />}>Platform</SidebarItem>
          <SidebarItem href="#growth" icon={<Icon d="M2.5 12.5 6.5 8.5l2.5 2.5 4.5-5 M10.5 6h3v3" />}>Growth</SidebarItem>
        </SidebarSection>
        <SidebarFooter>
          <SidebarSection>
            <SidebarItem href="#settings" icon={<Icon d="M8 5.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5Z M8 1.5v2 M8 12.5v2 M1.5 8h2 M12.5 8h2" />}>
              Settings
            </SidebarItem>
          </SidebarSection>
        </SidebarFooter>
      </Sidebar>
    </div>
  );
}

Starts as a rail

Hover or focus an icon to see its name beside it; the badge shrinks to a dot but its count stays in the link's name.

Code
import { Sidebar, SidebarItem, SidebarSection, SidebarToggle } from "@/components/equip/sidebar";

function Icon({ d }: { d: string }) {
  return (
    <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
      <path d={d} />
    </svg>
  );
}

export default function Example() {
  return (
    <div style={{ height: 220 }}>
      <Sidebar label="Main" defaultCollapsed>
        <SidebarToggle />
        <SidebarSection>
          <SidebarItem href="#dashboard" icon={<Icon d="M2.5 2.5h4.5v6h-4.5Z M9 2.5h4.5v3.5H9Z M9 8h4.5v5.5H9Z M2.5 10.5h4.5v3H2.5Z" />} current>
            Dashboard
          </SidebarItem>
          <SidebarItem href="#deployments" icon={<Icon d="M8 2v8 M5 7l3 3 3-3 M3 13.5h10" />} badge={2}>
            Deployments
          </SidebarItem>
          <SidebarItem href="#logs" icon={<Icon d="M3 4h10 M3 8h10 M3 12h6" />}>Logs</SidebarItem>
          <SidebarItem href="#billing" icon={<Icon d="M2 4.5h12v7H2Z M2 7h12" />}>Billing</SidebarItem>
        </SidebarSection>
      </Sidebar>
    </div>
  );
}

Frameless, inside a panel

With frame off the panel draws the edge and the links fill its width, and with collapsible off it never folds, so it needs no toggle.

Code
import { Sidebar, SidebarItem, SidebarSection } from "@/components/equip/sidebar";

function Icon({ d }: { d: string }) {
  return (
    <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
      <path d={d} />
    </svg>
  );
}

export default function Example() {
  return (
    <div
      style={{
        width: 220,
        padding: "var(--eq-space-3)",
        border: "1px solid var(--eq-line)",
        borderRadius: "var(--eq-radius-l)",
        background: "var(--eq-surface)",
      }}
    >
      <Sidebar label="Settings" frame={false} collapsible={false}>
        <SidebarSection label="Account">
          <SidebarItem href="#profile" icon={<Icon d="M8 7.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Z M3 13.5c.5-2.5 2.5-4 5-4s4.5 1.5 5 4" />} current>
            Profile
          </SidebarItem>
          <SidebarItem href="#security" icon={<Icon d="M4 7.5h8v6H4Z M5.5 7.5V5.5a2.5 2.5 0 0 1 5 0v2" />}>Security</SidebarItem>
          <SidebarItem href="#notifications" icon={<Icon d="M4.5 11V7.5a3.5 3.5 0 0 1 7 0V11 M3 11h10 M7 13.5h2" />} badge={2}>
            Notifications
          </SidebarItem>
        </SidebarSection>
        <SidebarSection label="Workspace">
          <SidebarItem href="#members" icon={<Icon d="M6 7a2 2 0 1 0 0-4 2 2 0 0 0 0 4Z M2 13c.4-2 1.8-3 4-3s3.6 1 4 3 M11 4a2 2 0 0 1 0 3.5 M12 10c1.2.4 1.8 1.4 2 3" />}>
            Members
          </SidebarItem>
          <SidebarItem href="#billing" icon={<Icon d="M2 4.5h12v7H2Z M2 7h12" />}>Billing</SidebarItem>
        </SidebarSection>
      </Sidebar>
    </div>
  );
}

Props

PropTypeDefaultWhat it does
labelstringnoneNames the navigation landmark, e.g. "Main".
collapsedbooleannoneControlled rail state.
defaultCollapsedbooleanfalseStarts as a rail when uncontrolled.
onCollapsedChange(collapsed: boolean) => voidnoneCalled when SidebarToggle is pressed.
framebooleantrueDraws the sidebar's own end border, background and padding at its set width. Off, it drops all three and fills the width of the drawer or panel that frames it.
collapsiblebooleantrueAllows folding to a rail. When false the sidebar stays open whatever collapsed says, and SidebarToggle renders nothing.
childrenReactNodenoneSidebarToggle, SidebarSection and SidebarFooter, in that order.
...restComponentProps<"nav">noneEvery native nav attribute.
SidebarSectionlabel?: stringnoneA list of items, named by its optional heading.
SidebarItemhref, icon, current?, badge?noneA link with an icon and a label. current sets aria-current="page"; badge shows a count or status after the label.
SidebarToggleComponentProps<"button">noneThe collapse button. Named "Sidebar" unless you pass aria-label.
SidebarFooterComponentProps<"div">nonePinned to the bottom under a hairline. Put a SidebarSection inside for links.
SidebarDefaultsframe?, collapsible?, childrennoneSets frame and collapsible for every Sidebar inside it, for a container that receives the sidebar from its caller, as App shell's drawer does. Props on a Sidebar win.
--eq-sidebar-widthCSS length240pxThe open width. Set it on the sidebar or any ancestor. A frameless sidebar ignores it and fills its container.
--eq-sidebar-heightCSS length100%The height. Set auto where a flex or grid parent stretches it, or where the page around it should scroll instead.
--eq-sidebar-item-heightCSS lengthvar(--eq-h-s)Row height; the rail is this plus the padding, so icons never move when it folds.

Anatomy

root.eq-sidebarThe nav. data-collapsed on the rail, data-frameless when frame is off.
toggle.eq-sidebar-toggle
toggle chevron.eq-sidebar-toggle-chevronTurns to point the way the sidebar will move.
section.eq-sidebar-section
section label.eq-sidebar-section-labelVisually hidden on the rail, where a hairline takes its place.
list.eq-sidebar-list
item.eq-sidebar-itemThe li.
link.eq-sidebar-link
icon.eq-sidebar-icon
label.eq-sidebar-labelOn the rail it becomes the floating name beside the item.
badge.eq-sidebar-badgeA dot on the icon's corner on the rail.
indicator.eq-sidebar-indicatorThe sliding start-edge bar, where anchor positioning exists.
footer.eq-sidebar-footer

Keyboard

TabShift+TabMoves through the toggle and the links in reading order.
EnterFollows a link.
EnterSpaceOn the toggle, folds the sidebar to a rail or opens it again.

Notes

  • It is a nav landmark of plain links, not a menu, so it uses Tab and no arrow keys, as the APG advises for site navigation.
  • On the rail every label stays in the DOM as the link's accessible name; it only becomes visible beside the item on hover or keyboard focus.
  • A badge's text stays in the link's name on the rail, so "Inbox 3" is still read even though only a dot shows.
  • The width animates on the plain ease, not the spring, so content beside the sidebar never bounces.
  • Pass current on the item for the page in view; the bar on its start edge slides from the previous item when current moves.
  • Give SidebarItem an onClick that calls preventDefault if your router handles navigation.
  • Inside App shell's drawer the sidebar is frameless and cannot fold, set through SidebarDefaults, so you pass the same Sidebar for both places.
  • The sliding bar and the floating names that escape the scroll area use CSS anchor positioning together with anchor-scope (Chrome 131 and later; check other engines for anchor-scope, not just anchor positioning). Elsewhere the current item draws its own static bar, and the rail stops scrolling so the names beside it are not clipped.

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/sidebar.tsx202 lines
"use client";

import {
  createContext,
  useContext,
  useId,
  useState,
  type ComponentProps,
  type MouseEvent,
  type ReactNode,
} from "react";
import "./sidebar.css";

type SidebarState = { collapsed: boolean; collapsible: boolean; toggle: () => void; navId: string };

const SidebarContext = createContext<SidebarState | null>(null);

type SidebarOptions = {
  /** Draws the sidebar's own border, background and padding. Turn off inside a drawer or a panel that frames it. */
  frame?: boolean;
  /** Allows folding to a rail. When false the sidebar stays open and SidebarToggle renders nothing. */
  collapsible?: boolean;
};

const SidebarDefaultsContext = createContext<SidebarOptions>({});

export type SidebarDefaultsProps = SidebarOptions & { children?: ReactNode };

/**
 * Sets frame and collapsible for every Sidebar inside it, for a container such as a drawer
 * that receives the sidebar from its caller. Props on the Sidebar itself still win.
 */
export function SidebarDefaults({ frame, collapsible, children }: SidebarDefaultsProps) {
  return <SidebarDefaultsContext.Provider value={{ frame, collapsible }}>{children}</SidebarDefaultsContext.Provider>;
}

export type SidebarProps = ComponentProps<"nav"> &
  SidebarOptions & {
    /** Names the navigation landmark, e.g. "Main". */
    label: string;
    collapsed?: boolean;
    defaultCollapsed?: boolean;
    onCollapsedChange?: (collapsed: boolean) => void;
    children: ReactNode;
  };

/**
 * Application navigation that folds down to an icon rail. Set the open width
 * with --eq-sidebar-width and the row height with --eq-sidebar-item-height.
 */
export function Sidebar({
  label,
  collapsed: collapsedProp,
  defaultCollapsed = false,
  onCollapsedChange,
  frame,
  collapsible,
  id,
  className,
  children,
  ...rest
}: SidebarProps) {
  const autoId = useId();
  const navId = id ?? autoId;
  const [own, setOwn] = useState(defaultCollapsed);
  const defaults = useContext(SidebarDefaultsContext);
  const framed = frame ?? defaults.frame ?? true;
  const canCollapse = collapsible ?? defaults.collapsible ?? true;
  // A sidebar that cannot fold is always open, whatever collapsed says.
  const collapsed = canCollapse && (collapsedProp ?? own);

  function toggle() {
    const next = !collapsed;
    if (collapsedProp === undefined) setOwn(next);
    onCollapsedChange?.(next);
  }

  return (
    <SidebarContext.Provider value={{ collapsed, collapsible: canCollapse, toggle, navId }}>
      <nav
        aria-label={label}
        {...rest}
        id={navId}
        className={["eq-sidebar", className].filter(Boolean).join(" ")}
        data-collapsed={collapsed || undefined}
        data-frameless={!framed || undefined}
      >
        {children}
        <span className="eq-sidebar-indicator" aria-hidden="true" />
      </nav>
    </SidebarContext.Provider>
  );
}

export type SidebarSectionProps = ComponentProps<"div"> & {
  /** A short heading that names the list. Hidden on the rail, still read out. */
  label?: string;
};

export function SidebarSection({ label, className, children, ...rest }: SidebarSectionProps) {
  const labelId = useId();
  return (
    <div {...rest} className={["eq-sidebar-section", className].filter(Boolean).join(" ")}>
      {label != null && (
        <span id={labelId} className="eq-sidebar-section-label">
          {label}
        </span>
      )}
      <ul className="eq-sidebar-list" aria-labelledby={label != null ? labelId : undefined}>
        {children}
      </ul>
    </div>
  );
}

export type SidebarItemProps = ComponentProps<"a"> & {
  href: string;
  icon: ReactNode;
  /** Marks the page the person is on with aria-current="page". */
  current?: boolean;
  /** A count or status after the label. On the rail it shrinks to a dot. */
  badge?: ReactNode;
};

export function SidebarItem({
  href,
  icon,
  current = false,
  badge,
  className,
  children,
  ...rest
}: SidebarItemProps) {
  return (
    <li className="eq-sidebar-item">
      <a
        {...rest}
        href={href}
        className={["eq-sidebar-link", className].filter(Boolean).join(" ")}
        aria-current={current ? "page" : undefined}
      >
        <span className="eq-sidebar-icon" aria-hidden="true">
          {icon}
        </span>
        <span className="eq-sidebar-label">{children}</span>
        {badge != null && <span className="eq-sidebar-badge">{badge}</span>}
      </a>
    </li>
  );
}

export type SidebarToggleProps = ComponentProps<"button">;

/** The collapse button. aria-expanded says whether the labels are showing. */
export function SidebarToggle({
  className,
  onClick,
  "aria-label": ariaLabel = "Sidebar",
  ...rest
}: SidebarToggleProps) {
  const context = useContext(SidebarContext);
  if (!context) throw new Error("SidebarToggle must be inside Sidebar.");
  const { collapsed, collapsible, toggle, navId } = context;
  if (!collapsible) return null;

  function handleClick(event: MouseEvent<HTMLButtonElement>) {
    onClick?.(event);
    if (!event.defaultPrevented) toggle();
  }

  return (
    <button
      type="button"
      aria-label={ariaLabel}
      {...rest}
      className={["eq-sidebar-toggle", className].filter(Boolean).join(" ")}
      aria-expanded={!collapsed}
      aria-controls={navId}
      onClick={handleClick}
    >
      <svg
        viewBox="0 0 16 16"
        width="16"
        height="16"
        fill="none"
        stroke="currentColor"
        strokeWidth="1.5"
        strokeLinecap="round"
        strokeLinejoin="round"
        aria-hidden="true"
      >
        <rect x="2" y="2.5" width="12" height="11" rx="2" />
        <path d="M6 2.5v11" />
        <path className="eq-sidebar-toggle-chevron" d="M10.5 6.5 9 8l1.5 1.5" />
      </svg>
    </button>
  );
}

export function SidebarFooter({ className, ...rest }: ComponentProps<"div">) {
  return <div {...rest} className={["eq-sidebar-footer", className].filter(Boolean).join(" ")} />;
}
components/equip/sidebar.css372 lines
@layer equip {
  .eq-sidebar {
    --_item: var(--eq-sidebar-item-height, var(--eq-h-s));
    --_inset: var(--eq-space-2);
    --_icon-pad: calc((var(--_item) - 16px) / 2);
    --_shift: calc(-1 * var(--eq-space-1));

    position: relative;
    box-sizing: border-box;
    display: flex;
    flex-direction: column;
    gap: var(--eq-space-3);
    width: var(--eq-sidebar-width, 240px);
    height: var(--eq-sidebar-height, 100%);
    padding: var(--_inset);
    overflow: hidden auto;
    overscroll-behavior: contain;
    scrollbar-width: thin;
    border-inline-end: 1px solid var(--eq-line);
    background: var(--eq-surface);
    color: var(--eq-ink-2);
    font: var(--eq-weight) var(--eq-text-m) / 1.4 var(--eq-font);
    /* Layout width: the plain ease, so the page beside it never bounces. */
    transition: width var(--eq-dur-move) var(--eq-ease);
  }

  .eq-sidebar:dir(rtl) {
    --_shift: var(--eq-space-1);
  }

  .eq-sidebar[data-collapsed] {
    width: calc(var(--_item) + 2 * var(--_inset) + 1px);
  }

  /* Frameless: no border, background or padding of its own, and as wide as the drawer or panel
     that frames it. --_inset stays, so the current-item bar keeps its place against the start edge. */
  /* The 4px of padding, cancelled by the margin, is room for the focus ring inside the scroller. */
  .eq-sidebar[data-frameless] {
    margin: -4px;
    padding: 4px;
    border-inline-end: 0;
    background: transparent;
  }

  .eq-sidebar[data-frameless]:not([data-collapsed]) {
    width: auto;
  }

  .eq-sidebar[data-frameless][data-collapsed] {
    width: calc(var(--_item) + 8px);
  }

  .eq-sidebar-toggle {
    flex: none;
    display: grid;
    place-items: center;
    width: var(--_item);
    height: var(--_item);
    padding: 0;
    border: 0;
    border-radius: var(--eq-radius-s);
    background: transparent;
    color: var(--eq-ink-2);
    cursor: pointer;
    transition:
      background-color var(--eq-dur) var(--eq-ease),
      color var(--eq-dur) var(--eq-ease),
      scale var(--eq-dur) var(--eq-ease);
  }

  .eq-sidebar-toggle-chevron {
    transform-box: fill-box;
    transform-origin: center;
    transition: rotate var(--eq-dur-move) var(--eq-spring);
  }

  .eq-sidebar[data-collapsed] .eq-sidebar-toggle-chevron {
    rotate: 180deg;
  }

  .eq-sidebar-section {
    display: flex;
    flex-direction: column;
    gap: 2px;
  }

  .eq-sidebar-section-label {
    height: 20px;
    padding-inline: var(--_icon-pad);
    overflow: hidden;
    color: var(--eq-ink-3);
    font-size: var(--eq-text-xs);
    font-weight: var(--eq-weight-strong);
    line-height: 20px;
    white-space: nowrap;
    text-overflow: ellipsis;
  }

  .eq-sidebar[data-collapsed] .eq-sidebar-section-label {
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip-path: inset(50%);
  }

  /* On the rail a hairline stands in for the section heading. */
  .eq-sidebar[data-collapsed] .eq-sidebar-section + .eq-sidebar-section {
    padding-top: var(--eq-space-2);
    border-top: 1px solid var(--eq-line);
  }

  .eq-sidebar-list {
    display: flex;
    flex-direction: column;
    gap: 2px;
    margin: 0;
    padding: 0;
    list-style: none;
  }

  .eq-sidebar-item {
    display: block;
  }

  .eq-sidebar-link {
    position: relative;
    display: flex;
    align-items: center;
    gap: var(--eq-space-2);
    height: var(--_item);
    padding-inline: var(--_icon-pad);
    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-sidebar-link[aria-current="page"] {
    background: var(--eq-tint-strong);
    color: var(--eq-ink);
    font-weight: var(--eq-weight-strong);
  }

  /* The start-edge bar on the current item. Replaced by the sliding indicator below where anchors work. */
  .eq-sidebar-link[aria-current="page"]::before {
    content: "";
    position: absolute;
    inset-inline-start: calc(-1 * var(--_inset));
    inset-block: calc(var(--_item) * 0.22);
    width: 3px;
    border-start-end-radius: var(--eq-radius-s);
    border-end-end-radius: var(--eq-radius-s);
    background: var(--eq-ink);
  }

  .eq-sidebar-indicator {
    display: none;
  }

  .eq-sidebar-icon {
    flex: none;
    display: grid;
    place-items: center;
    width: 16px;
    height: 16px;
    transition: scale var(--eq-dur) var(--eq-ease);
  }

  .eq-sidebar-icon > svg {
    width: 16px;
    height: 16px;
  }

  .eq-sidebar-label {
    flex: 1;
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    transition: opacity var(--eq-dur) var(--eq-ease);
  }

  .eq-sidebar-badge {
    flex: none;
    box-sizing: border-box;
    display: inline-grid;
    place-items: center;
    min-width: 20px;
    height: 20px;
    padding-inline: var(--eq-space-1);
    border-radius: var(--eq-radius-s);
    background: var(--eq-tint-strong);
    color: var(--eq-ink);
    font-size: var(--eq-text-xs);
    font-weight: var(--eq-weight-strong);
    font-variant-numeric: tabular-nums;
    line-height: 1;
  }

  .eq-sidebar-footer {
    display: flex;
    flex-direction: column;
    gap: 2px;
    margin-top: auto;
    padding-top: var(--eq-space-2);
    border-top: 1px solid var(--eq-line);
  }

  @media (hover: hover) {
    .eq-sidebar-toggle:hover,
    .eq-sidebar-link:hover {
      background: var(--eq-tint);
      color: var(--eq-ink);
    }

    .eq-sidebar-link[aria-current="page"]:hover {
      background: var(--eq-tint-strong);
    }
  }

  .eq-sidebar-toggle:active,
  .eq-sidebar-link:active {
    scale: var(--eq-press);
  }

  .eq-sidebar-toggle:focus-visible,
  .eq-sidebar-link:focus-visible {
    outline: 2px solid var(--eq-focus);
    outline-offset: 2px;
  }

  /* Tabbing into a scrolled sidebar brings the whole ring into view, not just the link. */
  .eq-sidebar-toggle,
  .eq-sidebar-link {
    scroll-margin-block: 4px;
  }

  /* ---- The rail ---- */

  /* The label anchors to the link on the rail, so the link itself must not scale. */
  .eq-sidebar[data-collapsed] .eq-sidebar-link:active {
    scale: none;
  }

  .eq-sidebar[data-collapsed] .eq-sidebar-link:active .eq-sidebar-icon {
    scale: var(--eq-press);
  }

  /* The label leaves the row and becomes a small floating name beside the item.
     It stays in the accessibility tree, so the link keeps its name. */
  .eq-sidebar[data-collapsed] .eq-sidebar-label {
    position: absolute;
    inset-inline-start: calc(100% + var(--_inset) + var(--eq-space-1));
    top: 50%;
    z-index: 1;
    flex: none;
    min-width: max-content;
    padding: var(--eq-space-1) var(--eq-space-2);
    overflow: visible;
    border: 1px solid var(--eq-line-strong);
    border-radius: var(--eq-radius-m);
    background: var(--eq-raised);
    box-shadow: var(--eq-shadow);
    -webkit-backdrop-filter: var(--eq-blur);
    backdrop-filter: var(--eq-blur);
    color: var(--eq-ink);
    font-size: var(--eq-text-s);
    font-weight: var(--eq-weight-strong);
    opacity: 0;
    scale: 0.96;
    translate: var(--_shift) -50%;
    pointer-events: none;
    /* No opacity fade at rest: on collapse it would flash every name beside the still-wide nav. */
    transition:
      scale var(--eq-dur) var(--eq-ease),
      translate var(--eq-dur) var(--eq-ease);
  }

  .eq-sidebar[data-collapsed] .eq-sidebar-link:focus-visible .eq-sidebar-label {
    opacity: 1;
    scale: 1;
    translate: 0 -50%;
    transition:
      opacity var(--eq-dur) var(--eq-ease),
      scale var(--eq-dur-move) var(--eq-spring),
      translate var(--eq-dur-move) var(--eq-spring);
  }

  @media (hover: hover) {
    .eq-sidebar[data-collapsed] .eq-sidebar-link:hover .eq-sidebar-label {
      opacity: 1;
      scale: 1;
      translate: 0 -50%;
      transition:
        opacity var(--eq-dur) var(--eq-ease),
        scale var(--eq-dur-move) var(--eq-spring),
        translate var(--eq-dur-move) var(--eq-spring);
    }
  }

  /* The count shrinks to a dot on the icon's corner; its text stays for screen readers. */
  .eq-sidebar[data-collapsed] .eq-sidebar-badge {
    position: absolute;
    top: calc(var(--_icon-pad) - 3px);
    inset-inline-start: calc(var(--_icon-pad) + 11px);
    min-width: 0;
    width: 8px;
    height: 8px;
    padding: 0;
    overflow: hidden;
    border-radius: var(--eq-radius-full);
    background: var(--eq-ink);
    box-shadow: 0 0 0 2px var(--eq-surface);
    color: transparent;
  }

  /* ---- Anchor positioning: a sliding bar, and labels that escape the scroller ---- */
  @supports (anchor-name: --a) and (anchor-scope: all) {
    .eq-sidebar {
      anchor-scope: --eq-sidebar-current;
    }

    .eq-sidebar-link {
      anchor-name: --eq-sidebar-link;
      anchor-scope: --eq-sidebar-link;
    }

    .eq-sidebar-link[aria-current="page"] {
      anchor-name: --eq-sidebar-link, --eq-sidebar-current;
    }

    .eq-sidebar-link[aria-current="page"]::before {
      content: none;
    }

    .eq-sidebar-indicator {
      display: block;
      position: absolute;
      position-anchor: --eq-sidebar-current;
      position-visibility: anchors-valid;
      inset-inline-start: 0;
      top: calc(anchor(top) + var(--_item) * 0.22);
      bottom: calc(anchor(bottom) + var(--_item) * 0.22);
      width: 3px;
      border-start-end-radius: var(--eq-radius-s);
      border-end-end-radius: var(--eq-radius-s);
      background: var(--eq-ink);
      pointer-events: none;
      transition:
        top var(--eq-dur-move) var(--eq-spring),
        bottom var(--eq-dur-move) var(--eq-spring);
    }

    .eq-sidebar[data-collapsed] .eq-sidebar-label {
      position: fixed;
      position-anchor: --eq-sidebar-link;
      inset-inline-start: calc(anchor(outside) + var(--_inset) + var(--eq-space-1));
      top: anchor(center);
    }
  }

  /* Without anchors the label is placed inside the nav, so the rail stops clipping. */
  @supports not ((anchor-name: --a) and (anchor-scope: all)) {
    .eq-sidebar[data-collapsed] {
      overflow: visible;
    }
  }
}

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