20 CSS Popovers10 / 20

Pure CSSMIT licensed

Submenus and Nested Popovers

Two auto popovers can be open at once if the browser considers one nested inside the other, and cannot if it does not. Nesting is established by DOM containment or by the popovertarget relationship, and getting it wrong makes the parent menu slam shut the instant the submenu opens. This two-level menu shows the working arrangement and names the rule.

Published

Live Demo
Try it

The code

<div class="pop-10">
  <div class="pop-10__stage">
    <div class="pop-10__app">
      <div class="pop-10__bar">
        <span class="pop-10__title">Ledger · Q3 close</span>
        <button class="pop-10__menubtn" type="button" popovertarget="pop-10-menu">
          Actions
          <svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.9" aria-hidden="true"><path d="M4 6.5 8 10.5 12 6.5"/></svg>
        </button>
      </div>
      <p class="pop-10__rulecopy">Nesting is DOM containment or a popovertarget relationship. Both levels stay open only because the submenu lives inside the menu.</p>

      <div class="pop-10__menu" popover="auto" id="pop-10-menu" role="menu" aria-label="Actions">
        <button class="pop-10__item" type="button" role="menuitem">Lock period</button>
        <button class="pop-10__item" type="button" role="menuitem">Recalculate accruals</button>
        <button class="pop-10__item pop-10__item--sub" type="button" role="menuitem" popovertarget="pop-10-sub">
          Export
          <svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.9" aria-hidden="true"><path d="M6 4l4 4-4 4"/></svg>
        </button>
        <button class="pop-10__item" type="button" role="menuitem">Invite auditor</button>

        <div class="pop-10__sub" popover="auto" id="pop-10-sub" role="menu" aria-label="Export format">
          <button class="pop-10__item" type="button" role="menuitem">CSV · one row per entry</button>
          <button class="pop-10__item" type="button" role="menuitem">XLSX · grouped by account</button>
          <button class="pop-10__item" type="button" role="menuitem">PDF · signed statement</button>
          <p class="pop-10__note">Both levels open. Escape closes this one first.</p>
        </div>
      </div>
    </div>
  </div>
</div>
.pop-10 {
  width: 100%;
  min-height: 100vh;
  display: block;
  box-sizing: border-box;
  --bg: #201d1a;
  --s1: #2a2622;
  --s2: #332e28;
  --s3: #3d372f;
  --ink: #f4efe6;
  --muted: #a89c8b;
  --rule: #443d34;
  --amber: #e8a33d;
  --font-ui: ui-sans-serif, system-ui, "Segoe UI", sans-serif;
  background: var(--bg);
  color: var(--ink);
  font-family: var(--font-ui);
  padding: 40px 18px;
}

.pop-10__stage {
  display: grid;
  place-items: center;
  max-inline-size: 100%;
  min-height: calc(100vh - 80px);
}

.pop-10__app {
  inline-size: 100%;
  max-inline-size: 30rem;
  min-inline-size: 0;
  background: var(--s1);
  border: 1px solid var(--rule);
  border-radius: 4px;
  padding: 16px;
}

.pop-10__bar {
  display: flex;
  flex-wrap: wrap;
  gap: 12px;
  align-items: center;
  justify-content: space-between;
}

.pop-10__title {
  font-size: 13.5px;
  font-weight: 650;
  letter-spacing: -0.01em;
  min-inline-size: 0;
}

.pop-10__menubtn {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  min-height: 44px;
  padding: 0 14px;
  font: 650 12.5px/1 var(--font-ui);
  color: var(--bg);
  background: var(--amber);
  border: 0;
  border-radius: 4px;
  cursor: pointer;
  anchor-name: --pop-10-anchor;
}

.pop-10__menubtn:focus-visible {
  outline: 3px solid var(--ink);
  outline-offset: 2px;
}

.pop-10__rulecopy {
  margin: 14px 0 0;
  font-size: 12.5px;
  line-height: 1.65;
  color: var(--muted);
  text-wrap: pretty;
}

