Skip to content
ShineUI
Install

TieredMenu

Menus inside menus.

<sui-tiered-menu>Menus and navigationStable

About

Vertical menu whose rows open flyout submenus, for deep action trees such as export formats or posting periods. A row with items shows a chevron; its flyout opens toward the inline end on hover, click or the forward arrow. On a phone, where no flyout fits, the row drills in: its items fill the menu under a back row.

Usage

<sui-tiered-menu variant="card" aria-label="Invoice actions"></sui-tiered-menu> <script type="module"> import { defineCustomElements } from "@shineui/core/loader"; import "@shineui/core/shineui.css"; defineCustomElements(); const el = document.querySelector("sui-tiered-menu"); el.items = [ { label: "Print", icon: "fa-solid fa-print" }, { label: "Email to customer", icon: "fa-regular fa-envelope" }, { separator: true }, { label: "Export", icon: "fa-regular fa-share-from-square", items: [ { label: "PDF", icon: "fa-regular fa-file-pdf" }, { label: "Excel", icon: "fa-regular fa-file-excel" }, { label: "CSV", icon: "fa-regular fa-file-lines" }, ], }, ]; /* SuiMenuItem[] */ el.addEventListener("suiPick", (e) => console.log(e.detail)); </script>
import { SuiTieredMenu } from "@shineui/react"; import "@shineui/core/shineui.css"; export function Example() { const items = [ { label: "Print", icon: "fa-solid fa-print" }, { label: "Email to customer", icon: "fa-regular fa-envelope" }, { separator: true }, { label: "Export", icon: "fa-regular fa-share-from-square", items: [ { label: "PDF", icon: "fa-regular fa-file-pdf" }, { label: "Excel", icon: "fa-regular fa-file-excel" }, { label: "CSV", icon: "fa-regular fa-file-lines" }, ], }, ]; /* SuiMenuItem[] */ return <SuiTieredMenu variant="card" aria-label="Invoice actions" items={items} onSuiPick={(e) => console.log(e.detail)} />; }
<script setup> import { SuiTieredMenu } from "@shineui/vue"; import "@shineui/core/shineui.css"; const items = [ { label: "Print", icon: "fa-solid fa-print" }, { label: "Email to customer", icon: "fa-regular fa-envelope" }, { separator: true }, { label: "Export", icon: "fa-regular fa-share-from-square", items: [ { label: "PDF", icon: "fa-regular fa-file-pdf" }, { label: "Excel", icon: "fa-regular fa-file-excel" }, { label: "CSV", icon: "fa-regular fa-file-lines" }, ], }, ]; // SuiMenuItem[] const onChange = (e) => console.log(e.detail); </script> <template> <SuiTieredMenu variant="card" aria-label="Invoice actions" :items="items" @sui-pick="onChange" /> </template>
import { Component } from "@angular/core"; import { SuiTieredMenu } from "@shineui/angular"; @Component({ standalone: true, imports: [SuiTieredMenu], template: `<sui-tiered-menu variant="card" aria-label="Invoice actions" [(openPath)]="openPath" [items]="items" (suiPick)="onChange($event)"></sui-tiered-menu>`, }) export class Example { openPath: number[] = []; items = [ { label: "Print", icon: "fa-solid fa-print" }, { label: "Email to customer", icon: "fa-regular fa-envelope" }, { separator: true }, { label: "Export", icon: "fa-regular fa-share-from-square", items: [ { label: "PDF", icon: "fa-regular fa-file-pdf" }, { label: "Excel", icon: "fa-regular fa-file-excel" }, { label: "CSV", icon: "fa-regular fa-file-lines" }, ], }, ]; // SuiMenuItem[] onChange(e: CustomEvent) { console.log(e.detail); } }

Basic

A row with children reads aria-haspopup="menu" and aria-expanded, and hovering it opens its flyout. Each level is its own role="menu", named by the row that opened it. Flyouts paint in the top layer, so a box around the menu cannot clip them; a press outside closes them. variant="card" frames a menu at rest.

<div style="min-height:200px"> <sui-tiered-menu variant="card" aria-label="Invoice actions"></sui-tiered-menu> </div>
// setup(root): properties and events setup(root) { root.querySelector('sui-tiered-menu').items = invoiceTree(); }

Open path

openPath holds the open row index per depth, so [1, 0] opens the second root row and then its first child. Nesting has no fixed depth, and width sets every level in px.

<div style="min-height:290px"> <sui-tiered-menu id="tm-deep" variant="card" width="260"></sui-tiered-menu> </div>
// setup(root): properties and events setup(root) { const el = root.querySelector('#tm-deep'); el.items = [ { label: 'New transaction', icon: 'fa-solid fa-plus' }, { label: 'Post to period', icon: 'fa-regular fa-calendar', items: [ { label: 'Financial year 2026', items: [ { label: 'January 2026' }, { label: 'February 2026' }, { label: 'March 2026' }, ], }, { label: 'Financial year 2025', items: [{ label: 'November 2025' }, { label: 'December 2025' }], }, ], }, { label: 'Attach receipt', icon: 'fa-solid fa-paperclip' }, ]; el.openPath = [1, 0]; }

Danger and disabled rows

A danger row takes the danger tokens and still picks normally. A disabled row is inert at every level: the keyboard steps over it and it cannot open its own flyout.

<div style="min-height:230px"> <sui-tiered-menu id="tm-states"></sui-tiered-menu> </div>
// setup(root): properties and events setup(root) { root.querySelector('#tm-states').items = [ { label: 'Match to bank feed', icon: 'fa-solid fa-link' }, { label: 'Reconcile', icon: 'fa-solid fa-check-double', items: [ { label: 'Operating 4402' }, { label: 'Payroll 4410', disabled: true }, ], }, { label: 'Lock period', icon: 'fa-solid fa-lock', disabled: true }, { separator: true }, { label: 'Exclude transaction', icon: 'fa-regular fa-circle-xmark', danger: true }, ]; }

A leaf with href is a real link at any depth: suiPick fires on the click, the path collapses, then the browser follows. A row with children opens them instead, whatever its href says.

<div style="min-height:200px"> <sui-tiered-menu id="tm-links"></sui-tiered-menu> </div>
// setup(root): properties and events setup(root) { root.querySelector('#tm-links').items = [ { label: 'Open invoice', icon: 'fa-regular fa-file-lines', href: '#/invoices/1042' }, { label: 'Customer', icon: 'fa-regular fa-user', items: [ { label: 'Profile', href: '#/customers/riverbend' }, { label: 'Statement', href: '#/customers/riverbend/statement' }, { label: 'Portal', href: 'https://example.com/portal', target: '_blank' }, ], }, { separator: true }, { label: 'Void invoice', icon: 'fa-regular fa-trash-can', danger: true }, ]; }

Events

Several flyouts can be open at once, so the element reports its path with suiOpenPathChange rather than an open and close pair. Picking a leaf emits suiPick, runs its command and collapses the path.

Path is empty.
<div style="min-height:250px;display:flex;gap:16px;align-items:flex-start"> <sui-tiered-menu id="tm-events"></sui-tiered-menu> <span id="tm-log" style="font:460 13px/1.6 var(--font-sans);color:var(--ink-muted)">Path is empty.</span> </div>
// setup(root): properties and events setup(root) { const log = root.querySelector('#tm-log'); const el = root.querySelector('#tm-events'); const items = invoiceTree(); items[0].command = () => { log.textContent += ' Its command ran.'; }; el.items = items; el.addEventListener('suiOpenPathChange', (e) => { log.textContent = e.detail.length ? `Open path [${e.detail.join(', ')}].` : 'Path is empty.'; }); el.addEventListener('suiPick', (e) => { log.textContent = `Picked ${e.detail.label}.`; }); }

Keyboard

KeyAction
ArrowDown ArrowUpMoves within the current level, wrapping, and closes any flyout open below it. Separators, section eyebrows and disabled rows are skipped.
Home EndMoves to the first or last enabled row of the current level.
ArrowRightOn a row with children, opens its flyout and focuses the first row. Under dir="rtl" the pair swaps, so the arrow that points along the reading direction is always the forward one.
Enter SpaceOn a row with children, opens its flyout and focuses the first row. On a leaf, picks it and collapses every flyout, with focus back on the root row that owned the path; on a leaf with href the browser then follows the link.
ArrowLeftCloses the current flyout and returns focus to its parent row.
EscapeCloses the current flyout and returns focus to its parent row. At the root, collapses any open path.
TabIn a flyout, closes every flyout and moves on to the control after the menu.
Shift+TabIn a flyout, closes every flyout and returns focus to the root row that opened them.
Any letterMoves to the next row in the current level whose label starts with that letter.

API

<sui-tiered-menu>

Properties

PropertyAttributeTypeDefaultDescription
itemsproperty onlySuiMenuItem[][]Nested rows: { label, icon?, danger?, disabled?, separator?, href?, target?, items?: [...] }[]. A leaf with href is a real link: Enter follows it and suiPick still fires. A row with items opens them instead, whatever its href says. Property, not attribute.
openPathproperty onlynumber[][]Open row index per depth: [3] opens the fourth root row's flyout, [1, 0] opens two levels. Mutable: the element writes it on every hover, open, collapse and pick and reports each change through suiOpenPathChange. Open flyouts paint in the browser's top layer, so a transformed, contained or clipping ancestor cannot move them or cut them off. A press outside the menu and its flyouts, and Tab from a flyout, collapse the path. Property, not attribute.
variantvariant"card" | "plain"'plain'Surface the root level paints. plain is the default: rows only, for a menu inside a sui-popover with flush or any other card. card adds a resting border and rounded corners, no shadow, for a menu that stands on a page by itself. Flyouts always paint their own floating card. Anything else falls back to plain.
widthwidthnumber230Width of every flyout in px, and of the root level when it is a card. A plain root takes the width of the box it sits in.

Events

EventDetailReactVueDescription
suiOpenPathChangenumber[]onSuiOpenPathChange@sui-open-path-changeFires whenever the element changes openPath; detail is the new path. Several flyouts can be open at once, so this is indexed expansion as in sui-accordion, not the suiOpen and suiClose pair: the menu is inline, with no trigger and nothing to dismiss. Does not bubble.
suiPickSuiMenuItemonSuiPick@sui-pickFires when a leaf row is picked; detail is the item. item.command() also runs, then the open path collapses to empty.

Methods

MethodSignatureDescription
setFocus()setFocus() => Promise<void>Moves keyboard focus to the menu's one tab stop: the highlighted root row, else the first enabled one (the current level's, while a phone-width menu has drilled in).

Types

SuiMenuItem interface
label?string
icon?string
shortcut?string
badge?string | number
danger?boolean
disabled?boolean
separator?boolean
section?stringSection heading row (uppercase eyebrow).
items?SuiMenuItem[]Nested items (TieredMenu, Menubar dropdowns).
command?() => void
href?stringRenders the row as a real link, so middle-click, copy link and open in a new tab work and Enter follows it. The row keeps its styling, its place in the arrow-key order and its pick: command runs and suiPick fires on the click, then the browser navigates. Ignored on a disabled row and on a row with nested items; those stay buttons.
target?stringLink target for a row with href, e.g. _blank. A _blank row gets rel="noopener".
current?booleanThe row is the page the reader is on: it carries aria-current="page" and the bold face sui-side-nav gives its current item. For a navigation menu, the site pages behind a phone menu button; an action menu has no current row.
checked?booleanMakes the row one choice of a set, for a mode or an order picked from a menu: the chosen row draws a check and every row keeps room for one, and the rows read as menuitemradio with aria-checked. Leave it unset on an action row.

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