20 CSS Popovers01 / 20

Pure CSSMIT licensed

Basic Popover Show and Hide

The reference build the rest of the collection reuses: one button carrying popovertarget, one element carrying the popover attribute, and no script at all. The browser promotes the panel to the top layer, closes it when you click outside or press Escape, and returns focus to the button — behaviour that used to take a few hundred lines of listener plumbing and a z-index audit.

Published

Live Demo
Try it

The code

<div class="pop-01">
  <div class="pop-01__stage">
    <div class="pop-01__doc">
      <header class="pop-01__bar">
        <div class="pop-01__brand">
          <span class="pop-01__mark" aria-hidden="true">
            <svg viewBox="0 0 20 20" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M4 5h12M4 10h12M4 15h7"/></svg>
          </span>
          <span class="pop-01__name">Atlas Docs</span>
        </div>
        <nav class="pop-01__nav" aria-label="Documentation">
          <span class="pop-01__navitem">Guides</span>
          <span class="pop-01__navitem">Reference</span>
          <button class="pop-01__trigger" type="button" popovertarget="pop-01-panel">
            Release notes
            <svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path d="M4 6.5 8 10.5 12 6.5"/></svg>
          </button>
        </nav>
      </header>

      <article class="pop-01__body">
        <p class="pop-01__eyebrow">Platform</p>
        <h2 class="pop-01__h">Floating panels, no positioning library</h2>
        <p class="pop-01__p">The button above is an invoker: it carries <code class="pop-01__code">popovertarget</code> and points at the id of an element with the <code class="pop-01__code">popover</code> attribute. Open it, then click anywhere on this paragraph — the panel closes itself. Press Escape and it closes too, and focus lands back on the button.</p>
        <p class="pop-01__p">This section has <code class="pop-01__code">overflow: hidden</code> and a stacking context, and the panel still paints over the header. That is the top layer, not a large z-index.</p>
      </article>

      <div class="pop-01__panel" popover="auto" id="pop-01-panel" role="group" aria-labelledby="pop-01-title">
        <div class="pop-01__phead">
          <h3 class="pop-01__ptitle" id="pop-01-title">Release notes</h3>
          <button class="pop-01__close" type="button" popovertarget="pop-01-panel" popovertargetaction="hide" aria-label="Close release notes">
            <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.9" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8"/></svg>
          </button>
        </div>
        <ul class="pop-01__list">
          <li class="pop-01__item"><span class="pop-01__ver">4.2.0</span> Query cache keys are now stable across workers.</li>
          <li class="pop-01__item"><span class="pop-01__ver">4.1.6</span> Fixed a regression in the CSV importer on Windows line endings.</li>
          <li class="pop-01__item"><span class="pop-01__ver">4.1.5</span> Audit log entries keep the actor when a token is rotated.</li>
        </ul>
        <p class="pop-01__note">Escape closes this. So does a click anywhere outside it. Neither needed a line of JavaScript.</p>
      </div>
    </div>
  </div>
</div>
.pop-01 {
  width: 100%;
  min-height: 100vh;
  display: block;
  box-sizing: border-box;
  --paper: #f7f5f0;
  --surface: #ffffff;
  --ink: #1b1a17;
  --muted: #736e63;
  --rule: #e0dcd1;
  --accent: #1b1a17;
  --font-ui: ui-sans-serif, system-ui, "Segoe UI", sans-serif;
  --font-mono: ui-monospace, "SFMono-Regular", Menlo, monospace;
  background: var(--paper);
  color: var(--ink);
  font-family: var(--font-ui);
  padding: 36px 18px;
}

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

.pop-01__doc {
  inline-size: 100%;
  max-inline-size: 40rem;
  min-inline-size: 0;
  background: var(--surface);
  border: 1px solid var(--rule);
  border-radius: 6px;
  overflow: hidden;
  isolation: isolate;
}

.pop-01__bar {
  display: flex;
  flex-wrap: wrap;
  gap: 12px;
  align-items: center;
  justify-content: space-between;
  padding: 12px 16px;
  border-bottom: 1px solid var(--rule);
}

.pop-01__brand {
  display: flex;
  align-items: center;
  gap: 9px;
  min-inline-size: 0;
}

.pop-01__mark {
  display: inline-flex;
  color: var(--muted);
}

.pop-01__name {
  font-size: 13px;
  font-weight: 650;
  letter-spacing: -0.01em;
}

.pop-01__nav {
  display: flex;
  align-items: center;
  gap: 6px;
  flex-wrap: wrap;
  min-inline-size: 0;
}

.pop-01__navitem {
  font-size: 12.5px;
  color: var(--muted);
  padding: 0 8px;
}