.pop-10__menu,
.pop-10__sub {
  position: fixed;
  margin: 0;
  inline-size: min(15rem, calc(100vw - 28px));
  max-inline-size: calc(100vw - 28px);
  padding: 5px;
  color: var(--ink);
  border: 1px solid var(--rule);
  border-radius: 4px;
  display: grid;
  gap: 1px;
}

.pop-10__menu {
  background: var(--s2);
}

.pop-10__sub {
  background: var(--s3);
}

@supports (anchor-name: --a) {
  .pop-10__menu {
    position-anchor: --pop-10-anchor;
    position-area: block-end span-inline-start;
    inset: auto;
    margin-block-start: 8px;
    position-try-fallbacks: flip-block;
  }

  .pop-10__sub {
    position-anchor: --pop-10-subanchor;
    position-area: inline-end block-start;
    inset: auto;
    margin-inline-start: 8px;
    position-try-fallbacks: flip-inline, flip-block;
  }
}

.pop-10__item {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  inline-size: 100%;
  min-height: 44px;
  padding: 0 11px;
  font: 500 13px/1.3 var(--font-ui);
  text-align: start;
  color: var(--ink);
  background: transparent;
  border: 0;
  border-radius: 3px;
  cursor: pointer;
  min-inline-size: 0;
}

.pop-10__item:hover {
  background: var(--s3);
}

.pop-10__sub .pop-10__item:hover {
  background: var(--rule);
}

.pop-10__item:focus-visible {
  outline: 2px solid var(--amber);
  outline-offset: -2px;
}

.pop-10__item--sub {
  anchor-name: --pop-10-subanchor;
  color: var(--amber);
}

.pop-10__note {
  margin: 6px 5px 2px;
  font-size: 11px;
  line-height: 1.5;
  color: var(--muted);
}

@media (prefers-color-scheme: light) {
  .pop-10 {
    --bg: #f4f1ec;
    --s1: #ffffff;
    --s2: #ffffff;
    --s3: #f2eee7;
    --ink: #241f19;
    --muted: #6f6355;
    --rule: #ded7cc;
    --amber: #8a5a10;
  }

  .pop-10__menubtn {
    color: #ffffff;
  }

  .pop-10__menu,
    .pop-10__sub {
    box-shadow: 0 16px 34px -22px rgba(36, 31, 25, 0.45);
  }

  .pop-10__item:hover {
    background: var(--s3);
  }
}

[data-theme="dark"] .pop-10 {
  --bg: #201d1a;
  --s1: #2a2622;
  --s2: #332e28;
  --s3: #3d372f;
  --ink: #f4efe6;
  --muted: #a89c8b;
  --rule: #443d34;
  --amber: #e8a33d;
}

[data-theme="dark"] .pop-10 .pop-10__menubtn {
  color: var(--bg);
}
Paste this into ChatGPT, Claude, Cursor, or any coding assistant. The block below is pre-framed with everything the AI needs to integrate this demo into your project — markup, styles, scoping notes, and the source URL. Hit Copy and paste straight into your chat.
Here's a working CSS Popover from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Submenus and Nested Popovers
Source: https://codefronts.com/snippets/css-popovers/submenus-and-nested-popovers/

