Skip to content

Menu Examples

The examples below assume that you have imported the components and set up the theme.

Basic

sv-menu wraps an sv-flyout and turns the flyout's content into a command menu: keyboard navigation, typeahead, and the menu popup role come with it, while the menuitem roles stay yours to declare. Acting on a chosen item is also yours: handle its click and call close(), as every example here does.

<div class="menu-basic">
<sv-menu>
<sv-flyout aria-label="Actions menu">
<button type="button" class="trigger" slot="trigger">Actions</button>
<button type="button" class="item" role="menuitem">Edit</button>
<button type="button" class="item" role="menuitem">Duplicate</button>
<button type="button" class="item" role="menuitem">Delete</button>
</sv-flyout>
</sv-menu>
</div>
<script type="application/x-typescript" module>
const menu = document.querySelector<Menu>(".menu-basic sv-menu")!;
menu.addEventListener("click", event => {
if ((event.target as HTMLElement | null)?.closest('[role="menuitem"]')) {
menu.close();
}
});
</script>
<style>
.menu-basic {
display: grid;
justify-items: center;
padding-block: 1em;
sv-flyout::part(content) {
min-inline-size: 10rem;
}
.trigger {
padding: 0.5em 1.25em;
font: inherit;
color: var(--sv-gray-2);
cursor: pointer;
background-color: var(--sv-gray-6);
border: solid var(--sv-bdr-sz-sm) var(--sv-gray-5);
border-radius: var(--sv-bdr-rad-md);
}
.item {
padding: 0.4em 0.75em;
font: inherit;
color: var(--sv-gray-2);
text-align: start;
cursor: pointer;
background-color: transparent;
border: none;
border-radius: var(--sv-bdr-rad-sm);
@media (hover: hover) and (pointer: fine) {
&:hover {
background-color: var(--sv-gray-5);
}
}
}
}
</style>

Items

Menu items are simply the focusable elements in the flyout's content. The disabled Paste here keeps its place in the list while the arrow keys skip over it.

<div class="menu-items">
<sv-menu>
<sv-flyout aria-label="Clipboard menu">
<button type="button" class="trigger" slot="trigger">Clipboard</button>
<button type="button" class="item" role="menuitem">Cut</button>
<button type="button" class="item" role="menuitem">Copy</button>
<button type="button" class="item" disabled role="menuitem">Paste</button>
<button type="button" class="item" role="menuitem">Select all</button>
</sv-flyout>
</sv-menu>
</div>
<script type="application/x-typescript" module>
const menu = document.querySelector<Menu>(".menu-items sv-menu")!;
menu.addEventListener("click", event => {
if ((event.target as HTMLElement | null)?.closest('[role="menuitem"]')) {
menu.close();
}
});
</script>
<style>
.menu-items {
display: grid;
justify-items: center;
padding-block: 1em;
sv-flyout::part(content) {
min-inline-size: 10rem;
}
.trigger {
padding: 0.5em 1.25em;
font: inherit;
color: var(--sv-gray-2);
cursor: pointer;
background-color: var(--sv-gray-6);
border: solid var(--sv-bdr-sz-sm) var(--sv-gray-5);
border-radius: var(--sv-bdr-rad-md);
}
.item {
padding: 0.4em 0.75em;
font: inherit;
color: var(--sv-gray-2);
text-align: start;
cursor: pointer;
background-color: transparent;
border: none;
border-radius: var(--sv-bdr-rad-sm);
&[disabled] {
color: var(--sv-gray-4);
cursor: not-allowed;
}
@media (hover: hover) and (pointer: fine) {
&:not([disabled]):hover {
background-color: var(--sv-gray-5);
}
}
}
}
</style>

A nested sv-menu with mode="submenu" turns its flyout into a submenu branch. Hover-opening and the side placement come from the nested flyout's open-on-hover and position-order attributes.