.pop-01__trigger {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  min-height: 44px;
  padding: 0 14px;
  font: 650 12.5px/1 var(--font-ui);
  color: var(--surface);
  background: var(--accent);
  border: 1px solid var(--accent);
  border-radius: 5px;
  cursor: pointer;
}

.pop-01__trigger:focus-visible {
  outline: 3px solid var(--accent);
  outline-offset: 2px;
}

.pop-01__body {
  padding: 26px 20px 30px;
}

.pop-01__eyebrow {
  margin: 0 0 10px;
  font: 700 10px/1 var(--font-ui);
  letter-spacing: 0.2em;
  text-transform: uppercase;
  color: var(--muted);
}

.pop-01__h {
  margin: 0 0 14px;
  font: 600 clamp(21px, 4.6vw, 28px)/1.2 var(--font-ui);
  letter-spacing: -0.02em;
}

.pop-01__p {
  margin: 0 0 12px;
  font-size: 14.5px;
  line-height: 1.7;
  color: var(--muted);
  text-wrap: pretty;
}

.pop-01__code {
  font: 0.88em/1 var(--font-mono);
  color: var(--ink);
  background: rgba(115, 110, 99, 0.14);
  padding: 0.15em 0.35em;
  border-radius: 3px;
}

.pop-01__panel {
  position: fixed;
  margin: 0;
  inline-size: min(30rem, calc(100vw - 32px));
  max-inline-size: calc(100vw - 32px);
  padding: 18px;
  color: var(--ink);
  background: var(--surface);
  border: 1px solid var(--rule);
  border-radius: 6px;
  box-shadow: 0 18px 44px -22px rgba(27, 26, 23, 0.5);
}

.pop-01__phead {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  margin-bottom: 12px;
}

.pop-01__ptitle {
  margin: 0;
  font: 650 13px/1 var(--font-ui);
  letter-spacing: 0.02em;
}

.pop-01__close {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 44px;
  min-height: 44px;
  margin: -12px -10px -12px 0;
  color: var(--muted);
  background: none;
  border: 0;
  border-radius: 5px;
  cursor: pointer;
}

.pop-01__close:hover {
  color: var(--ink);
}

.pop-01__close:focus-visible {
  outline: 3px solid var(--accent);
  outline-offset: 1px;
}

.pop-01__list {
  margin: 0;
  padding: 0;
  list-style: none;
  display: grid;
  gap: 1px;
  background: var(--rule);
  border: 1px solid var(--rule);
  border-radius: 5px;
  overflow: hidden;
}

.pop-01__item {
  display: flex;
  gap: 12px;
  align-items: baseline;
  padding: 11px 12px;
  background: var(--surface);
  font-size: 13px;
  line-height: 1.5;
  min-inline-size: 0;
}

.pop-01__ver {
  flex: none;
  font: 650 11px/1.4 var(--font-mono);
  color: var(--muted);
}

.pop-01__note {
  margin: 12px 0 0;
  font-size: 12px;
  line-height: 1.6;
  color: var(--muted);
}

@media (prefers-color-scheme: dark) {
  .pop-01 {
    --paper: #14140f;
    --surface: #1d1c17;
    --ink: #f3f0e6;
    --muted: #a09a8b;
    --rule: #322f26;
    --accent: #f3f0e6;
  }
}

[data-theme="dark"] .pop-01 {
  --paper: #14140f;
  --surface: #1d1c17;
  --ink: #f3f0e6;
  --muted: #a09a8b;
  --rule: #322f26;
  --accent: #f3f0e6;
}
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: Basic Popover Show and Hide
Source: https://codefronts.com/snippets/css-popovers/basic-popover-show-and-hide/