Two auto popovers can be open at once if the browser considers one nested inside the other, and cannot if it does not. Nesting is established by DOM containment or by the popovertarget relationship, and getting it wrong makes the parent menu slam shut the instant the submenu opens. This two-level menu shows the working arrangement and names the rule.
## HTML
```html
<div class="pop-10">
  <div class="pop-10__stage">
    <div class="pop-10__app">
      <div class="pop-10__bar">
        <span class="pop-10__title">Ledger · Q3 close</span>
        <button class="pop-10__menubtn" type="button" popovertarget="pop-10-menu">
          Actions
          <svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.9" aria-hidden="true"><path d="M4 6.5 8 10.5 12 6.5"/></svg>
        </button>
      </div>
      <p class="pop-10__rulecopy">Nesting is DOM containment or a popovertarget relationship. Both levels stay open only because the submenu lives inside the menu.</p>

      <div class="pop-10__menu" popover="auto" id="pop-10-menu" role="menu" aria-label="Actions">
        <button class="pop-10__item" type="button" role="menuitem">Lock period</button>
        <button class="pop-10__item" type="button" role="menuitem">Recalculate accruals</button>
        <button class="pop-10__item pop-10__item--sub" type="button" role="menuitem" popovertarget="pop-10-sub">
          Export
          <svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.9" aria-hidden="true"><path d="M6 4l4 4-4 4"/></svg>
        </button>
        <button class="pop-10__item" type="button" role="menuitem">Invite auditor</button>

        <div class="pop-10__sub" popover="auto" id="pop-10-sub" role="menu" aria-label="Export format">
          <button class="pop-10__item" type="button" role="menuitem">CSV · one row per entry</button>
          <button class="pop-10__item" type="button" role="menuitem">XLSX · grouped by account</button>
          <button class="pop-10__item" type="button" role="menuitem">PDF · signed statement</button>
          <p class="pop-10__note">Both levels open. Escape closes this one first.</p>
        </div>
      </div>
    </div>
  </div>
</div>
```
## CSS
```css
.pop-10 {
  width: 100%;
  min-height: 100vh;
  display: block;
  box-sizing: border-box;
  --bg: #201d1a;
  --s1: #2a2622;
  --s2: #332e28;
  --s3: #3d372f;
  --ink: #f4efe6;
  --muted: #a89c8b;
  --rule: #443d34;
  --amber: #e8a33d;
  --font-ui: ui-sans-serif, system-ui, "Segoe UI", sans-serif;
  background: var(--bg);
  color: var(--ink);
  font-family: var(--font-ui);
  padding: 40px 18px;
}

.pop-10__stage {
  display: grid;
  place-items: center;
  max-inline-size: 100%;
  min-height: calc(100vh - 80px);
}

.pop-10__app {
  inline-size: 100%;
  max-inline-size: 30rem;
  min-inline-size: 0;
  background: var(--s1);
  border: 1px solid var(--rule);
  border-radius: 4px;
  padding: 16px;
}

.pop-10__bar {
  display: flex;
  flex-wrap: wrap;
  gap: 12px;
  align-items: center;
  justify-content: space-between;
}

.pop-10__title {
  font-size: 13.5px;
  font-weight: 650;
  letter-spacing: -0.01em;
  min-inline-size: 0;
}

.pop-10__menubtn {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  min-height: 44px;
  padding: 0 14px;
  font: 650 12.5px/1 var(--font-ui);
  color: var(--bg);
  background: var(--amber);
  border: 0;
  border-radius: 4px;
  cursor: pointer;
  anchor-name: --pop-10-anchor;
}

.pop-10__menubtn:focus-visible {
  outline: 3px solid var(--ink);
  outline-offset: 2px;
}

.pop-10__rulecopy {
  margin: 14px 0 0;
  font-size: 12.5px;
  line-height: 1.65;
  color: var(--muted);
  text-wrap: pretty;
}

.pop-10__menu,
.pop-10__sub {
  position: fixed;
  margin: 0;
  inline-size: min(15rem, calc(100vw - 28px));
  max-inline-size: calc(100vw - 28px);
  padding: 5px;
  color: var(--ink);
  border: 1px solid var(--rule);
  border-radius: 4px;
  display: grid;
  gap: 1px;
}

.pop-10__menu {
  background: var(--s2);
}

.pop-10__sub {
  background: var(--s3);
}

@supports (anchor-name: --a) {
  .pop-10__menu {
    position-anchor: --pop-10-anchor;
    position-area: block-end span-inline-start;
    inset: auto;
    margin-block-start: 8px;
    position-try-fallbacks: flip-block;
  }

  .pop-10__sub {
    position-anchor: --pop-10-subanchor;
    position-area: inline-end block-start;
    inset: auto;
    margin-inline-start: 8px;
    position-try-fallbacks: flip-inline, flip-block;
  }
}

.pop-10__item {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  inline-size: 100%;
  min-height: 44px;
  padding: 0 11px;
  font: 500 13px/1.3 var(--font-ui);
  text-align: start;
  color: var(--ink);
  background: transparent;
  border: 0;
  border-radius: 3px;
  cursor: pointer;
  min-inline-size: 0;
}

.pop-10__item:hover {
  background: var(--s3);
}

.pop-10__sub .pop-10__item:hover {
  background: var(--rule);
}

.pop-10__item:focus-visible {
  outline: 2px solid var(--amber);
  outline-offset: -2px;
}

.pop-10__item--sub {
  anchor-name: --pop-10-subanchor;
  color: var(--amber);
}

.pop-10__note {
  margin: 6px 5px 2px;
  font-size: 11px;
  line-height: 1.5;
  color: var(--muted);
}

@media (prefers-color-scheme: light) {
  .pop-10 {
    --bg: #f4f1ec;
    --s1: #ffffff;
    --s2: #ffffff;
    --s3: #f2eee7;
    --ink: #241f19;
    --muted: #6f6355;
    --rule: #ded7cc;
    --amber: #8a5a10;
  }

  .pop-10__menubtn {
    color: #ffffff;
  }

  .pop-10__menu,
    .pop-10__sub {
    box-shadow: 0 16px 34px -22px rgba(36, 31, 25, 0.45);
  }

  .pop-10__item:hover {
    background: var(--s3);
  }
}

[data-theme="dark"] .pop-10 {
  --bg: #201d1a;
  --s1: #2a2622;
  --s2: #332e28;
  --s3: #3d372f;
  --ink: #f4efe6;
  --muted: #a89c8b;
  --rule: #443d34;
  --amber: #e8a33d;
}

[data-theme="dark"] .pop-10 .pop-10__menubtn {
  color: var(--bg);
}
```

