Pure TailwindMIT licensed

Tailwind Popover Show and Hide with the Popover API

The reference build the collection reuses: one button with popovertarget, one panel with popover, zero script. In v4 the open state is just the open: variant — it already matches :popover-open — so the panel's closed, open and entry states live in one class list, and the trigger's chevron flips via group-has-[#tpop-01-panel:popover-open]/root:rotate-180.

Live Demo
Plain CSSTailwind v4
Try it

Untitled UI Pro: 5,000+ React + Tailwind CSS v4 components · from $349 one-time

Untitled UI React · Affiliate link

Get Untitled UI Pro

The code

<section class="tpop-01 transition-[--tpop01-d] duration-300 ease-in-out motion-reduce:transition-none dark:[--tpop01-d:1] [[data-theme=dark]_&]:[--tpop01-d:1] [[data-theme=light]_&]:[--tpop01-d:0] group/root w-full min-h-svh bg-tpop01-paper bg-[radial-gradient(90%_60%_at_50%_0%,var(--color-tpop01-glow),transparent_70%)] px-4.5 py-10 font-tpop01-sans text-tpop01-ink antialiased" aria-label="Tailwind popover show and hide demo">
  <div class="grid min-h-[calc(100svh-80px)] max-w-full place-items-center">
    <div class="isolate w-full min-w-0 max-w-[40rem] overflow-hidden rounded-2xl border border-tpop01-rule bg-tpop01-surface shadow-[0_1px_2px_oklch(0.2_0.02_70/0.06),0_24px_48px_-32px_oklch(0.2_0.02_70/0.4)]">
      <header class="flex flex-wrap items-center justify-between gap-3 border-b border-tpop01-rule px-4 py-2">
        <div class="flex min-w-0 items-center gap-2.5">
          <span aria-hidden="true" class="grid size-7 place-items-center rounded-lg bg-tpop01-ink text-tpop01-surface">
            <svg viewBox="0 0 20 20" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 5h12M4 10h12M4 15h7"/></svg>
          </span>
          <span class="text-[13.5px] font-semibold tracking-[-0.01em]">Atlas Docs</span>
        </div>
        <nav class="flex min-w-0 flex-wrap items-center gap-1" aria-label="Documentation">
          <span class="px-2.5 text-[13px] text-tpop01-mut">Guides</span>
          <span class="px-2.5 text-[13px] text-tpop01-mut">Reference</span>
          <button id="tpop-01-trigger" type="button" popovertarget="tpop-01-panel" class="inline-flex min-h-11 cursor-pointer items-center gap-2 rounded-xl bg-tpop01-ink px-3.5 text-[13px] font-semibold text-tpop01-surface transition-colors hover:bg-tpop01-ink/85 focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-tpop01-acc group-has-[#tpop-01-panel:popover-open]/root:bg-tpop01-ink/80">
            Release notes
            <span class="rounded-full bg-tpop01-acc px-1.5 py-0.5 font-tpop01-mono text-[10px] leading-none text-tpop01-surface">3</span>
            <svg class="transition-transform duration-200 motion-reduce:transition-none group-has-[#tpop-01-panel:popover-open]/root:rotate-180" 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="px-5 pt-7 pb-8">
        <p class="mb-2.5 text-[10.5px] font-bold uppercase tracking-[0.2em] text-tpop01-acc">Platform</p>
        <h2 class="mb-3.5 text-[clamp(22px,4.6vw,30px)] font-semibold leading-[1.15] tracking-[-0.025em] text-balance">Floating panels, no positioning library</h2>
        <p class="mb-3 text-[15px] leading-[1.7] text-tpop01-mut text-pretty">The button above is an invoker: it carries <code class="rounded bg-tpop01-ink/6 px-1.5 py-0.5 font-tpop01-mono text-[0.86em] text-tpop01-ink">popovertarget</code> and points at an element with the <code class="rounded bg-tpop01-ink/6 px-1.5 py-0.5 font-tpop01-mono text-[0.86em] text-tpop01-ink">popover</code> attribute. Open it, then click anywhere on this paragraph — the panel closes itself.</p>
        <p class="text-[15px] leading-[1.7] text-tpop01-mut text-pretty">This card has <code class="rounded bg-tpop01-ink/6 px-1.5 py-0.5 font-tpop01-mono text-[0.86em] text-tpop01-ink">overflow-hidden</code> and its own stacking context, and the panel still paints over it. That is the top layer, not a large z-index.</p>
      </article>
      <div popover="auto" id="tpop-01-panel" role="group" aria-labelledby="tpop-01-title" class="fixed m-auto w-[min(30rem,calc(100vw-32px))] rounded-2xl border border-tpop01-rule bg-tpop01-surface p-4.5 text-tpop01-ink opacity-0 scale-[0.98] shadow-[0_32px_64px_-24px_oklch(0.2_0.02_70/0.45)] transition-[opacity,scale,display,overlay] duration-200 ease-out transition-discrete open:opacity-100 open:scale-100 starting:open:opacity-0 starting:open:scale-[0.98] motion-reduce:transition-none">
        <div class="mb-3 flex items-center justify-between gap-2.5">
          <h3 id="tpop-01-title" class="text-[13.5px] font-semibold">Release notes</h3>
          <button type="button" popovertarget="tpop-01-panel" popovertargetaction="hide" aria-label="Close release notes" class="-my-3 -mr-2.5 inline-flex size-11 cursor-pointer items-center justify-center rounded-lg text-tpop01-mut transition-colors hover:bg-tpop01-ink/6 hover:text-tpop01-ink focus-visible:outline-3 focus-visible:outline-offset-1 focus-visible:outline-tpop01-acc">
            <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="grid gap-px overflow-hidden rounded-xl border border-tpop01-rule bg-tpop01-rule">
          <li class="flex min-w-0 items-baseline gap-3 bg-tpop01-surface px-3 py-3 text-[13.5px] leading-normal"><span class="flex-none rounded-md bg-tpop01-acc/12 px-1.5 py-0.5 font-tpop01-mono text-[11px] font-semibold text-tpop01-acc">4.2.0</span> Query cache keys are now stable across workers.</li>
          <li class="flex min-w-0 items-baseline gap-3 bg-tpop01-surface px-3 py-3 text-[13.5px] leading-normal"><span class="flex-none rounded-md bg-tpop01-ink/6 px-1.5 py-0.5 font-tpop01-mono text-[11px] font-semibold text-tpop01-mut">4.1.6</span> Fixed a regression in the CSV importer on Windows line endings.</li>
          <li class="flex min-w-0 items-baseline gap-3 bg-tpop01-surface px-3 py-3 text-[13.5px] leading-normal"><span class="flex-none rounded-md bg-tpop01-ink/6 px-1.5 py-0.5 font-tpop01-mono text-[11px] font-semibold text-tpop01-mut">4.1.5</span> Audit log entries keep the actor when a token is rotated.</li>
        </ul>
        <p class="mt-3 flex flex-wrap items-center gap-1.5 text-[12.5px] leading-relaxed text-tpop01-mut"><kbd class="rounded-md border border-tpop01-rule border-b-2 px-1.5 py-0.5 font-tpop01-mono text-[11px] text-tpop01-ink">Esc</kbd> or a click outside closes this. Neither needed a line of JavaScript.</p>
      </div>
    </div>
  </div>
</section>
@import "tailwindcss";

@theme {
  --color-tpop01-paper: #f7f5f1;
  --color-tpop01-paper: oklch(0.97 0.006 85);
  --color-tpop01-glow: #fffdfa;
  --color-tpop01-glow: oklch(0.995 0.004 85);
  --color-tpop01-surface: #ffffff;
  --color-tpop01-surface: oklch(1 0 0);
  --color-tpop01-ink: #1e1a14;
  --color-tpop01-ink: oklch(0.22 0.012 75);
  --color-tpop01-mut: #605a51;
  --color-tpop01-mut: oklch(0.47 0.016 75);
  --color-tpop01-rule: #e4e1da;
  --color-tpop01-rule: oklch(0.91 0.01 85);
  --color-tpop01-acc: #be4b13;
  --color-tpop01-acc: oklch(0.56 0.16 42);
  --font-tpop01-sans: 'Geist', system-ui, sans-serif;
  --font-tpop01-mono: 'Geist Mono', ui-monospace, monospace;
}
/* Theme dial: 0 = light, 1 = dark. Registered so the root can transition it. */

@property --tpop01-d {
  syntax: '<number>';
  inherits: true;
  initial-value: 0;
}
/* Root rule: the full-bleed contract, stated in CSS as well as in the markup's utilities, so the
   stage is sized before the browser build compiles — plus the dark palette. Each @theme colour is
   re-declared on the root as a light/dark mix of the dial, so every utility (and every open
   popover, which inherits from the root) follows it. Light value last, dark value first. */

.tpop-01 {
  width: 100%;
  min-height: 100svh;
  --color-tpop01-paper: color-mix(in oklch, oklch(0.17 0.008 75) calc(var(--tpop01-d) * 100%), oklch(0.97 0.006 85));
  --color-tpop01-glow: color-mix(in oklch, oklch(0.21 0.012 75) calc(var(--tpop01-d) * 100%), oklch(0.995 0.004 85));
  --color-tpop01-surface: color-mix(in oklch, oklch(0.205 0.01 75) calc(var(--tpop01-d) * 100%), oklch(1 0 0));
  --color-tpop01-ink: color-mix(in oklch, oklch(0.95 0.008 85) calc(var(--tpop01-d) * 100%), oklch(0.22 0.012 75));
  --color-tpop01-mut: color-mix(in oklch, oklch(0.74 0.016 80) calc(var(--tpop01-d) * 100%), oklch(0.47 0.016 75));
  --color-tpop01-rule: color-mix(in oklch, oklch(0.3 0.012 75) calc(var(--tpop01-d) * 100%), oklch(0.91 0.01 85));
  --color-tpop01-acc: color-mix(in oklch, oklch(0.72 0.14 45) calc(var(--tpop01-d) * 100%), oklch(0.56 0.16 42));
}

Setup — do this once per project

Skip this if your project already runs Tailwind v4.

1Load Tailwind
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>

Compiles in the browser — perfect for a CodePen or a quick test, not for production.

2Add the stylesheetRequiredthe @theme block is what makes the custom utilities exist
@import "tailwindcss";

@theme {
  --color-tpop01-paper: #f7f5f1;
  --color-tpop01-paper: oklch(0.97 0.006 85);
  --color-tpop01-glow: #fffdfa;
  --color-tpop01-glow: oklch(0.995 0.004 85);
  --color-tpop01-surface: #ffffff;
  --color-tpop01-surface: oklch(1 0 0);
  --color-tpop01-ink: #1e1a14;
  --color-tpop01-ink: oklch(0.22 0.012 75);
  --color-tpop01-mut: #605a51;
  --color-tpop01-mut: oklch(0.47 0.016 75);
  --color-tpop01-rule: #e4e1da;
  --color-tpop01-rule: oklch(0.91 0.01 85);
  --color-tpop01-acc: #be4b13;
  --color-tpop01-acc: oklch(0.56 0.16 42);
  --font-tpop01-sans: 'Geist', system-ui, sans-serif;
  --font-tpop01-mono: 'Geist Mono', ui-monospace, monospace;
}
/* Theme dial: 0 = light, 1 = dark. Registered so the root can transition it. */

@property --tpop01-d {
  syntax: '<number>';
  inherits: true;
  initial-value: 0;
}
/* Root rule: the full-bleed contract, stated in CSS as well as in the markup's utilities, so the
   stage is sized before the browser build compiles — plus the dark palette. Each @theme colour is
   re-declared on the root as a light/dark mix of the dial, so every utility (and every open
   popover, which inherits from the root) follows it. Light value last, dark value first. */

.tpop-01 {
  width: 100%;
  min-height: 100svh;
  --color-tpop01-paper: color-mix(in oklch, oklch(0.17 0.008 75) calc(var(--tpop01-d) * 100%), oklch(0.97 0.006 85));
  --color-tpop01-glow: color-mix(in oklch, oklch(0.21 0.012 75) calc(var(--tpop01-d) * 100%), oklch(0.995 0.004 85));
  --color-tpop01-surface: color-mix(in oklch, oklch(0.205 0.01 75) calc(var(--tpop01-d) * 100%), oklch(1 0 0));
  --color-tpop01-ink: color-mix(in oklch, oklch(0.95 0.008 85) calc(var(--tpop01-d) * 100%), oklch(0.22 0.012 75));
  --color-tpop01-mut: color-mix(in oklch, oklch(0.74 0.016 80) calc(var(--tpop01-d) * 100%), oklch(0.47 0.016 75));
  --color-tpop01-rule: color-mix(in oklch, oklch(0.3 0.012 75) calc(var(--tpop01-d) * 100%), oklch(0.91 0.01 85));
  --color-tpop01-acc: color-mix(in oklch, oklch(0.72 0.14 45) calc(var(--tpop01-d) * 100%), oklch(0.56 0.16 42));
}
3Fonts used by this demooptional — skip if you have your own type stack
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Geist:wght@400..800&family=Geist+Mono:wght@400..600&display=swap">

What changed in the port

Which parts became utilities, which stayed CSS, and why.

  • The :popover-open rule, the @starting-style block and the transition list became six utilities on the panel — open:, starting:open: and transition-discrete — so every state of the panel reads in one attribute.
  • Bug fix from the CSS original: the original set margin: 0 on a panel it described as centred, which pins a popover to the top-left corner. m-auto now genuinely centres it (and survives Tailwind's preflight).
  • UI upgrade: the panel now fades and scales in and out (the original popped), the trigger carries a count badge and its chevron flips while open via group-has-[…:popover-open]/root: — zero JS.
  • UI upgrade: version numbers became tinted chips, the newest in the accent; an Esc keycap teaches the dismissal; muted copy rose from #736e63 to oklch(0.47) (7:1 on white).
  • UI upgrade — dark mode: the stage now follows prefers-color-scheme (and a data-theme override on any ancestor) through one registered dial, --tpop01-d. The root rule re-declares each @theme colour as color-mix(dark calc(var(--tpop01-d)*100%), light), the root sets the dial with dark:[--tpop01-d:1], and transition-[--tpop01-d] crossfades page and open popovers together — the markup's colour utilities are unchanged.
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 demo 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: Tailwind Popover Show and Hide with the Popover API
Source: https://codefronts.com/snippets/tailwind-css-popovers/tailwind-popover-show-hide-popover-api/

The reference build the collection reuses: one button with popovertarget, one panel with popover, zero script. In v4 the open state is just the open: variant — it already matches :popover-open — so the panel's closed, open and entry states live in one class list, and the trigger's chevron flips via group-has-[#tpop-01-panel:popover-open]/root:rotate-180.
## HTML
```html
<section class="tpop-01 transition-[--tpop01-d] duration-300 ease-in-out motion-reduce:transition-none dark:[--tpop01-d:1] [[data-theme=dark]_&]:[--tpop01-d:1] [[data-theme=light]_&]:[--tpop01-d:0] group/root w-full min-h-svh bg-tpop01-paper bg-[radial-gradient(90%_60%_at_50%_0%,var(--color-tpop01-glow),transparent_70%)] px-4.5 py-10 font-tpop01-sans text-tpop01-ink antialiased" aria-label="Tailwind popover show and hide demo">
  <div class="grid min-h-[calc(100svh-80px)] max-w-full place-items-center">
    <div class="isolate w-full min-w-0 max-w-[40rem] overflow-hidden rounded-2xl border border-tpop01-rule bg-tpop01-surface shadow-[0_1px_2px_oklch(0.2_0.02_70/0.06),0_24px_48px_-32px_oklch(0.2_0.02_70/0.4)]">
      <header class="flex flex-wrap items-center justify-between gap-3 border-b border-tpop01-rule px-4 py-2">
        <div class="flex min-w-0 items-center gap-2.5">
          <span aria-hidden="true" class="grid size-7 place-items-center rounded-lg bg-tpop01-ink text-tpop01-surface">
            <svg viewBox="0 0 20 20" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 5h12M4 10h12M4 15h7"/></svg>
          </span>
          <span class="text-[13.5px] font-semibold tracking-[-0.01em]">Atlas Docs</span>
        </div>
        <nav class="flex min-w-0 flex-wrap items-center gap-1" aria-label="Documentation">
          <span class="px-2.5 text-[13px] text-tpop01-mut">Guides</span>
          <span class="px-2.5 text-[13px] text-tpop01-mut">Reference</span>
          <button id="tpop-01-trigger" type="button" popovertarget="tpop-01-panel" class="inline-flex min-h-11 cursor-pointer items-center gap-2 rounded-xl bg-tpop01-ink px-3.5 text-[13px] font-semibold text-tpop01-surface transition-colors hover:bg-tpop01-ink/85 focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-tpop01-acc group-has-[#tpop-01-panel:popover-open]/root:bg-tpop01-ink/80">
            Release notes
            <span class="rounded-full bg-tpop01-acc px-1.5 py-0.5 font-tpop01-mono text-[10px] leading-none text-tpop01-surface">3</span>
            <svg class="transition-transform duration-200 motion-reduce:transition-none group-has-[#tpop-01-panel:popover-open]/root:rotate-180" 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="px-5 pt-7 pb-8">
        <p class="mb-2.5 text-[10.5px] font-bold uppercase tracking-[0.2em] text-tpop01-acc">Platform</p>
        <h2 class="mb-3.5 text-[clamp(22px,4.6vw,30px)] font-semibold leading-[1.15] tracking-[-0.025em] text-balance">Floating panels, no positioning library</h2>
        <p class="mb-3 text-[15px] leading-[1.7] text-tpop01-mut text-pretty">The button above is an invoker: it carries <code class="rounded bg-tpop01-ink/6 px-1.5 py-0.5 font-tpop01-mono text-[0.86em] text-tpop01-ink">popovertarget</code> and points at an element with the <code class="rounded bg-tpop01-ink/6 px-1.5 py-0.5 font-tpop01-mono text-[0.86em] text-tpop01-ink">popover</code> attribute. Open it, then click anywhere on this paragraph — the panel closes itself.</p>
        <p class="text-[15px] leading-[1.7] text-tpop01-mut text-pretty">This card has <code class="rounded bg-tpop01-ink/6 px-1.5 py-0.5 font-tpop01-mono text-[0.86em] text-tpop01-ink">overflow-hidden</code> and its own stacking context, and the panel still paints over it. That is the top layer, not a large z-index.</p>
      </article>
      <div popover="auto" id="tpop-01-panel" role="group" aria-labelledby="tpop-01-title" class="fixed m-auto w-[min(30rem,calc(100vw-32px))] rounded-2xl border border-tpop01-rule bg-tpop01-surface p-4.5 text-tpop01-ink opacity-0 scale-[0.98] shadow-[0_32px_64px_-24px_oklch(0.2_0.02_70/0.45)] transition-[opacity,scale,display,overlay] duration-200 ease-out transition-discrete open:opacity-100 open:scale-100 starting:open:opacity-0 starting:open:scale-[0.98] motion-reduce:transition-none">
        <div class="mb-3 flex items-center justify-between gap-2.5">
          <h3 id="tpop-01-title" class="text-[13.5px] font-semibold">Release notes</h3>
          <button type="button" popovertarget="tpop-01-panel" popovertargetaction="hide" aria-label="Close release notes" class="-my-3 -mr-2.5 inline-flex size-11 cursor-pointer items-center justify-center rounded-lg text-tpop01-mut transition-colors hover:bg-tpop01-ink/6 hover:text-tpop01-ink focus-visible:outline-3 focus-visible:outline-offset-1 focus-visible:outline-tpop01-acc">
            <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="grid gap-px overflow-hidden rounded-xl border border-tpop01-rule bg-tpop01-rule">
          <li class="flex min-w-0 items-baseline gap-3 bg-tpop01-surface px-3 py-3 text-[13.5px] leading-normal"><span class="flex-none rounded-md bg-tpop01-acc/12 px-1.5 py-0.5 font-tpop01-mono text-[11px] font-semibold text-tpop01-acc">4.2.0</span> Query cache keys are now stable across workers.</li>
          <li class="flex min-w-0 items-baseline gap-3 bg-tpop01-surface px-3 py-3 text-[13.5px] leading-normal"><span class="flex-none rounded-md bg-tpop01-ink/6 px-1.5 py-0.5 font-tpop01-mono text-[11px] font-semibold text-tpop01-mut">4.1.6</span> Fixed a regression in the CSV importer on Windows line endings.</li>
          <li class="flex min-w-0 items-baseline gap-3 bg-tpop01-surface px-3 py-3 text-[13.5px] leading-normal"><span class="flex-none rounded-md bg-tpop01-ink/6 px-1.5 py-0.5 font-tpop01-mono text-[11px] font-semibold text-tpop01-mut">4.1.5</span> Audit log entries keep the actor when a token is rotated.</li>
        </ul>
        <p class="mt-3 flex flex-wrap items-center gap-1.5 text-[12.5px] leading-relaxed text-tpop01-mut"><kbd class="rounded-md border border-tpop01-rule border-b-2 px-1.5 py-0.5 font-tpop01-mono text-[11px] text-tpop01-ink">Esc</kbd> or a click outside closes this. Neither needed a line of JavaScript.</p>
      </div>
    </div>
  </div>
</section>
```
## CSS
```css
@import "tailwindcss";

@theme {
  --color-tpop01-paper: #f7f5f1;
  --color-tpop01-paper: oklch(0.97 0.006 85);
  --color-tpop01-glow: #fffdfa;
  --color-tpop01-glow: oklch(0.995 0.004 85);
  --color-tpop01-surface: #ffffff;
  --color-tpop01-surface: oklch(1 0 0);
  --color-tpop01-ink: #1e1a14;
  --color-tpop01-ink: oklch(0.22 0.012 75);
  --color-tpop01-mut: #605a51;
  --color-tpop01-mut: oklch(0.47 0.016 75);
  --color-tpop01-rule: #e4e1da;
  --color-tpop01-rule: oklch(0.91 0.01 85);
  --color-tpop01-acc: #be4b13;
  --color-tpop01-acc: oklch(0.56 0.16 42);
  --font-tpop01-sans: 'Geist', system-ui, sans-serif;
  --font-tpop01-mono: 'Geist Mono', ui-monospace, monospace;
}
/* Theme dial: 0 = light, 1 = dark. Registered so the root can transition it. */

@property --tpop01-d {
  syntax: '<number>';
  inherits: true;
  initial-value: 0;
}
/* Root rule: the full-bleed contract, stated in CSS as well as in the markup's utilities, so the
   stage is sized before the browser build compiles — plus the dark palette. Each @theme colour is
   re-declared on the root as a light/dark mix of the dial, so every utility (and every open
   popover, which inherits from the root) follows it. Light value last, dark value first. */

.tpop-01 {
  width: 100%;
  min-height: 100svh;
  --color-tpop01-paper: color-mix(in oklch, oklch(0.17 0.008 75) calc(var(--tpop01-d) * 100%), oklch(0.97 0.006 85));
  --color-tpop01-glow: color-mix(in oklch, oklch(0.21 0.012 75) calc(var(--tpop01-d) * 100%), oklch(0.995 0.004 85));
  --color-tpop01-surface: color-mix(in oklch, oklch(0.205 0.01 75) calc(var(--tpop01-d) * 100%), oklch(1 0 0));
  --color-tpop01-ink: color-mix(in oklch, oklch(0.95 0.008 85) calc(var(--tpop01-d) * 100%), oklch(0.22 0.012 75));
  --color-tpop01-mut: color-mix(in oklch, oklch(0.74 0.016 80) calc(var(--tpop01-d) * 100%), oklch(0.47 0.016 75));
  --color-tpop01-rule: color-mix(in oklch, oklch(0.3 0.012 75) calc(var(--tpop01-d) * 100%), oklch(0.91 0.01 85));
  --color-tpop01-acc: color-mix(in oklch, oklch(0.72 0.14 45) calc(var(--tpop01-d) * 100%), oklch(0.56 0.16 42));
}
```

How this works

Two attributes replace a behaviour layer. The browser wires the button to the panel, promotes the open panel to the top layer (above every z-index, unclipped by the card's overflow-hidden), closes it on outside click or Escape, and returns focus to the button.

<button popovertarget="tpop-01-panel">Release notes</button>
<div popover id="tpop-01-panel" role="group"
  class="fixed m-auto opacity-0 scale-[0.98] transition-discrete
    transition-[opacity,scale,display,overlay] duration-200
    open:opacity-100 open:scale-100
    starting:open:opacity-0 starting:open:scale-[0.98]">

The preflight trap. Preflight's margin: 0 outranks the UA's margin: auto, so an unstyled popover in a Tailwind page lands in the top-left corner. m-auto puts it back in the centre — the CSS original had the same bug with an explicit margin: 0.

State without script. The panel lives inside the root, so :has(#tpop-01-panel:popover-open) on the root can style anything else — here the chevron rotates and the trigger dims while the panel is open.

Make it yours

  • Open-only invoker: add popovertargetaction="show" to a second button.
  • Sheet on phones: add max-sm:mb-0 max-sm:w-full max-sm:rounded-b-none to pin the panel to the bottom edge.
  • Opt out of light dismiss: popover="manual" — demo 02 covers the difference.
  • Re-theme: edit --color-tpop01-acc in @theme — badge, focus ring and version tags follow.

Gotchas — read before shipping

  • popovertarget pointing at an element without popover does nothing, silently.
  • Don't hardcode aria-expanded — modern engines compute it for invokers, and a literal value goes stale on light dismiss.
  • Styles on the bare panel apply to both states; the open look must sit behind open:, and the entry look behind starting:open:.

Browser support

ChromeSafariFirefoxEdge
114+17+125+114+

The popover attribute is Chrome 114 / Safari 17 / Firefox 125; the generated sheet needs Chrome 111 / Safari 16.4 / Firefox 128. The fade wants @starting-style (Chrome 117 / Safari 17.5 / Firefox 129) and simply appears instantly where it is missing.

Techniques used in this demo

Utilitiesfirst-class utility or variant

Search CodeFronts

Loading…