The reference build the rest of the collection reuses: one button carrying popovertarget, one element carrying the popover attribute, and no script at all. The browser promotes the panel to the top layer, closes it when you click outside or press Escape, and returns focus to the button — behaviour that used to take a few hundred lines of listener plumbing and a z-index audit.
## HTML
```html
<div class="pop-01">
  <div class="pop-01__stage">
    <div class="pop-01__doc">
      <header class="pop-01__bar">
        <div class="pop-01__brand">
          <span class="pop-01__mark" aria-hidden="true">
            <svg viewBox="0 0 20 20" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M4 5h12M4 10h12M4 15h7"/></svg>
          </span>
          <span class="pop-01__name">Atlas Docs</span>
        </div>
        <nav class="pop-01__nav" aria-label="Documentation">
          <span class="pop-01__navitem">Guides</span>
          <span class="pop-01__navitem">Reference</span>
          <button class="pop-01__trigger" type="button" popovertarget="pop-01-panel">
            Release notes
            <svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path d="M4 6.5 8 10.5 12 6.5"/></svg>
          </button>
        </nav>
      </header>

      <article class="pop-01__body">
        <p class="pop-01__eyebrow">Platform</p>
        <h2 class="pop-01__h">Floating panels, no positioning library</h2>
        <p class="pop-01__p">The button above is an invoker: it carries <code class="pop-01__code">popovertarget</code> and points at the id of an element with the <code class="pop-01__code">popover</code> attribute. Open it, then click anywhere on this paragraph — the panel closes itself. Press Escape and it closes too, and focus lands back on the button.</p>
        <p class="pop-01__p">This section has <code class="pop-01__code">overflow: hidden</code> and a stacking context, and the panel still paints over the header. That is the top layer, not a large z-index.</p>
      </article>

      <div class="pop-01__panel" popover="auto" id="pop-01-panel" role="group" aria-labelledby="pop-01-title">
        <div class="pop-01__phead">
          <h3 class="pop-01__ptitle" id="pop-01-title">Release notes</h3>
          <button class="pop-01__close" type="button" popovertarget="pop-01-panel" popovertargetaction="hide" aria-label="Close release notes">
            <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.9" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8"/></svg>
          </button>
        </div>
        <ul class="pop-01__list">
          <li class="pop-01__item"><span class="pop-01__ver">4.2.0</span> Query cache keys are now stable across workers.</li>
          <li class="pop-01__item"><span class="pop-01__ver">4.1.6</span> Fixed a regression in the CSV importer on Windows line endings.</li>
          <li class="pop-01__item"><span class="pop-01__ver">4.1.5</span> Audit log entries keep the actor when a token is rotated.</li>
        </ul>
        <p class="pop-01__note">Escape closes this. So does a click anywhere outside it. Neither needed a line of JavaScript.</p>
      </div>
    </div>
  </div>
</div>
```
## CSS
```css
.pop-01 {
  width: 100%;
  min-height: 100vh;
  display: block;
  box-sizing: border-box;
  --paper: #f7f5f0;
  --surface: #ffffff;
  --ink: #1b1a17;
  --muted: #736e63;
  --rule: #e0dcd1;
  --accent: #1b1a17;
  --font-ui: ui-sans-serif, system-ui, "Segoe UI", sans-serif;
  --font-mono: ui-monospace, "SFMono-Regular", Menlo, monospace;
  background: var(--paper);
  color: var(--ink);
  font-family: var(--font-ui);
  padding: 36px 18px;
}

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

.pop-01__doc {
  inline-size: 100%;
  max-inline-size: 40rem;
  min-inline-size: 0;
  background: var(--surface);
  border: 1px solid var(--rule);
  border-radius: 6px;
  overflow: hidden;
  isolation: isolate;
}

.pop-01__bar {
  display: flex;
  flex-wrap: wrap;
  gap: 12px;
  align-items: center;
  justify-content: space-between;
  padding: 12px 16px;
  border-bottom: 1px solid var(--rule);
}

.pop-01__brand {
  display: flex;
  align-items: center;
  gap: 9px;
  min-inline-size: 0;
}

.pop-01__mark {
  display: inline-flex;
  color: var(--muted);
}

.pop-01__name {
  font-size: 13px;
  font-weight: 650;
  letter-spacing: -0.01em;
}

.pop-01__nav {
  display: flex;
  align-items: center;
  gap: 6px;
  flex-wrap: wrap;
  min-inline-size: 0;
}

.pop-01__navitem {
  font-size: 12.5px;
  color: var(--muted);
  padding: 0 8px;
}

.pop-01__trigger {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  min-height: 44px;
  padding: 0 14px;
  font: 650 12.5px/1 var(--font-ui);
  color: var(--surface);
  background: var(--accent);
  border: 1px solid var(--accent);
  border-radius: 5px;
  cursor: pointer;
}

.pop-01__trigger:focus-visible {
  outline: 3px solid var(--accent);
  outline-offset: 2px;
}

.pop-01__body {
  padding: 26px 20px 30px;
}

.pop-01__eyebrow {
  margin: 0 0 10px;
  font: 700 10px/1 var(--font-ui);
  letter-spacing: 0.2em;
  text-transform: uppercase;
  color: var(--muted);
}

.pop-01__h {
  margin: 0 0 14px;
  font: 600 clamp(21px, 4.6vw, 28px)/1.2 var(--font-ui);
  letter-spacing: -0.02em;
}

.pop-01__p {
  margin: 0 0 12px;
  font-size: 14.5px;
  line-height: 1.7;
  color: var(--muted);
  text-wrap: pretty;
}

.pop-01__code {
  font: 0.88em/1 var(--font-mono);
  color: var(--ink);
  background: rgba(115, 110, 99, 0.14);
  padding: 0.15em 0.35em;
  border-radius: 3px;
}

.pop-01__panel {
  position: fixed;
  margin: 0;
  inline-size: min(30rem, calc(100vw - 32px));
  max-inline-size: calc(100vw - 32px);
  padding: 18px;
  color: var(--ink);
  background: var(--surface);
  border: 1px solid var(--rule);
  border-radius: 6px;
  box-shadow: 0 18px 44px -22px rgba(27, 26, 23, 0.5);
}

.pop-01__phead {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  margin-bottom: 12px;
}

.pop-01__ptitle {
  margin: 0;
  font: 650 13px/1 var(--font-ui);
  letter-spacing: 0.02em;
}

.pop-01__close {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 44px;
  min-height: 44px;
  margin: -12px -10px -12px 0;
  color: var(--muted);
  background: none;
  border: 0;
  border-radius: 5px;
  cursor: pointer;
}

.pop-01__close:hover {
  color: var(--ink);
}

.pop-01__close:focus-visible {
  outline: 3px solid var(--accent);
  outline-offset: 1px;
}

.pop-01__list {
  margin: 0;
  padding: 0;
  list-style: none;
  display: grid;
  gap: 1px;
  background: var(--rule);
  border: 1px solid var(--rule);
  border-radius: 5px;
  overflow: hidden;
}

.pop-01__item {
  display: flex;
  gap: 12px;
  align-items: baseline;
  padding: 11px 12px;
  background: var(--surface);
  font-size: 13px;
  line-height: 1.5;
  min-inline-size: 0;
}

.pop-01__ver {
  flex: none;
  font: 650 11px/1.4 var(--font-mono);
  color: var(--muted);
}

.pop-01__note {
  margin: 12px 0 0;
  font-size: 12px;
  line-height: 1.6;
  color: var(--muted);
}

@media (prefers-color-scheme: dark) {
  .pop-01 {
    --paper: #14140f;
    --surface: #1d1c17;
    --ink: #f3f0e6;
    --muted: #a09a8b;
    --rule: #322f26;
    --accent: #f3f0e6;
  }
}

[data-theme="dark"] .pop-01 {
  --paper: #14140f;
  --surface: #1d1c17;
  --ink: #f3f0e6;
  --muted: #a09a8b;
  --rule: #322f26;
  --accent: #f3f0e6;
}
```