How this works

The rule in one sentence: an auto popover closes every other auto popover that is not in its ancestor chain. That chain is built two ways. Either the child popover element sits inside the parent popover in the DOM, or the button that opens the child lives inside the parent and points at it with popovertarget. Satisfy one of those and both stay open; satisfy neither and the parent is dismissed the moment the child opens.

This is the single most confusing part of the API in practice. A submenu whose panel was hoisted to the end of the body — a habit from the positioning-library era, where it avoided overflow clipping — is no longer a DOM descendant of its parent menu, so it looks correctly nested on screen and behaves like a sibling. The top layer makes the hoisting unnecessary: keep the child inside the parent and the clipping problem never arises.

Dismissal walks the chain in order. Escape closes the innermost popover only, so a two-level menu takes two presses, and a click outside both closes the whole chain at once. That is the correct behaviour for a menu and it comes free — as long as the chain is real.

Anchor each level to its own trigger. The submenu is anchored to the menu item that opens it, not to the parent panel, so it tracks that row and can flip independently. Give each level its own anchor-name; reusing one name across levels makes the submenu jump to the wrong row when the menu scrolls.

Make it yours

  • Move the submenu element outside the parent popover to see the parent slam shut — the failure case, in one edit.
  • Change the submenu's position-area to inline-start for a menu that opens leftward.
  • Add a third level using the same containment rule.
  • Swap the amber accent by editing --amber on the root.
  • Give the submenu min-inline-size: anchor-size(width) to match its parent row.
  • Remove the surface step between levels for a single flat tone.

Gotchas — read before shipping

  • Hoisting the submenu to the end of the body breaks the ancestor chain and the parent closes as soon as the child opens.
  • Using popover="manual" for the submenu hides the symptom but loses light dismiss for the whole chain.
  • Reusing one anchor-name for several menu rows anchors the submenu to whichever row the browser resolves last.
  • Expecting one Escape press to close the whole chain — it closes the innermost popover only.

Browser support

ChromeSafariFirefoxEdge
125+26+131+125+

Nesting behaviour comes with the popover attribute (Chrome 114, Safari 17, Firefox 125) and is worth relying on. The submenu's tethering needs anchor positioning (Chrome 125, Safari 26, Firefox 131), which sets the floor above. Without anchoring the nesting still works exactly as described; the submenu simply appears centred rather than beside its row.

Techniques used in this demo

Search CodeFronts

Loading…