<div class="menu-submenus">
<sv-menu>
<sv-flyout aria-label="Actions menu">
<button type="button" class="trigger" slot="trigger">Actions</button>
<button type="button" class="item" role="menuitem">Rename</button>
<button type="button" class="item" role="menuitem">Duplicate</button>
<sv-menu mode="submenu">
<sv-flyout aria-label="Share submenu" open-on-hover position-order="horizontal">
<button type="button" class="item" slot="trigger">
<span>Share</span>
<!-- Arrow right -->
<svg class="submenu-icon" aria-hidden="true" viewBox="0 -960 960 960" xmlns="http://www.w3.org/2000/svg">
<path d="M400-280v-400l200 200-200 200Z"></path>
</svg>
</button>
<button type="button" class="item" role="menuitem">Copy link</button>
<button type="button" class="item" role="menuitem">Send by email</button>
</sv-flyout>
</sv-menu>
<sv-menu mode="submenu">
<sv-flyout aria-label="Export submenu" open-on-hover position-order="horizontal">
<button type="button" class="item" slot="trigger">
<span>Export</span>
<!-- Arrow right -->
<svg class="submenu-icon" aria-hidden="true" viewBox="0 -960 960 960" xmlns="http://www.w3.org/2000/svg">
<path d="M400-280v-400l200 200-200 200Z"></path>
</svg>
</button>
<button type="button" class="item" role="menuitem">As PDF</button>
<button type="button" class="item" role="menuitem">As PNG</button>
</sv-flyout>
</sv-menu>
</sv-flyout>
</sv-menu>
</div>
<script type="application/x-typescript" module>
const menu = document.querySelector<Menu>(".menu-submenus > sv-menu")!;
menu.addEventListener("click", event => {
if ((event.target as HTMLElement | null)?.closest('[role="menuitem"]:not([slot="trigger"])')) {
menu.close();
}
});
</script>
<style>
.menu-submenus {
display: grid;
justify-items: center;
padding-block: 1em;
sv-flyout::part(content) {
min-inline-size: 10rem;
}
sv-menu[mode="submenu"] sv-flyout {
--sv-flyout-offset: 0;
}
.trigger {
padding: 0.5em 1.25em;
font: inherit;
color: var(--sv-gray-2);
cursor: pointer;
background-color: var(--sv-gray-6);
border: solid var(--sv-bdr-sz-sm) var(--sv-gray-5);
border-radius: var(--sv-bdr-rad-md);
}
.item {
display: flex;
gap: 0.5em;
align-items: center;
padding: 0.4em 0.75em;
font: inherit;
color: var(--sv-gray-2);
text-align: start;
cursor: pointer;
background-color: transparent;
border: none;
border-radius: var(--sv-bdr-rad-sm);
.submenu-icon {
inline-size: 1.2em;
block-size: 1.2em;
margin-inline-start: auto;
fill: currentcolor;
}
@media (hover: hover) and (pointer: fine) {
&:hover {
background-color: var(--sv-gray-5);
}
}
}
}
</style>

Context Menu

mode="context" opens the menu from a contextmenu event inside the trigger, right at the pointer. The trigger stays the keyboard path: focus it and press Shift+F10 or the menu key.

<div class="menu-context">
<sv-menu mode="context">
<sv-flyout aria-label="Page menu">
<button type="button" class="context-target" slot="trigger">
<strong>Right-click here</strong>
<span>Or touch and hold, or focus and press Shift+F10.</span>
</button>
<button type="button" class="item" role="menuitem">Reload</button>
<button type="button" class="item" role="menuitem">Copy</button>
<button type="button" class="item" role="menuitem">Select all</button>
</sv-flyout>
</sv-menu>
</div>
<script type="application/x-typescript" module>
const menu = document.querySelector<Menu>(".menu-context sv-menu")!;
menu.addEventListener("click", event => {
if ((event.target as HTMLElement | null)?.closest('[role="menuitem"]')) {
menu.close();
}
});
</script>
<style>
.menu-context {
display: grid;
inline-size: min(100%, 26rem);
margin-inline: auto;
sv-flyout {
--sv-flyout-offset: 0;
&::part(content) {
min-inline-size: 10rem;
}
}
.context-target {
display: grid;
gap: 0.5em;
place-content: center;
min-block-size: 10rem;
padding: 1.5em;
font: inherit;
color: var(--sv-gray-2);
text-align: center;
touch-action: manipulation;
cursor: context-menu;
user-select: none;
background: var(--sv-gray-7);
border: var(--sv-bdr-sz-sm) dashed var(--sv-gray-4);
border-radius: var(--sv-bdr-rad-lg);
-webkit-touch-callout: none;
&:focus-visible {
outline: var(--sv-bdr-sz-md) solid var(--sv-accent);
outline-offset: 0.2em;
}
span {
color: var(--sv-gray-3);
}
}
.item {
padding: 0.4em 0.75em;
font: inherit;
color: var(--sv-gray-2);
text-align: start;
cursor: pointer;
background-color: transparent;
border: none;
border-radius: var(--sv-bdr-rad-sm);
@media (hover: hover) and (pointer: fine) {
&:hover {
background-color: var(--sv-gray-5);
}
}
}
}
</style>