Skip to content
ShineUI
Install

PanelMenu

Accordion navigation.

<sui-panel-menu>Menus and navigationStable

About

Accordion navigation for in-page sidebars such as settings pages. Each group is a header button over a list of rows; one group is open at a time unless multiple is set. The active row carries the accent bar and is data, so the host page owns routing. The whole is a nav landmark named by the host's aria-label.

Usage

<sui-panel-menu></sui-panel-menu> <script type="module"> import { defineCustomElements } from "@shineui/core/loader"; import "@shineui/core/shineui.css"; defineCustomElements(); const el = document.querySelector("sui-panel-menu"); el.groups = [ { label: "Company", icon: "fa-regular fa-building", items: [{ label: "Profile" }, { label: "Locations" }, { label: "Financial year" }] }, { label: "Billing", icon: "fa-regular fa-credit-card", items: [{ label: "Subscription", active: true }, { label: "Invoices from us", badge: 2 }] }, { label: "Users", icon: "fa-regular fa-user", items: [{ label: "People" }, { label: "Roles" }] }, ]; /* SuiPanelMenuGroup[] */ el.addEventListener("suiPick", (e) => console.log(e.detail)); </script>
import { SuiPanelMenu } from "@shineui/react"; import "@shineui/core/shineui.css"; export function Example() { const groups = [ { label: "Company", icon: "fa-regular fa-building", items: [{ label: "Profile" }, { label: "Locations" }, { label: "Financial year" }] }, { label: "Billing", icon: "fa-regular fa-credit-card", items: [{ label: "Subscription", active: true }, { label: "Invoices from us", badge: 2 }] }, { label: "Users", icon: "fa-regular fa-user", items: [{ label: "People" }, { label: "Roles" }] }, ]; /* SuiPanelMenuGroup[] */ return <SuiPanelMenu groups={groups} onSuiPick={(e) => console.log(e.detail)} />; }
<script setup> import { SuiPanelMenu } from "@shineui/vue"; import "@shineui/core/shineui.css"; const groups = [ { label: "Company", icon: "fa-regular fa-building", items: [{ label: "Profile" }, { label: "Locations" }, { label: "Financial year" }] }, { label: "Billing", icon: "fa-regular fa-credit-card", items: [{ label: "Subscription", active: true }, { label: "Invoices from us", badge: 2 }] }, { label: "Users", icon: "fa-regular fa-user", items: [{ label: "People" }, { label: "Roles" }] }, ]; // SuiPanelMenuGroup[] const onChange = (e) => console.log(e.detail); </script> <template> <SuiPanelMenu :groups="groups" @sui-pick="onChange" /> </template>
import { Component } from "@angular/core"; import { SuiPanelMenu } from "@shineui/angular"; @Component({ standalone: true, imports: [SuiPanelMenu], template: `<sui-panel-menu [(expanded)]="expanded" [groups]="groups" (suiPick)="onChange($event)"></sui-panel-menu>`, }) export class Example { expanded?: number[]; groups = [ { label: "Company", icon: "fa-regular fa-building", items: [{ label: "Profile" }, { label: "Locations" }, { label: "Financial year" }] }, { label: "Billing", icon: "fa-regular fa-credit-card", items: [{ label: "Subscription", active: true }, { label: "Invoices from us", badge: 2 }] }, { label: "Users", icon: "fa-regular fa-user", items: [{ label: "People" }, { label: "Roles" }] }, ]; // SuiPanelMenuGroup[] onChange(e: CustomEvent) { console.log(e.detail); } }

Basic

Group headers and rows are ordinary tab stops in document order: this is navigation, not a composite widget, so nothing leaves the tab order. The arrow keys are a shortcut from header to header, past a whole group's rows. A header carries aria-expanded and aria-controls, and the active row carries aria-current="page".

<sui-panel-menu></sui-panel-menu>
// setup(root): properties and events setup(root) { root.querySelector('sui-panel-menu').groups = settingsGroups(); }

Several groups at once

One group at a time is the default, as in sui-accordion. Set multiple to keep more than one panel expanded, and seed the open indexes with expanded.

<sui-panel-menu id="pm-multi" multiple></sui-panel-menu>
// setup(root): properties and events setup(root) { const el = root.querySelector('#pm-multi'); el.groups = settingsGroups(); el.expanded = [0, 2]; }

Row states

A row marked active carries the orange bar and the navy-soft fill. A disabled row renders a native disabled button, so a destination gated by plan or permission keeps its place in the list.

<sui-panel-menu id="pm-states"></sui-panel-menu>
// setup(root): properties and events setup(root) { const el = root.querySelector('#pm-states'); el.groups = [ { label: 'Bookkeeping', icon: 'fa-regular fa-rectangle-list', items: [ { label: 'Chart of accounts', active: true }, { label: 'Journals', badge: 4 }, { label: 'Multi-currency', disabled: true }, ], }, { label: 'Payroll', icon: 'fa-regular fa-money-bill-1', items: [{ label: 'Pay runs' }, { label: 'Leave', disabled: true }], }, ]; }

A row with href is a real link in the same place: Enter follows it and suiPick still fires on the click. A disabled row stays a disabled button whatever its href says.

<sui-panel-menu id="pm-links"></sui-panel-menu>
// setup(root): properties and events setup(root) { root.querySelector('#pm-links').groups = [ { label: 'Company', icon: 'fa-regular fa-building', items: [ { label: 'Profile', href: '#/settings/profile', active: true }, { label: 'Locations', href: '#/settings/locations' }, { label: 'Financial year', href: '#/settings/year', disabled: true }, ], }, { label: 'Support', icon: 'fa-regular fa-circle-question', items: [{ label: 'Help center', href: 'https://example.com/help', target: '_blank' }], }, ]; }

Width

width sets the card in px. A settings rail sits at the default 250; a narrow drawer takes less.

<div style="display:flex;gap:16px;align-items:flex-start"> <sui-panel-menu id="pm-narrow" width="190"></sui-panel-menu> <sui-panel-menu id="pm-wide" width="300"></sui-panel-menu> </div>
// setup(root): properties and events setup(root) { root.querySelector('#pm-narrow').groups = settingsGroups(); root.querySelector('#pm-wide').groups = settingsGroups(); }

Events

A header toggle emits suiExpandedChange with the new array of expanded indexes, as sui-accordion does. Choosing a row emits suiPick with the item, as every menu does, and then runs its command.

Group 0 is open.
<div style="display:flex;gap:16px;align-items:flex-start"> <sui-panel-menu id="pm-events"></sui-panel-menu> <span id="pm-log" style="font:460 13px/1.6 var(--font-sans);color:var(--ink-muted)">Group 0 is open.</span> </div>
// setup(root): properties and events setup(root) { const log = root.querySelector('#pm-log'); const el = root.querySelector('#pm-events'); const groups = settingsGroups(); groups[0].items[0].command = () => { log.textContent += ' Its command ran.'; }; el.groups = groups; el.addEventListener('suiExpandedChange', (e) => { log.textContent = e.detail.length ? `Open groups [${e.detail.join(', ')}].` : 'Every group is closed.'; }); el.addEventListener('suiPick', (e) => { log.textContent = `Picked ${e.detail.label}.`; }); }

Keyboard

KeyAction
TabMoves through the headers and the rows of open groups, in document order.
ArrowDownFrom a header, moves to the next header, wrapping past the last.
ArrowUpFrom a header, moves to the previous header, wrapping past the first.
Home EndFrom a header, moves to the first or the last header.
Enter SpaceOpens or closes the focused group, or follows the focused row.

API

<sui-panel-menu>

Properties

PropertyAttributeTypeDefaultDescription
expandedproperty onlynumber[][0]Indexes of the expanded groups. Mutable; the element updates it as headers toggle, and opens the group that holds an active row when groups arrives with one in a closed group. It is not the boolean open a dismissible surface carries, which is why it has its own name. Property, not attribute.
groupsproperty onlySuiPanelMenuGroup[][]Groups to render: { label, icon?, items: [{ label, active?, badge?, disabled?, command?, href?, target? }] }[]. Headers and rows are plain buttons in document order, so Tab reaches every one; the arrow keys are a shortcut between headers and take nothing out of the tab order. A row with href is a real link in the same place. Property, not attribute.
multiplemultiplebooleanfalseAllow more than one group open at once. Default single-open, as sui-accordion. Reflected.
widthwidthnumber250Card width in px.

Events

EventDetailReactVueDescription
suiExpandedChangenumber[]onSuiExpandedChange@sui-expanded-changeFires after a group header toggles; detail is the new expanded list of open group indexes. Indexed expansion is not open state, so this is not the suiOpen and suiClose pair, as in sui-accordion. Does not bubble.
suiPickSuiPanelMenuItemonSuiPick@sui-pickFires when a row is chosen; detail is the item object, as every menu reports a pick. item.command() also runs.

Methods

MethodSignatureDescription
setFocus()setFocus() => Promise<void>Moves keyboard focus to the first group header.

Types

SuiPanelMenuGroup interfaceOne accordion group: a header button plus its rows.
labelstring
icon?string
itemsSuiPanelMenuItem[]
SuiPanelMenuItem interfaceOne row inside a PanelMenu group.
labelstring
active?boolean
badge?string | number
disabled?booleanInert row (a destination gated by permission or plan). Renders a native disabled button.
command?() => voidRuns when the row is chosen, alongside suiPick (the family's item-command rule).
href?stringRenders the row as a real link: Enter follows it, and suiPick and command still run on the click. A disabled row stays a disabled button whatever href says.
target?stringLink target for a row with href, e.g. _blank. A _blank row gets rel="noopener".

Theming

Every design token this element can render with, read from its own stylesheets and the ones it imports. Set a token on :root or on any ancestor and every use below follows it. The values shown are the ones in the theme you are viewing. The list is read per stylesheet, so an element that imports a shared drawing module lists that module's tokens too, whether or not it paints with all of them.

See also