How this works

Two attributes replace an entire behaviour layer. Put popover on the panel and popovertarget on a <button> whose value is the panel's id. That is the whole contract. There is no listener, no state variable, no outside-click handler, and no library — the user agent wires the button to the panel and manages the open state for you.

What you get for free is the part people underestimate. An open popover is promoted to the top layer, so it paints above every other element on the page regardless of z-index, and it is not clipped by an ancestor with overflow: hidden. Clicking anywhere outside closes it, so does Escape, and focus returns to the invoker afterwards. Those four behaviours are the reason to reach for the attribute instead of a div.

The UA stylesheet is the first thing to override. A popover ships with position: fixed, inset: 0, margin: auto, width: fit-content and a default border and padding, which is why an unstyled popover lands dead centre in the viewport. Reset the box explicitly — here the panel keeps the centred placement on purpose, and demo 03 tethers it to its button instead.

The trap is styling the closed state. A popover is display: none until it opens, so a rule written against the element alone applies to both states and a transition from the closed state has nothing to interpolate from. Target the open state with the :popover-open pseudo-class, and see demo 04 for the animation recipe that makes the closed state addressable.

Make it yours

  • Swap popovertarget for popovertargetaction="show" on a second button to make it open-only.
  • Change --surface and --rule on the root to retint the panel.
  • Give the panel a max-inline-size in ch units to control the measure of its text.
  • Set border-radius to 0 for the flat editorial treatment, or 16px for a softer card.
  • Add popover="manual" to opt out of light dismiss — demo 02 covers the difference.
  • Move the close control out of the panel and rely on Escape alone if the panel is a hint rather than a menu.

Gotchas — read before shipping

  • Pointing popovertarget at an element without the popover attribute does nothing at all, silently — the invoker simply is not an invoker.
  • A hardcoded aria-expanded on the invoker goes stale immediately with no script; modern browsers compute the expanded state from the popover itself, so leave it off rather than lying about it.
  • The panel is display: none when closed, so any transition or animation authored on the element alone never runs — you need @starting-style and allow-discrete.
  • Using a <div> with a click handler instead of a <button> for the invoker loses keyboard activation and the implicit expanded state.

Browser support

ChromeSafariFirefoxEdge
114+17+125+114+

The popover attribute itself is the only feature this demo needs, and it is supported everywhere current: Chrome 114, Safari 17, Firefox 125. No anchor positioning is involved, so support here is far wider than in the anchored demos. In an older engine the panel stays in normal flow and the button does nothing, which is why demo 16 pairs the attribute with a feature test.

Techniques used in this demo

Search CodeFronts

Loading…