Context Menu

Right-click this area

Or focus it and press Shift + F10 (or the Menu key).

@use 'sprucecss/scss/spruce' as *;

.context-menu-demo {
  align-items: start;
  display: grid;
  gap: spacer('l');

  @include breakpoint('md') {
    grid-template-columns: auto 1fr;
  }
}

// The wrapper of a menu button: it positions the menu.
.btn-dropdown {
  display: inline-block;
  position: relative;
}

.context-menu {
  --menu-inline-size: 15rem;
  --menu-offset: #{spacer('xxs')};
  @include clear-list;
  background-color: color('surface');
  border: config('border-width-base', $display) solid color('border');
  border-radius: radius('md');
  box-shadow: shadow('lg');
  color: color('text');
  font-size: config('font-size-sm', $typography);
  inline-size: var(--menu-inline-size);
  inset-block-start: calc(100% + var(--menu-offset));
  max-inline-size: calc(100vi - #{spacer('m')});
  padding: spacer('xxs');
  position: absolute;
  z-index: z-index('dropdown');

  &[hidden] {
    display: none;
  }

  @include motion-safe {
    animation: context-menu-in 0.12s ease-out;
  }

  // Placement modifiers. The script adds --block-start (and --inline-end) when there is no room.
  &--inline-start {
    inset-inline-start: 0;
  }

  &--inline-end {
    inset-inline-end: 0;
  }

  &--block-start {
    inset-block: auto calc(100% + var(--menu-offset));
  }

  // A menu opened at the pointer (right-click) or at the focused element.
  &--fixed {
    inset-block-start: var(--menu-block, 0);
    inset-inline-start: var(--menu-inline, 0);
    position: fixed;
  }

  &--submenu {
    inset-block-start: calc(#{spacer('xxs')} * -1);
    inset-inline-start: calc(100% + #{spacer('xxs')});

    &.context-menu--flip {
      inset-inline: auto calc(100% + #{spacer('xxs')});
    }
  }

  > li,
  &__group > li {
    margin: 0 !important;
  }

  &__group {
    @include clear-list;
  }

  &__submenu-wrapper {
    position: relative;
  }

  &__label {
    color: color('muted');
    font-size: 0.75rem;
    font-weight: 700;
    letter-spacing: 0.06em;
    padding: spacer('xs') spacer('s') spacer('xxs');
    text-transform: uppercase;
  }

  &__separator {
    border-block-start: config('border-width-base', $display) solid color('border');
    margin-block: spacer('xxs') !important;
  }

  &__item {
    @include clear-btn;
    align-items: center;
    border-radius: radius('sm');
    color: color('heading');
    display: flex;
    gap: spacer('xs');
    inline-size: 100%;
    padding: spacer('xs') spacer('s');
    text-align: start;

    &:hover,
    &:focus {
      background-color: color-alpha('primary', 0.1);
      color: color('primary');
      outline: none;
    }

    &:focus-visible {
      box-shadow: 0 0 0 2px color('focus-ring') inset;
    }

    &[aria-expanded='true'] {
      background-color: color-alpha('primary', 0.1);
    }

    &[aria-disabled='true'] {
      color: color('muted');
      cursor: not-allowed;
      opacity: 0.6;

      &:hover,
      &:focus {
        background-color: transparent;
      }
    }

    // Checkable items draw their mark in the icon column (no image to load).
    &[role='menuitemcheckbox'],
    &[role='menuitemradio'] {
      &::before {
        content: '';
        flex-shrink: 0;
        visibility: hidden;
      }

      &[aria-checked='true']::before {
        visibility: visible;
      }
    }

    // A check mark is not directional, so it keeps its physical sides in RTL too.
    /* stylelint-disable property-disallowed-list */
    &[role='menuitemcheckbox']::before {
      block-size: 0.35rem;
      border-block-end: 2px solid currentcolor;
      border-left: 2px solid currentcolor;
      inline-size: 0.7rem;
      margin: 0 0.15rem 0.2rem;
      rotate: -45deg;
    }
    /* stylelint-enable property-disallowed-list */

    &[role='menuitemradio']::before {
      background-color: currentcolor;
      block-size: 0.5rem;
      border-radius: 50%;
      inline-size: 0.5rem;
      margin: 0.25rem;
    }

    &--danger {
      color: color('danger', 'alert');

      &:hover,
      &:focus {
        background-color: color-alpha('danger', 0.1, 'alert');
        color: color('danger', 'alert');
      }
    }
  }

  &__icon {
    block-size: 1rem;
    color: color('muted');
    flex-shrink: 0;
    inline-size: 1rem;
  }

  &__item:hover &__icon,
  &__item:focus &__icon,
  &__item--danger &__icon {
    color: currentcolor;
  }

  &__text {
    flex-grow: 1;
  }

  &__shortcut {
    background-color: transparent;
    border: 0;
    box-shadow: none;
    color: color('muted');
    font-size: 0.75rem;
    padding: 0;
  }

  &__chevron {
    block-size: 0.9rem;
    color: color('muted');
    inline-size: 0.9rem;
  }
}

[dir='rtl'] .context-menu__chevron {
  scale: -1 1;
}

.context-menu-area {
  align-content: center;
  border: 2px dashed color('border-strong');
  border-radius: radius('lg');
  display: grid;
  gap: spacer('xxs');
  min-block-size: 12rem;
  padding: spacer('m');
  text-align: center;

  &:focus-visible {
    border-color: color('primary');
    outline: 2px solid color('focus-ring');
    outline-offset: 2px;
  }

  &__title {
    color: color('heading');
    font-weight: 700;
    margin-block: 0;
  }

  &__help {
    color: color('muted');
    font-size: config('font-size-sm', $typography);
    margin-block: 0;
  }
}

@keyframes context-menu-in {
  from {
    opacity: 0;
    translate: 0 -0.25rem;
  }
}
<div class="context-menu-demo">
    <div class="btn-dropdown" data-menu>
        <button class="btn btn--primary" type="button" id="menu-add-block-button" aria-haspopup="menu" aria-expanded="false" aria-controls="menu-add-block" data-menu-trigger>
            Add block
            <svg class="btn__icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m6 9 6 6 6-6"/></svg>
        </button>
    <ul class="context-menu context-menu--inline-start" id="menu-add-block" role="menu" aria-labelledby="menu-add-block-button" hidden data-menu-list>
        <li class="context-menu__label" role="presentation">Content</li>
        <li role="none"><button class="context-menu__item" type="button" role="menuitem" tabindex="-1"><svg class="context-menu__icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M4 7V4h16v3M9 20h6M12 4v16"/></svg><span class="context-menu__text">Text</span><kbd class="context-menu__shortcut">T</kbd></button></li>
        <li role="none"><button class="context-menu__item" type="button" role="menuitem" tabindex="-1"><svg class="context-menu__icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.1-3.1a2 2 0 0 0-2.8 0L6 21"/></svg><span class="context-menu__text">Image</span><kbd class="context-menu__shortcut">I</kbd></button></li>
        <li role="none"><button class="context-menu__item" type="button" role="menuitem" tabindex="-1"><svg class="context-menu__icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M3 21c3 0 7-1 7-8V5c0-1.25-.756-2.017-2-2H4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .008-1 1.031V20c0 1 0 1 1 1z"/><path d="M15 21c3 0 7-1 7-8V5c0-1.25-.757-2.017-2-2h-4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2h.75c0 2.25.25 4-2.75 4v3c0 1 0 1 1 1z"/></svg><span class="context-menu__text">Quote</span><kbd class="context-menu__shortcut">Q</kbd></button></li>
        <li role="none" class="context-menu__submenu-wrapper">
            <button class="context-menu__item" type="button" role="menuitem" tabindex="-1" aria-haspopup="menu" aria-expanded="false" aria-controls="menu-embed" id="menu-embed-button" data-submenu-trigger><svg class="context-menu__icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m16 18 6-6-6-6"/><path d="m8 6-6 6 6 6"/></svg><span class="context-menu__text">Embed</span><svg class="context-menu__chevron" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m9 18 6-6-6-6"/></svg></button>
            <ul class="context-menu context-menu--submenu" id="menu-embed" role="menu" aria-labelledby="menu-embed-button" hidden data-menu-list>
                <li role="none"><button class="context-menu__item" type="button" role="menuitem" tabindex="-1"><span class="context-menu__text">YouTube video</span></button></li>
                <li role="none"><button class="context-menu__item" type="button" role="menuitem" tabindex="-1"><span class="context-menu__text">Vimeo video</span></button></li>
                <li role="none"><button class="context-menu__item" type="button" role="menuitem" tabindex="-1"><span class="context-menu__text">CodePen</span></button></li>
                <li role="none"><button class="context-menu__item" type="button" role="menuitem" tabindex="-1" aria-disabled="true"><span class="context-menu__text">Map</span></button></li>
            </ul>
        </li>
        <li class="context-menu__separator" role="separator"></li>
        <li class="context-menu__label" role="presentation">View</li>
        <li role="none"><button class="context-menu__item" type="button" role="menuitemcheckbox" tabindex="-1" aria-checked="true"><span class="context-menu__text">Show outlines</span></button></li>
        <li role="none"><button class="context-menu__item" type="button" role="menuitemcheckbox" tabindex="-1" aria-checked="false"><span class="context-menu__text">Snap to grid</span></button></li>
        <li class="context-menu__separator" role="separator"></li>
        <li role="none">
            <ul class="context-menu__group" role="group" aria-label="Width">
                <li role="none"><button class="context-menu__item" type="button" role="menuitemradio" tabindex="-1" aria-checked="false"><span class="context-menu__text">Narrow</span></button></li>
                <li role="none"><button class="context-menu__item" type="button" role="menuitemradio" tabindex="-1" aria-checked="true"><span class="context-menu__text">Regular</span></button></li>
                <li role="none"><button class="context-menu__item" type="button" role="menuitemradio" tabindex="-1" aria-checked="false"><span class="context-menu__text">Full width</span></button></li>
            </ul>
        </li>
    </ul>
    </div>
    <div class="context-menu-area" tabindex="0" aria-describedby="context-menu-area-help" data-context-area data-menu>
        <p class="context-menu-area__title">Right-click this area</p>
        <p class="context-menu-area__help" id="context-menu-area-help">Or focus it and press <kbd>Shift</kbd> + <kbd>F10</kbd> (or the Menu key).</p>
        <ul class="context-menu" id="menu-canvas" role="menu" aria-label="Block actions" hidden data-menu-list>
            <li role="none"><button class="context-menu__item" type="button" role="menuitem" tabindex="-1"><svg class="context-menu__icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><rect width="14" height="14" x="8" y="8" rx="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg><span class="context-menu__text">Duplicate</span><kbd class="context-menu__shortcut">Ctrl D</kbd></button></li>
            <li role="none"><button class="context-menu__item" type="button" role="menuitem" tabindex="-1"><svg class="context-menu__icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M10 13a5 5 0 0 0 7.5.5l3-3a5 5 0 0 0-7-7l-1.7 1.7"/><path d="M14 11a5 5 0 0 0-7.5-.5l-3 3a5 5 0 0 0 7 7l1.7-1.7"/></svg><span class="context-menu__text">Copy link</span></button></li>
            <li class="context-menu__separator" role="separator"></li>
            <li role="none"><button class="context-menu__item context-menu__item--danger" type="button" role="menuitem" tabindex="-1"><svg class="context-menu__icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M3 6h18M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/></svg><span class="context-menu__text">Delete</span><kbd class="context-menu__shortcut">Del</kbd></button></li>
        </ul>
    </div>
    <p class="sr-only" aria-live="polite" data-menu-status></p>
</div>
(() => {
  const status = document.querySelector('[data-menu-status]');
  const isRtl = (element) => getComputedStyle(element).direction === 'rtl';
  const itemsOf = (menu) => [...menu.querySelectorAll(':scope > li > [role^="menuitem"], :scope > li > ul > li > [role^="menuitem"]')];

  // Keep the menu inside the viewport: open upward or toward the other side when there is no room.
  const place = (menu) => {
    menu.classList.remove('context-menu--block-start', 'context-menu--flip');
    const rect = menu.getBoundingClientRect();

    if (menu.classList.contains('context-menu--submenu')) {
      if (rect.right > window.innerWidth || rect.left < 0) menu.classList.add('context-menu--flip');
    } else if (!menu.classList.contains('context-menu--fixed') && rect.bottom > window.innerHeight && rect.top > rect.height) {
      menu.classList.add('context-menu--block-start');
    }
  };

  const closeMenu = (menu, { restoreFocus = false } = {}) => {
    if (!menu || menu.hidden) return;

    menu.querySelectorAll('[data-menu-list]').forEach((submenu) => closeMenu(submenu));
    menu.hidden = true;

    const trigger = document.querySelector(`[aria-controls="${menu.id}"]`);
    trigger?.setAttribute('aria-expanded', 'false');

    if (restoreFocus) (trigger ?? menu.closest('[data-context-area]'))?.focus();
  };

  const openMenu = (menu, focus = 'first') => {
    menu.hidden = false;
    document.querySelector(`[aria-controls="${menu.id}"]`)?.setAttribute('aria-expanded', 'true');
    place(menu);

    const items = itemsOf(menu).filter((item) => item.getAttribute('aria-disabled') !== 'true');
    (focus === 'last' ? items[items.length - 1] : items[0])?.focus();
  };

  const activate = (item) => {
    if (item.getAttribute('aria-disabled') === 'true') return;

    const role = item.getAttribute('role');
    const label = item.querySelector('.context-menu__text').textContent;

    if (item.hasAttribute('aria-haspopup')) {
      openMenu(document.getElementById(item.getAttribute('aria-controls')));
      return;
    }

    if (role === 'menuitemcheckbox') {
      const checked = item.getAttribute('aria-checked') !== 'true';
      item.setAttribute('aria-checked', String(checked));
      status.textContent = `${label} ${checked ? 'on' : 'off'}`;
      return; // Checkable items keep the menu open.
    }

    if (role === 'menuitemradio') {
      item.closest('[role="group"]').querySelectorAll('[role="menuitemradio"]').forEach((other) => other.setAttribute('aria-checked', String(other === item)));
      status.textContent = `${label} selected`;
      return;
    }

    status.textContent = `${label} chosen`;
    item.dispatchEvent(new CustomEvent('menuselect', { bubbles: true, detail: label }));
    closeMenu(item.closest('[data-menu]').querySelector(':scope > [data-menu-list]'), { restoreFocus: true });
  };

  document.querySelectorAll('[data-menu]').forEach((root) => {
    const menu = root.querySelector(':scope > [data-menu-list]');
    const trigger = root.querySelector(':scope > [data-menu-trigger]');

    // Menu button: click, Enter/Space, ArrowDown (first item), ArrowUp (last item).
    trigger?.addEventListener('click', () => (menu.hidden ? openMenu(menu) : closeMenu(menu)));
    trigger?.addEventListener('keydown', (event) => {
      if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
        event.preventDefault();
        openMenu(menu, event.key === 'ArrowUp' ? 'last' : 'first');
      }
    });

    // Context area: right-click at the pointer, Shift+F10 or the Menu key at the element.
    if (root.hasAttribute('data-context-area')) {
      const openAt = (x, y) => {
        menu.classList.add('context-menu--fixed');
        menu.style.setProperty('--menu-block', `${y}px`);
        menu.style.setProperty('--menu-inline', `${isRtl(root) ? window.innerWidth - x : x}px`);
        openMenu(menu);

        const rect = menu.getBoundingClientRect();
        if (rect.bottom > window.innerHeight) menu.style.setProperty('--menu-block', `${Math.max(8, y - rect.height)}px`);
        if (rect.right > window.innerWidth || rect.left < 0) menu.style.setProperty('--menu-inline', `${Math.max(8, (isRtl(root) ? window.innerWidth - x : x) - rect.width)}px`);
      };

      root.addEventListener('contextmenu', (event) => {
        if (menu.contains(event.target)) return;
        event.preventDefault();
        openAt(event.clientX, event.clientY);
      });

      root.addEventListener('keydown', (event) => {
        if (event.target === root && ((event.shiftKey && event.key === 'F10') || event.key === 'ContextMenu')) {
          event.preventDefault();
          const rect = root.getBoundingClientRect();
          openAt(isRtl(root) ? rect.right - 16 : rect.left + 16, rect.top + 16);
        }
      });
    }

    root.addEventListener('click', (event) => {
      const item = event.target.closest('[role^="menuitem"]');
      if (item && root.contains(item)) activate(item);
    });

    // Keyboard support inside the menus (the WAI-ARIA menu pattern).
    root.addEventListener('keydown', (event) => {
      const item = event.target.closest('[role^="menuitem"]');
      if (!item) return;

      const currentMenu = item.closest('[data-menu-list]');
      const items = itemsOf(currentMenu);
      const index = items.indexOf(item);
      const forward = isRtl(root) ? 'ArrowLeft' : 'ArrowRight';
      const backward = isRtl(root) ? 'ArrowRight' : 'ArrowLeft';
      const isSubmenu = currentMenu.classList.contains('context-menu--submenu');

      switch (event.key) {
        case 'ArrowDown': items[(index + 1) % items.length].focus(); break;
        case 'ArrowUp': items[(index - 1 + items.length) % items.length].focus(); break;
        case 'Home': items[0].focus(); break;
        case 'End': items[items.length - 1].focus(); break;
        case 'Enter':
        case ' ': activate(item); break;
        case forward:
          if (item.hasAttribute('aria-haspopup')) openMenu(document.getElementById(item.getAttribute('aria-controls')));
          break;
        case backward:
          if (isSubmenu) { closeMenu(currentMenu); document.querySelector(`[aria-controls="${currentMenu.id}"]`).focus(); }
          break;
        case 'Escape':
          if (isSubmenu) { closeMenu(currentMenu); document.querySelector(`[aria-controls="${currentMenu.id}"]`).focus(); } else { closeMenu(menu, { restoreFocus: true }); }
          break;
        case 'Tab': closeMenu(menu); return;
        default: {
          // Typeahead: jump to the next item starting with the typed character.
          if (event.key.length !== 1 || !/\S/.test(event.key)) return;
          const next = [...items.slice(index + 1), ...items.slice(0, index)].find((other) => other.textContent.trim().toLowerCase().startsWith(event.key.toLowerCase()));
          next?.focus();
        }
      }

      event.preventDefault();
    });

    // Submenus also open on hover.
    root.querySelectorAll('[data-submenu-trigger]').forEach((submenuTrigger) => {
      const wrapper = submenuTrigger.parentElement;
      const submenu = document.getElementById(submenuTrigger.getAttribute('aria-controls'));
      wrapper.addEventListener('pointerenter', () => { submenu.hidden = false; submenuTrigger.setAttribute('aria-expanded', 'true'); place(submenu); });
      wrapper.addEventListener('pointerleave', () => closeMenu(submenu));
    });
  });

  // A click outside closes every open menu.
  document.addEventListener('pointerdown', (event) => {
    document.querySelectorAll('[data-menu] > [data-menu-list]:not([hidden])').forEach((menu) => {
      if (!menu.closest('[data-menu]').contains(event.target) || (menu.classList.contains('context-menu--fixed') && !menu.contains(event.target))) closeMenu(menu);
    });
  });

  // A menu opened at the pointer doesn't follow the page, so scrolling closes it.
  document.addEventListener('scroll', () => {
    document.querySelectorAll('.context-menu--fixed:not([hidden])').forEach((menu) => closeMenu(menu));
  }, { capture: true, passive: true });
})();

Context Menu

An accessible menu for actions: a menu button (dropdown) and a right-click context menu, with groups, icons, keyboard shortcuts, checkable items, a radio group, a submenu, and disabled and destructive items.

Anatomy

  • .btn-dropdown wraps a menu button and positions its menu.
  • .context-menu is the menu (role="menu"); its modifiers set the placement: --inline-start (default), --inline-end, --block-start (opens upward), --fixed (at the pointer) and --submenu.
  • .context-menu__item is a button with role="menuitem", menuitemcheckbox or menuitemradio. Add an icon (.context-menu__icon), a label (.context-menu__text), a shortcut hint (.context-menu__shortcut) or a submenu chevron.
  • .context-menu__label, .context-menu__separator and .context-menu__group organize the items. .context-menu__item--danger marks destructive actions.
  • .context-menu-area is an element with its own right-click menu.

Keyboard

It follows the WAI-ARIA Menu Button and Menu patterns:

KeyAction
Enter, Space, ↓Open the menu and focus the first item (on the button).
↑Open the menu and focus the last item (on the button).
↓ / ↑Move to the next/previous item (wraps around).
Home / EndMove to the first/last item.
→ / ←Open/close a submenu (mirrored in RTL).
A letterMove to the next item starting with it (typeahead).
EscClose the (sub)menu and return the focus.
TabClose the menu and move on.
Shift + F10, Menu keyOpen the context menu of a focused context area.

Technical Details

  • Vanilla JavaScript, no dependencies. The script manages aria-expanded on the triggers, a roving focus (tabindex="-1" on the items) and aria-checked on the checkable items.
  • Checkable and radio items keep the menu open; the other items close it, return the focus to the trigger and dispatch a bubbling menuselect event with the label, so you can react to the choice. A polite live region announces the result.
  • Disabled items use aria-disabled="true" (they stay focusable and readable, but don't run).
  • The menu flips upward when there isn't enough room below, and submenus flip to the other side near the viewport edge. A click outside closes every open menu.
  • Every position uses logical properties (inset-inline-start, inset-block-start), so the menu mirrors in RTL.
  • The colors come from the tokens (surface, border, primary, the danger alert color), so the menu follows the dark mode and every style; the old context color group is no longer needed.

Usage

document.addEventListener('menuselect', (event) => {
  console.log(`Selected: ${event.detail}`);
});

Documentation

Learn about Spruce CSS through our extensive documentation.

Components

Explore our UI library of accessible components built with Spruce CSS.

Blocks and Pages

Full-width page blocks and complete page templates, ready to copy.

Find us on GitHub

Did you find a bug? Have an idea or a question? Please open an issue to help us develop the project.