Sidebar
PortedThe app's navigation: a nav beside the page. It collapses to an icon rail, and on phones it is a drawer (a popover) that opens on the current page's link and closes after a link is chosen. Native attributes and ref pass through.
import { ChartBarIcon, ChatsCircleIcon, GearIcon, GraphIcon, MagnifyingGlassIcon, TrayIcon } from "@phosphor-icons/react";
import { Avatar } from "@etched/monet/avatar";
import { Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupLabel, SidebarHeader, SidebarMenu, SidebarMenuButton, SidebarProvider } from "@etched/monet/sidebar";
// A frame in place of the browser window. The provider takes its height, so the page scrolls inside it.
const frame = { blockSize: "32rem", overflow: "hidden", borderRadius: "var(--et-card-radius)", boxShadow: "0 0 0 1px var(--et-line)" };
// The page beside the sidebar. In an app, this is the page's `main`.
const page = { overflow: "auto", padding: "var(--et-space-6)" };
// Etched's mark, 28 px, as Orbit's header shows it.
function Logo() {
return (
<svg viewBox="0 0 116 116" fill="currentColor" aria-hidden="true" style={{ flex: "none", inlineSize: "var(--et-control-sm)", blockSize: "var(--et-control-sm)" }}>
<path
fillRule="evenodd"
d="M0.456543 4.50079C0.456543 2.02536 2.4819 0 4.95733 0H111.476C113.952 0 115.977 2.02536 115.977 4.50079V111.02C115.977 113.495 113.952 115.52 111.476 115.52H4.95733C2.4819 115.52 0.456543 113.495 0.456543 111.02V4.50079ZM95.7545 96.8425H20.8667C19.9665 96.8425 19.2914 96.1671 19.2914 95.3419V85.9431C19.2914 85.118 20.0415 84.4429 20.8667 84.4429H83.3549C85.728 84.4429 88.4655 85.5681 90.1794 87.5184L96.7967 94.6953C97.6219 95.5204 96.9548 96.8425 95.7545 96.8425Z"
/>
</svg>
);
}
// Orbit's navigation, with Tracer as the current page.
function OrbitNav() {
return (
<>
<SidebarHeader>
<Logo />
<span className="et-sidebar-label">Orbit</span>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>Recruiting</SidebarGroupLabel>
<SidebarMenu>
<SidebarMenuButton href="#sidebar" icon={<GraphIcon />}>
Topo
</SidebarMenuButton>
<SidebarMenuButton href="#sidebar" icon={<MagnifyingGlassIcon weight="fill" />} current>
Tracer
</SidebarMenuButton>
<SidebarMenuButton href="#sidebar" icon={<TrayIcon />}>
SOBO
</SidebarMenuButton>
<SidebarMenuButton href="#sidebar" icon={<ChatsCircleIcon />}>
References
</SidebarMenuButton>
</SidebarMenu>
</SidebarGroup>
<SidebarGroup>
<SidebarGroupLabel>Admin</SidebarGroupLabel>
<SidebarMenu>
<SidebarMenuButton href="#sidebar" icon={<GearIcon />}>
Settings
</SidebarMenuButton>
<SidebarMenuButton href="#sidebar" icon={<ChartBarIcon />}>
Data quality
</SidebarMenuButton>
</SidebarMenu>
</SidebarGroup>
</SidebarContent>
<SidebarFooter>
<Avatar name="Sam Lee" size="sm" alt="" />
<span className="et-sidebar-label">Sam Lee</span>
</SidebarFooter>
</>
);
}
// Orbit-style stroke icons, as its Python `icon()` writes them.
const icon = (paths: string) =>
`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths}</svg>`;
export function Preview() {
return (
<SidebarProvider style={{ ...frame, blockSize: "24rem" }}>
<Sidebar aria-label="Orbit">
<OrbitNav />
</Sidebar>
<div style={page} />
</SidebarProvider>
);
}Installation
import { Sidebar, SidebarCollapsible, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupLabel, SidebarHeader, SidebarMenu, SidebarMenuButton, SidebarProvider, SidebarTrigger, useSidebar } from "@etched/monet/sidebar";Examples
Groups and the current page
Group labels in sentence case, then links with an icon each. The current page has current: a filled row, strong text, and Phosphor's fill weight on its icon.
import { ChartBarIcon, ChatsCircleIcon, GearIcon, GraphIcon, MagnifyingGlassIcon, TrayIcon } from "@phosphor-icons/react";
import { Avatar } from "@etched/monet/avatar";
import { Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupLabel, SidebarHeader, SidebarMenu, SidebarMenuButton, SidebarProvider } from "@etched/monet/sidebar";
// A frame in place of the browser window. The provider takes its height, so the page scrolls inside it.
const frame = { blockSize: "32rem", overflow: "hidden", borderRadius: "var(--et-card-radius)", boxShadow: "0 0 0 1px var(--et-line)" };
// The page beside the sidebar. In an app, this is the page's `main`.
const page = { overflow: "auto", padding: "var(--et-space-6)" };
// Etched's mark, 28 px, as Orbit's header shows it.
function Logo() {
return (
<svg viewBox="0 0 116 116" fill="currentColor" aria-hidden="true" style={{ flex: "none", inlineSize: "var(--et-control-sm)", blockSize: "var(--et-control-sm)" }}>
<path
fillRule="evenodd"
d="M0.456543 4.50079C0.456543 2.02536 2.4819 0 4.95733 0H111.476C113.952 0 115.977 2.02536 115.977 4.50079V111.02C115.977 113.495 113.952 115.52 111.476 115.52H4.95733C2.4819 115.52 0.456543 113.495 0.456543 111.02V4.50079ZM95.7545 96.8425H20.8667C19.9665 96.8425 19.2914 96.1671 19.2914 95.3419V85.9431C19.2914 85.118 20.0415 84.4429 20.8667 84.4429H83.3549C85.728 84.4429 88.4655 85.5681 90.1794 87.5184L96.7967 94.6953C97.6219 95.5204 96.9548 96.8425 95.7545 96.8425Z"
/>
</svg>
);
}
// Orbit's navigation, with Tracer as the current page.
function OrbitNav() {
return (
<>
<SidebarHeader>
<Logo />
<span className="et-sidebar-label">Orbit</span>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>Recruiting</SidebarGroupLabel>
<SidebarMenu>
<SidebarMenuButton href="#sidebar" icon={<GraphIcon />}>
Topo
</SidebarMenuButton>
<SidebarMenuButton href="#sidebar" icon={<MagnifyingGlassIcon weight="fill" />} current>
Tracer
</SidebarMenuButton>
<SidebarMenuButton href="#sidebar" icon={<TrayIcon />}>
SOBO
</SidebarMenuButton>
<SidebarMenuButton href="#sidebar" icon={<ChatsCircleIcon />}>
References
</SidebarMenuButton>
</SidebarMenu>
</SidebarGroup>
<SidebarGroup>
<SidebarGroupLabel>Admin</SidebarGroupLabel>
<SidebarMenu>
<SidebarMenuButton href="#sidebar" icon={<GearIcon />}>
Settings
</SidebarMenuButton>
<SidebarMenuButton href="#sidebar" icon={<ChartBarIcon />}>
Data quality
</SidebarMenuButton>
</SidebarMenu>
</SidebarGroup>
</SidebarContent>
<SidebarFooter>
<Avatar name="Sam Lee" size="sm" alt="" />
<span className="et-sidebar-label">Sam Lee</span>
</SidebarFooter>
</>
);
}
// Orbit-style stroke icons, as its Python `icon()` writes them.
const icon = (paths: string) =>
`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths}</svg>`;
export function GroupsAndTheCurrentPage() {
return (
<SidebarProvider style={frame}>
<Sidebar aria-label="Orbit">
<OrbitNav />
</Sidebar>
<div style={page}>The page</div>
</SidebarProvider>
);
}The icon rail
Collapsed, the sidebar shows only icons; hover or focus one for its name. The trigger widens it again; keep the choice with open and onOpenChange.
import { ChartBarIcon, ChatsCircleIcon, GearIcon, GraphIcon, MagnifyingGlassIcon, TrayIcon } from "@phosphor-icons/react";
import { Avatar } from "@etched/monet/avatar";
import { Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupLabel, SidebarHeader, SidebarMenu, SidebarMenuButton, SidebarProvider, SidebarTrigger } from "@etched/monet/sidebar";
// A frame in place of the browser window. The provider takes its height, so the page scrolls inside it.
const frame = { blockSize: "32rem", overflow: "hidden", borderRadius: "var(--et-card-radius)", boxShadow: "0 0 0 1px var(--et-line)" };
// The page beside the sidebar. In an app, this is the page's `main`.
const page = { overflow: "auto", padding: "var(--et-space-6)" };
// Etched's mark, 28 px, as Orbit's header shows it.
function Logo() {
return (
<svg viewBox="0 0 116 116" fill="currentColor" aria-hidden="true" style={{ flex: "none", inlineSize: "var(--et-control-sm)", blockSize: "var(--et-control-sm)" }}>
<path
fillRule="evenodd"
d="M0.456543 4.50079C0.456543 2.02536 2.4819 0 4.95733 0H111.476C113.952 0 115.977 2.02536 115.977 4.50079V111.02C115.977 113.495 113.952 115.52 111.476 115.52H4.95733C2.4819 115.52 0.456543 113.495 0.456543 111.02V4.50079ZM95.7545 96.8425H20.8667C19.9665 96.8425 19.2914 96.1671 19.2914 95.3419V85.9431C19.2914 85.118 20.0415 84.4429 20.8667 84.4429H83.3549C85.728 84.4429 88.4655 85.5681 90.1794 87.5184L96.7967 94.6953C97.6219 95.5204 96.9548 96.8425 95.7545 96.8425Z"
/>
</svg>
);
}
// Orbit's navigation, with Tracer as the current page.
function OrbitNav() {
return (
<>
<SidebarHeader>
<Logo />
<span className="et-sidebar-label">Orbit</span>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>Recruiting</SidebarGroupLabel>
<SidebarMenu>
<SidebarMenuButton href="#sidebar" icon={<GraphIcon />}>
Topo
</SidebarMenuButton>
<SidebarMenuButton href="#sidebar" icon={<MagnifyingGlassIcon weight="fill" />} current>
Tracer
</SidebarMenuButton>
<SidebarMenuButton href="#sidebar" icon={<TrayIcon />}>
SOBO
</SidebarMenuButton>
<SidebarMenuButton href="#sidebar" icon={<ChatsCircleIcon />}>
References
</SidebarMenuButton>
</SidebarMenu>
</SidebarGroup>
<SidebarGroup>
<SidebarGroupLabel>Admin</SidebarGroupLabel>
<SidebarMenu>
<SidebarMenuButton href="#sidebar" icon={<GearIcon />}>
Settings
</SidebarMenuButton>
<SidebarMenuButton href="#sidebar" icon={<ChartBarIcon />}>
Data quality
</SidebarMenuButton>
</SidebarMenu>
</SidebarGroup>
</SidebarContent>
<SidebarFooter>
<Avatar name="Sam Lee" size="sm" alt="" />
<span className="et-sidebar-label">Sam Lee</span>
</SidebarFooter>
</>
);
}
// Orbit-style stroke icons, as its Python `icon()` writes them.
const icon = (paths: string) =>
`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths}</svg>`;
export function TheIconRail() {
return (
<SidebarProvider style={frame} defaultOpen={false}>
<Sidebar aria-label="Orbit, collapsed">
<OrbitNav />
</Sidebar>
<div style={page}>
<SidebarTrigger />
</div>
</SidebarProvider>
);
}Sections that open
A SidebarCollapsible is a native details: its links sit indented under a line. Open it when it holds the current page.
import { GearIcon, ShieldCheckIcon, UsersIcon } from "@phosphor-icons/react";
import { Sidebar, SidebarCollapsible, SidebarContent, SidebarGroup, SidebarGroupLabel, SidebarMenu, SidebarMenuButton, SidebarProvider, SidebarTrigger } from "@etched/monet/sidebar";
// A frame in place of the browser window. The provider takes its height, so the page scrolls inside it.
const frame = { blockSize: "32rem", overflow: "hidden", borderRadius: "var(--et-card-radius)", boxShadow: "0 0 0 1px var(--et-line)" };
// The page beside the sidebar. In an app, this is the page's `main`.
const page = { overflow: "auto", padding: "var(--et-space-6)" };
// Orbit-style stroke icons, as its Python `icon()` writes them.
const icon = (paths: string) =>
`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths}</svg>`;
export function SectionsThatOpen() {
return (
<SidebarProvider style={frame}>
<Sidebar aria-label="Settings">
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>Admin</SidebarGroupLabel>
<SidebarMenu>
<SidebarCollapsible label="Settings" icon={<GearIcon />} defaultOpen>
<SidebarMenuButton href="#sidebar">Users</SidebarMenuButton>
<SidebarMenuButton href="#sidebar" current>
Notifications
</SidebarMenuButton>
<SidebarMenuButton href="#sidebar">Access</SidebarMenuButton>
</SidebarCollapsible>
<SidebarCollapsible label="Referrals" icon={<UsersIcon />}>
<SidebarMenuButton href="#sidebar">Directory</SidebarMenuButton>
<SidebarMenuButton href="#sidebar">Leaderboard</SidebarMenuButton>
</SidebarCollapsible>
<SidebarMenuButton href="#sidebar" icon={<ShieldCheckIcon />}>
Data quality
</SidebarMenuButton>
</SidebarMenu>
</SidebarGroup>
</SidebarContent>
</Sidebar>
<div style={page}>
<SidebarTrigger />
</div>
</SidebarProvider>
);
}Plain HTML
For pages without React, such as Orbit's Python templates. The look comes from classes and data-state only: here one sidebar is expanded and one collapsed. Below 48 rem, the popovertarget button opens the drawer without a script.
// Orbit-style stroke icons, as its Python `icon()` writes them.
const icon = (paths: string) =>
`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths}</svg>`;
// The same sidebar from markup alone: no React, only the classes and attributes in sidebar.tsx's header.
const plainSidebar = (id: string, state: "expanded" | "collapsed") => `
<div class="et-sidebar-layout" style="block-size: 26rem; overflow: hidden; border-radius: var(--et-card-radius); box-shadow: 0 0 0 1px var(--et-line)">
<nav class="et-sidebar" id="${id}" aria-label="Orbit, plain HTML, ${state}" popover data-state="${state}">
<div class="et-sidebar-content">
<div class="et-sidebar-group">
<div class="et-sidebar-group-label">Recruiting</div>
<ul class="et-sidebar-menu">
<li class="et-sidebar-menu-item">
<a class="et-sidebar-menu-button" href="#sidebar" aria-current="page" title="Tracer">${icon('<circle cx="11" cy="11" r="7"/><path d="m20 20-4-4"/>')}<span class="et-sidebar-label">Tracer</span></a>
</li>
<li class="et-sidebar-menu-item">
<a class="et-sidebar-menu-button" href="#sidebar" title="SOBO">${icon('<path d="M3 13h5l2 3h4l2-3h5"/><path d="M5 5h14l2 8v6H3v-6z"/>')}<span class="et-sidebar-label">SOBO</span></a>
</li>
</ul>
</div>
<div class="et-sidebar-group">
<div class="et-sidebar-group-label">Admin</div>
<ul class="et-sidebar-menu">
<li class="et-sidebar-menu-item">
<details class="et-sidebar-collapsible" open>
<summary class="et-sidebar-menu-button" title="Settings">${icon('<circle cx="12" cy="12" r="3"/><path d="M12 3v2m0 14v2M3 12h2m14 0h2"/>')}<span class="et-sidebar-label">Settings</span>${icon('<path d="m9 6 6 6-6 6"/>').replace("<svg", '<svg class="et-sidebar-chevron"')}</summary>
<ul class="et-sidebar-menu">
<li class="et-sidebar-menu-item"><a class="et-sidebar-menu-button" href="#sidebar"><span class="et-sidebar-label">Users</span></a></li>
<li class="et-sidebar-menu-item"><a class="et-sidebar-menu-button" href="#sidebar"><span class="et-sidebar-label">Access</span></a></li>
</ul>
</details>
</li>
</ul>
</div>
</div>
</nav>
<div style="padding: var(--et-space-6)">
<button class="et-sidebar-trigger" popovertarget="${id}" aria-label="Navigation">${icon('<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M9 4v16"/>')}</button>
${state === "collapsed" ? "Collapsed" : "Expanded"}
</div>
</div>`;
export function PlainHtml() {
return (
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(16rem, 1fr))", gap: "var(--et-space-5)" }}>
<div dangerouslySetInnerHTML={{ __html: plainSidebar("plain-sidebar", "expanded") }} />
<div dangerouslySetInnerHTML={{ __html: plainSidebar("plain-rail", "collapsed") }} />
</div>
);
}App shell
The sidebar, then the page: a PageHeader with breadcrumbs, actions and tabs, and the content. The trigger beside the breadcrumbs collapses the sidebar; below 48 rem it opens the sidebar as a drawer.
import { ChartBarIcon, ChatsCircleIcon, GearIcon, GraphIcon, MagnifyingGlassIcon, PlusIcon, TrayIcon } from "@phosphor-icons/react";
import { Avatar } from "@etched/monet/avatar";
import { Breadcrumbs, BreadcrumbsCurrent, BreadcrumbsLink } from "@etched/monet/breadcrumbs";
import { Button } from "@etched/monet/button";
import { PageHeader } from "@etched/monet/page-header";
import { Pill } from "@etched/monet/pill";
import { Table } from "@etched/monet/table";
import { TabsNav } from "@etched/monet/tabs";
import { Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupLabel, SidebarHeader, SidebarMenu, SidebarMenuButton, SidebarProvider, SidebarTrigger } from "@etched/monet/sidebar";
// A frame in place of the browser window. The provider takes its height, so the page scrolls inside it.
const frame = { blockSize: "32rem", overflow: "hidden", borderRadius: "var(--et-card-radius)", boxShadow: "0 0 0 1px var(--et-line)" };
// The page beside the sidebar. In an app, this is the page's `main`.
const page = { overflow: "auto", padding: "var(--et-space-6)" };
// Etched's mark, 28 px, as Orbit's header shows it.
function Logo() {
return (
<svg viewBox="0 0 116 116" fill="currentColor" aria-hidden="true" style={{ flex: "none", inlineSize: "var(--et-control-sm)", blockSize: "var(--et-control-sm)" }}>
<path
fillRule="evenodd"
d="M0.456543 4.50079C0.456543 2.02536 2.4819 0 4.95733 0H111.476C113.952 0 115.977 2.02536 115.977 4.50079V111.02C115.977 113.495 113.952 115.52 111.476 115.52H4.95733C2.4819 115.52 0.456543 113.495 0.456543 111.02V4.50079ZM95.7545 96.8425H20.8667C19.9665 96.8425 19.2914 96.1671 19.2914 95.3419V85.9431C19.2914 85.118 20.0415 84.4429 20.8667 84.4429H83.3549C85.728 84.4429 88.4655 85.5681 90.1794 87.5184L96.7967 94.6953C97.6219 95.5204 96.9548 96.8425 95.7545 96.8425Z"
/>
</svg>
);
}
// Orbit's navigation, with Tracer as the current page.
function OrbitNav() {
return (
<>
<SidebarHeader>
<Logo />
<span className="et-sidebar-label">Orbit</span>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>Recruiting</SidebarGroupLabel>
<SidebarMenu>
<SidebarMenuButton href="#sidebar" icon={<GraphIcon />}>
Topo
</SidebarMenuButton>
<SidebarMenuButton href="#sidebar" icon={<MagnifyingGlassIcon weight="fill" />} current>
Tracer
</SidebarMenuButton>
<SidebarMenuButton href="#sidebar" icon={<TrayIcon />}>
SOBO
</SidebarMenuButton>
<SidebarMenuButton href="#sidebar" icon={<ChatsCircleIcon />}>
References
</SidebarMenuButton>
</SidebarMenu>
</SidebarGroup>
<SidebarGroup>
<SidebarGroupLabel>Admin</SidebarGroupLabel>
<SidebarMenu>
<SidebarMenuButton href="#sidebar" icon={<GearIcon />}>
Settings
</SidebarMenuButton>
<SidebarMenuButton href="#sidebar" icon={<ChartBarIcon />}>
Data quality
</SidebarMenuButton>
</SidebarMenu>
</SidebarGroup>
</SidebarContent>
<SidebarFooter>
<Avatar name="Sam Lee" size="sm" alt="" />
<span className="et-sidebar-label">Sam Lee</span>
</SidebarFooter>
</>
);
}
// Orbit-style stroke icons, as its Python `icon()` writes them.
const icon = (paths: string) =>
`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths}</svg>`;
const candidates = [
{ name: "Ana Ruiz", stage: "Interviewing", tone: "cyan" },
{ name: "José Núñez", stage: "Scheduled", tone: "purple" },
{ name: "Lee Park", stage: "Hired", tone: "green" },
] as const;
export function AppShell() {
return (
<SidebarProvider style={{ ...frame, blockSize: "34rem" }}>
<Sidebar aria-label="Orbit, app shell">
<OrbitNav />
</Sidebar>
<div style={{ ...page, display: "grid", alignContent: "start", gap: "var(--et-space-6)" }}>
<PageHeader
breadcrumbs={
<div className="row">
<SidebarTrigger />
<Breadcrumbs>
<BreadcrumbsLink href="#sidebar">Tracer</BreadcrumbsLink>
<BreadcrumbsCurrent>Compiler Engineer</BreadcrumbsCurrent>
</Breadcrumbs>
</div>
}
title="Compiler Engineer"
description="Toronto · Hiring manager: Sam Lee · 2 openings"
actions={
<Button variant="primary" icon={<PlusIcon />}>
Add candidate
</Button>
}
tabs={
<TabsNav
aria-label="Role views"
variant="underline"
links={[
{ href: "#sidebar-pipeline", label: "Pipeline", current: true },
{ href: "#sidebar-summary", label: "Summary" },
]}
/>
}
/>
<div className="card">
<Table aria-label="Compiler Engineer pipeline">
<thead>
<tr>
<th>Name</th>
<th>Stage</th>
</tr>
</thead>
<tbody>
{candidates.map(({ name, stage, tone }) => (
<tr key={name}>
<th scope="row">{name}</th>
<td>
<Pill tone={tone}>{stage}</Pill>
</td>
</tr>
))}
</tbody>
</Table>
</div>
</div>
</SidebarProvider>
);
}Accessibility
EnterorSpaceonSidebarTriggercollapses or widens the sidebar.Enteron a section opens or closes it.- On a phone, the drawer opens with focus on the current page's link.
Escapecloses it, and focus goes back to the trigger. - A
navlandmark named by the requiredaria-label. Items are links in lists; a section is a nativedetailsandsummary. - The trigger is named "Navigation" and has
aria-expanded. The current page's link hasaria-current="page". - On the icon rail, the hidden text still names each link, and keyboard focus shows it in a tooltip.
- Set
currenton the current page's link. In plain HTML, writearia-current="page", and let your script setdata-stateand the toggle'saria-expanded.