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
| Prop | Type | Default | What it does |
|---|---|---|---|
label | string | none | Names the navigation landmark, e.g. "Main". |
collapsed | boolean | none | Controlled rail state. |
defaultCollapsed | boolean | false | Starts as a rail when uncontrolled. |
onCollapsedChange | (collapsed: boolean) => void | none | Called when SidebarToggle is pressed. |
frame | boolean | true | Draws 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. |
collapsible | boolean | true | Allows folding to a rail. When false the sidebar stays open whatever collapsed says, and SidebarToggle renders nothing. |
children | ReactNode | none | SidebarToggle, SidebarSection and SidebarFooter, in that order. |
...rest | ComponentProps<"nav"> | none | Every native nav attribute. |
SidebarSection | label?: string | none | A list of items, named by its optional heading. |
SidebarItem | href, icon, current?, badge? | none | A link with an icon and a label. current sets aria-current="page"; badge shows a count or status after the label. |
SidebarToggle | ComponentProps<"button"> | none | The collapse button. Named "Sidebar" unless you pass aria-label. |
SidebarFooter | ComponentProps<"div"> | none | Pinned to the bottom under a hairline. Put a SidebarSection inside for links. |
SidebarDefaults | frame?, collapsible?, children | none | Sets 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-width | CSS length | 240px | The open width. Set it on the sidebar or any ancestor. A frameless sidebar ignores it and fills its container. |
--eq-sidebar-height | CSS length | 100% | The height. Set auto where a flex or grid parent stretches it, or where the page around it should scroll instead. |
--eq-sidebar-item-height | CSS length | var(--eq-h-s) | Row height; the rail is this plus the padding, so icons never move when it folds. |
Anatomy
| root | .eq-sidebar | The nav. data-collapsed on the rail, data-frameless when frame is off. |
|---|---|---|
| toggle | .eq-sidebar-toggle | |
| toggle chevron | .eq-sidebar-toggle-chevron | Turns to point the way the sidebar will move. |
| section | .eq-sidebar-section | |
| section label | .eq-sidebar-section-label | Visually hidden on the rail, where a hairline takes its place. |
| list | .eq-sidebar-list | |
| item | .eq-sidebar-item | The li. |
| link | .eq-sidebar-link | |
| icon | .eq-sidebar-icon | |
| label | .eq-sidebar-label | On the rail it becomes the floating name beside the item. |
| badge | .eq-sidebar-badge | A dot on the icon's corner on the rail. |
| indicator | .eq-sidebar-indicator | The sliding start-edge bar, where anchor positioning exists. |
| footer | .eq-sidebar-footer |
Keyboard
| TabShift+Tab | Moves through the toggle and the links in reading order. |
|---|---|
| Enter | Follows a link. |
| EnterSpace | On 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;
}
}
}