20 / 20
All 20 in 20 Tailwind CSS Popovers
- 01Tailwind Popover Show and Hide with the Popover API
- 02Tailwind Auto vs Manual Popover Behavior
- 03Tailwind Anchored Popover with CSS Anchor Positioning
- 04Tailwind Popover Entry and Exit Animation
- 05Tailwind Popover Backdrop and Light Dismiss
- 06Tailwind Position Area Popover Placement Grid
- 07Tailwind Popover Flip with Position Try Fallbacks
- 08Tailwind Tooltip Arrow Pointing to Anchor
- 09Tailwind Dropdown Matching Anchor Width
- 10Tailwind Nested Popover Submenu
- 11Tailwind Custom Select Listbox Popover
- 12Tailwind Tooltip on Hover and Focus
- 13Tailwind User Profile Dropdown Menu
- 14Tailwind Date Picker Popover
- 15Tailwind Filter Dropdown with Checkboxes
- 16Tailwind Popover Fallback and Polyfill
- 17Tailwind Popover vs Dialog Modal
- 18Tailwind Glassmorphism Popover
- 19Tailwind Dark Mode Popover Toggle
- 20Tailwind Accessible Tooltip with ARIA
Tailwind Accessible Tooltip with ARIA
A ledger of what the API gives you — top layer, light dismiss, Escape, focus return — and what it doesn't: a role, a name, a described-by relationship. The demo wires both correctly: a click-opened hint with aria-describedby and no tooltip role, and a role="menu" panel. The port fixes the original's always-visible menu (display: grid outranked the UA's closed state) with open:grid.
Untitled UI Pro: 5,000+ React + Tailwind CSS v4 components · from $349 one-time
Untitled UI React · Affiliate link
The code
<section class="tpop-20 transition-[--tpop20-d] duration-300 ease-in-out motion-reduce:transition-none dark:[--tpop20-d:1] [[data-theme=dark]_&]:[--tpop20-d:1] [[data-theme=light]_&]:[--tpop20-d:0] group/root w-full min-h-svh bg-tpop20-sepia px-5 py-11 font-tpop20-sans text-tpop20-ink" aria-label="Accessible popover with ARIA roles demo">
<div class="grid min-h-[calc(100svh-88px)] max-w-full place-items-center">
<div class="w-full min-w-0 max-w-[32rem] rounded-t-[12rem] rounded-b-3xl border border-tpop20-rule bg-tpop20-paper px-6.5 pt-12 pb-6.5 shadow-[0_24px_50px_-40px_oklch(0.3_0.03_50/0.6)]">
<p class="mb-2.5 text-center text-[10.5px] font-bold uppercase tracking-[0.22em] text-tpop20-mut">Accessibility ledger</p>
<h2 class="mb-6 text-center font-tpop20-serif text-[clamp(24px,5vw,32px)] font-medium leading-[1.15] tracking-[-0.015em] text-balance">What the API does, and does not, hand you</h2>
<div class="mb-6 grid grid-cols-[repeat(auto-fit,minmax(min(100%,12rem),1fr))] gap-4">
<ul class="grid gap-2 rounded-2xl bg-tpop20-yes/10 px-4 py-3.5" aria-label="Provided by popover">
<li class="relative min-w-0 pl-5 text-[13.5px] leading-normal before:absolute before:top-[6px] before:left-0.5 before:h-1.5 before:w-2.5 before:-rotate-45 before:border-b-2 before:border-l-2 before:border-tpop20-yes before:content-['']">Top layer</li>
<li class="relative min-w-0 pl-5 text-[13.5px] leading-normal before:absolute before:top-[6px] before:left-0.5 before:h-1.5 before:w-2.5 before:-rotate-45 before:border-b-2 before:border-l-2 before:border-tpop20-yes before:content-['']">Light dismiss</li>
<li class="relative min-w-0 pl-5 text-[13.5px] leading-normal before:absolute before:top-[6px] before:left-0.5 before:h-1.5 before:w-2.5 before:-rotate-45 before:border-b-2 before:border-l-2 before:border-tpop20-yes before:content-['']">Escape to close</li>
<li class="relative min-w-0 pl-5 text-[13.5px] leading-normal before:absolute before:top-[6px] before:left-0.5 before:h-1.5 before:w-2.5 before:-rotate-45 before:border-b-2 before:border-l-2 before:border-tpop20-yes before:content-['']">Focus back to the trigger</li>
</ul>
<ul class="grid gap-2 rounded-2xl bg-tpop20-no/9 px-4 py-3.5" aria-label="Not provided by popover">
<li class="relative min-w-0 pl-5 text-[13.5px] leading-normal before:absolute before:top-[10px] before:left-0.5 before:h-0.5 before:w-2.5 before:bg-tpop20-no before:content-['']">A role</li>
<li class="relative min-w-0 pl-5 text-[13.5px] leading-normal before:absolute before:top-[10px] before:left-0.5 before:h-0.5 before:w-2.5 before:bg-tpop20-no before:content-['']">An accessible name</li>
<li class="relative min-w-0 pl-5 text-[13.5px] leading-normal before:absolute before:top-[10px] before:left-0.5 before:h-0.5 before:w-2.5 before:bg-tpop20-no before:content-['']">A described-by relationship</li>
<li class="relative min-w-0 pl-5 text-[13.5px] leading-normal before:absolute before:top-[10px] before:left-0.5 before:h-0.5 before:w-2.5 before:bg-tpop20-no before:content-['']">Any keyboard model inside</li>
</ul>
</div>
<div class="flex flex-wrap items-center justify-between gap-3.5 border-t border-tpop20-rule pt-4.5">
<p class="flex min-w-0 items-center gap-2 text-[14.5px] font-semibold">
Retention window
<button id="tpop-20-trigger" type="button" popovertarget="tpop-20-hint" aria-describedby="tpop-20-hint" aria-label="What the retention window means" class="inline-flex size-11 cursor-pointer items-center justify-center rounded-full border border-tpop20-rule text-tpop20-mut transition-colors [anchor-name:--tpop20-hint] hover:bg-tpop20-sepia hover:text-tpop20-ink focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-tpop20-yes group-has-[#tpop-20-hint:popover-open]/root:border-tpop20-ink group-has-[#tpop-20-hint:popover-open]/root:text-tpop20-ink">
<svg viewBox="0 0 20 20" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.7" aria-hidden="true"><circle cx="10" cy="10" r="7.2"/><path d="M10 9v5M10 6.3v.8"/></svg>
</button>
</p>
<button type="button" popovertarget="tpop-20-menu" aria-haspopup="menu" class="inline-flex min-h-11 cursor-pointer items-center gap-2 rounded-full bg-tpop20-ink px-4 text-[13px] font-semibold text-tpop20-paper transition-colors [anchor-name:--tpop20-menu] hover:bg-tpop20-ink/90 focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-tpop20-yes">
Row actions
<svg class="transition-transform duration-200 motion-reduce:transition-none group-has-[#tpop-20-menu:popover-open]/root:rotate-180" viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M4 9.5 8 5.5 12 9.5"/></svg>
</button>
</div>
<p class="mt-4.5 text-center text-[12.5px] leading-relaxed text-tpop20-mut">The hint is described-by. The menu is role=menu. Neither came for free.</p>
</div>
</div>
<div popover="auto" id="tpop-20-hint" class="fixed m-auto w-[min(17rem,calc(100vw-32px))] rounded-2xl border border-tpop20-rule bg-tpop20-paper px-4 py-3 text-[13px] leading-normal text-tpop20-ink opacity-0 shadow-[0_18px_38px_-24px_oklch(0.3_0.03_50/0.5)] text-pretty transition-[opacity,translate,display,overlay] duration-150 ease-out transition-discrete [position-anchor:--tpop20-hint] [position-area:block-start_center] [justify-self:anchor-center] [position-try-fallbacks:flip-block] open:opacity-100 starting:open:translate-y-1 starting:open:opacity-0 motion-reduce:transition-none supports-[anchor-name:--a]:inset-auto supports-[anchor-name:--a]:m-0 supports-[anchor-name:--a]:mb-2.5">Deleted records stay recoverable for <strong class="font-semibold">30 days</strong>, then are purged nightly.</div>
<div popover="auto" id="tpop-20-menu" role="menu" aria-label="Row actions" class="fixed m-auto w-[min(14.5rem,calc(100vw-32px))] gap-0.5 rounded-3xl border border-tpop20-rule bg-tpop20-paper p-1.5 text-tpop20-ink opacity-0 shadow-[0_18px_38px_-24px_oklch(0.3_0.03_50/0.5)] transition-[opacity,translate,display,overlay] duration-150 ease-out transition-discrete [position-anchor:--tpop20-menu] [position-area:block-start_span-inline-start] [position-try-fallbacks:flip-block,flip-inline] open:grid open:opacity-100 starting:open:translate-y-1 starting:open:opacity-0 motion-reduce:transition-none supports-[anchor-name:--a]:inset-auto supports-[anchor-name:--a]:m-0 supports-[anchor-name:--a]:mb-2.5">
<button type="button" role="menuitem" class="min-h-11 w-full min-w-0 cursor-pointer rounded-full px-3.5 text-left text-[13.5px] font-medium transition-colors hover:bg-tpop20-sepia focus-visible:outline-3 focus-visible:-outline-offset-3 focus-visible:outline-tpop20-yes">Extend retention</button>
<button type="button" role="menuitem" class="min-h-11 w-full min-w-0 cursor-pointer rounded-full px-3.5 text-left text-[13.5px] font-medium transition-colors hover:bg-tpop20-sepia focus-visible:outline-3 focus-visible:-outline-offset-3 focus-visible:outline-tpop20-yes">Export audit trail</button>
<span role="separator" class="mx-3 my-0.5 block h-px bg-tpop20-rule"></span>
<button type="button" role="menuitem" class="min-h-11 w-full min-w-0 cursor-pointer rounded-full px-3.5 text-left text-[13.5px] font-medium text-tpop20-no transition-colors hover:bg-tpop20-no/9 focus-visible:outline-3 focus-visible:-outline-offset-3 focus-visible:outline-tpop20-no">Purge now</button>
</div>
</section><section class="tpop-20 transition-[--tpop20-d] duration-300 ease-in-out motion-reduce:transition-none dark:[--tpop20-d:1] [[data-theme=dark]_&]:[--tpop20-d:1] [[data-theme=light]_&]:[--tpop20-d:0] group/root w-full min-h-svh bg-tpop20-sepia px-5 py-11 font-tpop20-sans text-tpop20-ink" aria-label="Accessible popover with ARIA roles demo">
<div class="grid min-h-[calc(100svh-88px)] max-w-full place-items-center">
<div class="w-full min-w-0 max-w-[32rem] rounded-t-[12rem] rounded-b-3xl border border-tpop20-rule bg-tpop20-paper px-6.5 pt-12 pb-6.5 shadow-[0_24px_50px_-40px_oklch(0.3_0.03_50/0.6)]">
<p class="mb-2.5 text-center text-[10.5px] font-bold uppercase tracking-[0.22em] text-tpop20-mut">Accessibility ledger</p>
<h2 class="mb-6 text-center font-tpop20-serif text-[clamp(24px,5vw,32px)] font-medium leading-[1.15] tracking-[-0.015em] text-balance">What the API does, and does not, hand you</h2>
<div class="mb-6 grid grid-cols-[repeat(auto-fit,minmax(min(100%,12rem),1fr))] gap-4">
<ul class="grid gap-2 rounded-2xl bg-tpop20-yes/10 px-4 py-3.5" aria-label="Provided by popover">
<li class="relative min-w-0 pl-5 text-[13.5px] leading-normal before:absolute before:top-[6px] before:left-0.5 before:h-1.5 before:w-2.5 before:-rotate-45 before:border-b-2 before:border-l-2 before:border-tpop20-yes before:content-['']">Top layer</li>
<li class="relative min-w-0 pl-5 text-[13.5px] leading-normal before:absolute before:top-[6px] before:left-0.5 before:h-1.5 before:w-2.5 before:-rotate-45 before:border-b-2 before:border-l-2 before:border-tpop20-yes before:content-['']">Light dismiss</li>
<li class="relative min-w-0 pl-5 text-[13.5px] leading-normal before:absolute before:top-[6px] before:left-0.5 before:h-1.5 before:w-2.5 before:-rotate-45 before:border-b-2 before:border-l-2 before:border-tpop20-yes before:content-['']">Escape to close</li>
<li class="relative min-w-0 pl-5 text-[13.5px] leading-normal before:absolute before:top-[6px] before:left-0.5 before:h-1.5 before:w-2.5 before:-rotate-45 before:border-b-2 before:border-l-2 before:border-tpop20-yes before:content-['']">Focus back to the trigger</li>
</ul>
<ul class="grid gap-2 rounded-2xl bg-tpop20-no/9 px-4 py-3.5" aria-label="Not provided by popover">
<li class="relative min-w-0 pl-5 text-[13.5px] leading-normal before:absolute before:top-[10px] before:left-0.5 before:h-0.5 before:w-2.5 before:bg-tpop20-no before:content-['']">A role</li>
<li class="relative min-w-0 pl-5 text-[13.5px] leading-normal before:absolute before:top-[10px] before:left-0.5 before:h-0.5 before:w-2.5 before:bg-tpop20-no before:content-['']">An accessible name</li>
<li class="relative min-w-0 pl-5 text-[13.5px] leading-normal before:absolute before:top-[10px] before:left-0.5 before:h-0.5 before:w-2.5 before:bg-tpop20-no before:content-['']">A described-by relationship</li>
<li class="relative min-w-0 pl-5 text-[13.5px] leading-normal before:absolute before:top-[10px] before:left-0.5 before:h-0.5 before:w-2.5 before:bg-tpop20-no before:content-['']">Any keyboard model inside</li>
</ul>
</div>
<div class="flex flex-wrap items-center justify-between gap-3.5 border-t border-tpop20-rule pt-4.5">
<p class="flex min-w-0 items-center gap-2 text-[14.5px] font-semibold">
Retention window
<button id="tpop-20-trigger" type="button" popovertarget="tpop-20-hint" aria-describedby="tpop-20-hint" aria-label="What the retention window means" class="inline-flex size-11 cursor-pointer items-center justify-center rounded-full border border-tpop20-rule text-tpop20-mut transition-colors [anchor-name:--tpop20-hint] hover:bg-tpop20-sepia hover:text-tpop20-ink focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-tpop20-yes group-has-[#tpop-20-hint:popover-open]/root:border-tpop20-ink group-has-[#tpop-20-hint:popover-open]/root:text-tpop20-ink">
<svg viewBox="0 0 20 20" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.7" aria-hidden="true"><circle cx="10" cy="10" r="7.2"/><path d="M10 9v5M10 6.3v.8"/></svg>
</button>
</p>
<button type="button" popovertarget="tpop-20-menu" aria-haspopup="menu" class="inline-flex min-h-11 cursor-pointer items-center gap-2 rounded-full bg-tpop20-ink px-4 text-[13px] font-semibold text-tpop20-paper transition-colors [anchor-name:--tpop20-menu] hover:bg-tpop20-ink/90 focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-tpop20-yes">
Row actions
<svg class="transition-transform duration-200 motion-reduce:transition-none group-has-[#tpop-20-menu:popover-open]/root:rotate-180" viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M4 9.5 8 5.5 12 9.5"/></svg>
</button>
</div>
<p class="mt-4.5 text-center text-[12.5px] leading-relaxed text-tpop20-mut">The hint is described-by. The menu is role=menu. Neither came for free.</p>
</div>
</div>
<div popover="auto" id="tpop-20-hint" class="fixed m-auto w-[min(17rem,calc(100vw-32px))] rounded-2xl border border-tpop20-rule bg-tpop20-paper px-4 py-3 text-[13px] leading-normal text-tpop20-ink opacity-0 shadow-[0_18px_38px_-24px_oklch(0.3_0.03_50/0.5)] text-pretty transition-[opacity,translate,display,overlay] duration-150 ease-out transition-discrete [position-anchor:--tpop20-hint] [position-area:block-start_center] [justify-self:anchor-center] [position-try-fallbacks:flip-block] open:opacity-100 starting:open:translate-y-1 starting:open:opacity-0 motion-reduce:transition-none supports-[anchor-name:--a]:inset-auto supports-[anchor-name:--a]:m-0 supports-[anchor-name:--a]:mb-2.5">Deleted records stay recoverable for <strong class="font-semibold">30 days</strong>, then are purged nightly.</div>
<div popover="auto" id="tpop-20-menu" role="menu" aria-label="Row actions" class="fixed m-auto w-[min(14.5rem,calc(100vw-32px))] gap-0.5 rounded-3xl border border-tpop20-rule bg-tpop20-paper p-1.5 text-tpop20-ink opacity-0 shadow-[0_18px_38px_-24px_oklch(0.3_0.03_50/0.5)] transition-[opacity,translate,display,overlay] duration-150 ease-out transition-discrete [position-anchor:--tpop20-menu] [position-area:block-start_span-inline-start] [position-try-fallbacks:flip-block,flip-inline] open:grid open:opacity-100 starting:open:translate-y-1 starting:open:opacity-0 motion-reduce:transition-none supports-[anchor-name:--a]:inset-auto supports-[anchor-name:--a]:m-0 supports-[anchor-name:--a]:mb-2.5">
<button type="button" role="menuitem" class="min-h-11 w-full min-w-0 cursor-pointer rounded-full px-3.5 text-left text-[13.5px] font-medium transition-colors hover:bg-tpop20-sepia focus-visible:outline-3 focus-visible:-outline-offset-3 focus-visible:outline-tpop20-yes">Extend retention</button>
<button type="button" role="menuitem" class="min-h-11 w-full min-w-0 cursor-pointer rounded-full px-3.5 text-left text-[13.5px] font-medium transition-colors hover:bg-tpop20-sepia focus-visible:outline-3 focus-visible:-outline-offset-3 focus-visible:outline-tpop20-yes">Export audit trail</button>
<span role="separator" class="mx-3 my-0.5 block h-px bg-tpop20-rule"></span>
<button type="button" role="menuitem" class="min-h-11 w-full min-w-0 cursor-pointer rounded-full px-3.5 text-left text-[13.5px] font-medium text-tpop20-no transition-colors hover:bg-tpop20-no/9 focus-visible:outline-3 focus-visible:-outline-offset-3 focus-visible:outline-tpop20-no">Purge now</button>
</div>
</section>@import "tailwindcss";
@theme {
--color-tpop20-sepia: #f0e6da;
--color-tpop20-sepia: oklch(0.93 0.02 75);
--color-tpop20-paper: #fbf6ed;
--color-tpop20-paper: oklch(0.975 0.013 80);
--color-tpop20-ink: #2f241e;
--color-tpop20-ink: oklch(0.27 0.02 50);
--color-tpop20-mut: #68574a;
--color-tpop20-mut: oklch(0.47 0.03 60);
--color-tpop20-rule: #ddcfbc;
--color-tpop20-rule: oklch(0.86 0.03 75);
--color-tpop20-yes: #476536;
--color-tpop20-yes: oklch(0.47 0.08 135);
--color-tpop20-no: #983d1f;
--color-tpop20-no: oklch(0.48 0.13 38);
--font-tpop20-serif: 'Newsreader', Georgia, serif;
--font-tpop20-sans: 'Plus Jakarta Sans', system-ui, sans-serif;
}
/* Theme dial: 0 = light, 1 = dark. Registered so the root can transition it. */
@property --tpop20-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-20 {
width: 100%;
min-height: 100svh;
--color-tpop20-sepia: color-mix(in oklch, oklch(0.17 0.014 60) calc(var(--tpop20-d) * 100%), oklch(0.93 0.02 75));
--color-tpop20-paper: color-mix(in oklch, oklch(0.215 0.016 60) calc(var(--tpop20-d) * 100%), oklch(0.975 0.013 80));
--color-tpop20-ink: color-mix(in oklch, oklch(0.94 0.016 75) calc(var(--tpop20-d) * 100%), oklch(0.27 0.02 50));
--color-tpop20-mut: color-mix(in oklch, oklch(0.74 0.03 65) calc(var(--tpop20-d) * 100%), oklch(0.47 0.03 60));
--color-tpop20-rule: color-mix(in oklch, oklch(0.33 0.022 60) calc(var(--tpop20-d) * 100%), oklch(0.86 0.03 75));
--color-tpop20-yes: color-mix(in oklch, oklch(0.78 0.1 135) calc(var(--tpop20-d) * 100%), oklch(0.47 0.08 135));
--color-tpop20-no: color-mix(in oklch, oklch(0.76 0.13 38) calc(var(--tpop20-d) * 100%), oklch(0.48 0.13 38));
}@import "tailwindcss";
@theme {
--color-tpop20-sepia: #f0e6da;
--color-tpop20-sepia: oklch(0.93 0.02 75);
--color-tpop20-paper: #fbf6ed;
--color-tpop20-paper: oklch(0.975 0.013 80);
--color-tpop20-ink: #2f241e;
--color-tpop20-ink: oklch(0.27 0.02 50);
--color-tpop20-mut: #68574a;
--color-tpop20-mut: oklch(0.47 0.03 60);
--color-tpop20-rule: #ddcfbc;
--color-tpop20-rule: oklch(0.86 0.03 75);
--color-tpop20-yes: #476536;
--color-tpop20-yes: oklch(0.47 0.08 135);
--color-tpop20-no: #983d1f;
--color-tpop20-no: oklch(0.48 0.13 38);
--font-tpop20-serif: 'Newsreader', Georgia, serif;
--font-tpop20-sans: 'Plus Jakarta Sans', system-ui, sans-serif;
}
/* Theme dial: 0 = light, 1 = dark. Registered so the root can transition it. */
@property --tpop20-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-20 {
width: 100%;
min-height: 100svh;
--color-tpop20-sepia: color-mix(in oklch, oklch(0.17 0.014 60) calc(var(--tpop20-d) * 100%), oklch(0.93 0.02 75));
--color-tpop20-paper: color-mix(in oklch, oklch(0.215 0.016 60) calc(var(--tpop20-d) * 100%), oklch(0.975 0.013 80));
--color-tpop20-ink: color-mix(in oklch, oklch(0.94 0.016 75) calc(var(--tpop20-d) * 100%), oklch(0.27 0.02 50));
--color-tpop20-mut: color-mix(in oklch, oklch(0.74 0.03 65) calc(var(--tpop20-d) * 100%), oklch(0.47 0.03 60));
--color-tpop20-rule: color-mix(in oklch, oklch(0.33 0.022 60) calc(var(--tpop20-d) * 100%), oklch(0.86 0.03 75));
--color-tpop20-yes: color-mix(in oklch, oklch(0.78 0.1 135) calc(var(--tpop20-d) * 100%), oklch(0.47 0.08 135));
--color-tpop20-no: color-mix(in oklch, oklch(0.76 0.13 38) calc(var(--tpop20-d) * 100%), oklch(0.48 0.13 38));
}Setup — do this once per project
Skip this if your project already runs Tailwind v4.
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<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.
npm install tailwindcss @tailwindcss/vite
npm install tailwindcss @tailwindcss/vite// vite.config.js ("type": "module" in package.json)
import { defineConfig } from 'vite'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [tailwindcss()],
})// vite.config.js ("type": "module" in package.json)
import { defineConfig } from 'vite'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [tailwindcss()],
})Any v4 setup works — Vite, PostCSS or the CLI. Nothing in this demo is bundler-specific.
@import "tailwindcss";
@theme {
--color-tpop20-sepia: #f0e6da;
--color-tpop20-sepia: oklch(0.93 0.02 75);
--color-tpop20-paper: #fbf6ed;
--color-tpop20-paper: oklch(0.975 0.013 80);
--color-tpop20-ink: #2f241e;
--color-tpop20-ink: oklch(0.27 0.02 50);
--color-tpop20-mut: #68574a;
--color-tpop20-mut: oklch(0.47 0.03 60);
--color-tpop20-rule: #ddcfbc;
--color-tpop20-rule: oklch(0.86 0.03 75);
--color-tpop20-yes: #476536;
--color-tpop20-yes: oklch(0.47 0.08 135);
--color-tpop20-no: #983d1f;
--color-tpop20-no: oklch(0.48 0.13 38);
--font-tpop20-serif: 'Newsreader', Georgia, serif;
--font-tpop20-sans: 'Plus Jakarta Sans', system-ui, sans-serif;
}
/* Theme dial: 0 = light, 1 = dark. Registered so the root can transition it. */
@property --tpop20-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-20 {
width: 100%;
min-height: 100svh;
--color-tpop20-sepia: color-mix(in oklch, oklch(0.17 0.014 60) calc(var(--tpop20-d) * 100%), oklch(0.93 0.02 75));
--color-tpop20-paper: color-mix(in oklch, oklch(0.215 0.016 60) calc(var(--tpop20-d) * 100%), oklch(0.975 0.013 80));
--color-tpop20-ink: color-mix(in oklch, oklch(0.94 0.016 75) calc(var(--tpop20-d) * 100%), oklch(0.27 0.02 50));
--color-tpop20-mut: color-mix(in oklch, oklch(0.74 0.03 65) calc(var(--tpop20-d) * 100%), oklch(0.47 0.03 60));
--color-tpop20-rule: color-mix(in oklch, oklch(0.33 0.022 60) calc(var(--tpop20-d) * 100%), oklch(0.86 0.03 75));
--color-tpop20-yes: color-mix(in oklch, oklch(0.78 0.1 135) calc(var(--tpop20-d) * 100%), oklch(0.47 0.08 135));
--color-tpop20-no: color-mix(in oklch, oklch(0.76 0.13 38) calc(var(--tpop20-d) * 100%), oklch(0.48 0.13 38));
}@import "tailwindcss";
@theme {
--color-tpop20-sepia: #f0e6da;
--color-tpop20-sepia: oklch(0.93 0.02 75);
--color-tpop20-paper: #fbf6ed;
--color-tpop20-paper: oklch(0.975 0.013 80);
--color-tpop20-ink: #2f241e;
--color-tpop20-ink: oklch(0.27 0.02 50);
--color-tpop20-mut: #68574a;
--color-tpop20-mut: oklch(0.47 0.03 60);
--color-tpop20-rule: #ddcfbc;
--color-tpop20-rule: oklch(0.86 0.03 75);
--color-tpop20-yes: #476536;
--color-tpop20-yes: oklch(0.47 0.08 135);
--color-tpop20-no: #983d1f;
--color-tpop20-no: oklch(0.48 0.13 38);
--font-tpop20-serif: 'Newsreader', Georgia, serif;
--font-tpop20-sans: 'Plus Jakarta Sans', system-ui, sans-serif;
}
/* Theme dial: 0 = light, 1 = dark. Registered so the root can transition it. */
@property --tpop20-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-20 {
width: 100%;
min-height: 100svh;
--color-tpop20-sepia: color-mix(in oklch, oklch(0.17 0.014 60) calc(var(--tpop20-d) * 100%), oklch(0.93 0.02 75));
--color-tpop20-paper: color-mix(in oklch, oklch(0.215 0.016 60) calc(var(--tpop20-d) * 100%), oklch(0.975 0.013 80));
--color-tpop20-ink: color-mix(in oklch, oklch(0.94 0.016 75) calc(var(--tpop20-d) * 100%), oklch(0.27 0.02 50));
--color-tpop20-mut: color-mix(in oklch, oklch(0.74 0.03 65) calc(var(--tpop20-d) * 100%), oklch(0.47 0.03 60));
--color-tpop20-rule: color-mix(in oklch, oklch(0.33 0.022 60) calc(var(--tpop20-d) * 100%), oklch(0.86 0.03 75));
--color-tpop20-yes: color-mix(in oklch, oklch(0.78 0.1 135) calc(var(--tpop20-d) * 100%), oklch(0.47 0.08 135));
--color-tpop20-no: color-mix(in oklch, oklch(0.76 0.13 38) calc(var(--tpop20-d) * 100%), oklch(0.48 0.13 38));
}<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=Newsreader:ital,opsz,wght@0,6..72,400..700;1,6..72,400..700&family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap">
<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=Newsreader:ital,opsz,wght@0,6..72,400..700;1,6..72,400..700&family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap">What changed in the port
Which parts became utilities, which stayed CSS, and why.
.pop-20__gives .pop-20__li::beforeand its--lackstwin becamebefore:utilities on each item — the check and the dash are visible in the markup that carries them.color-mix(in oklab, var(--yes) 10%, var(--paper))tints becamebg-tpop20-yes/10.- Bug fix from the CSS original:
.pop-20__menu { display: grid }outranked the UA's closeddisplay: none, so the menu was always rendered;open:gridrestores the closed state. - UI upgrade: the arch silhouette became a proper
rounded-t-[12rem]top that doesn't pinch the heading, the lists carryaria-labels, the destructive Purge row is separated and tinted, the menu trigger has a flipping chevron andaria-haspopup, and both panels fade in from their anchors. - UI upgrade — dark mode: the stage now follows
prefers-color-scheme(and adata-themeoverride on any ancestor) through one registered dial,--tpop20-d. The root rule re-declares each@themecolour ascolor-mix(dark calc(var(--tpop20-d)*100%), light), the root sets the dial withdark:[--tpop20-d:1], andtransition-[--tpop20-d]crossfades page and open popovers together — the markup's colour utilities are unchanged.
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 Accessible Tooltip with ARIA
Source: https://codefronts.com/snippets/tailwind-css-popovers/tailwind-accessible-tooltip-aria/
A ledger of what the API gives you — top layer, light dismiss, Escape, focus return — and what it doesn't: a role, a name, a described-by relationship. The demo wires both correctly: a click-opened hint with aria-describedby and no tooltip role, and a role="menu" panel. The port fixes the original's always-visible menu (display: grid outranked the UA's closed state) with open:grid.
## HTML
```html
<section class="tpop-20 transition-[--tpop20-d] duration-300 ease-in-out motion-reduce:transition-none dark:[--tpop20-d:1] [[data-theme=dark]_&]:[--tpop20-d:1] [[data-theme=light]_&]:[--tpop20-d:0] group/root w-full min-h-svh bg-tpop20-sepia px-5 py-11 font-tpop20-sans text-tpop20-ink" aria-label="Accessible popover with ARIA roles demo">
<div class="grid min-h-[calc(100svh-88px)] max-w-full place-items-center">
<div class="w-full min-w-0 max-w-[32rem] rounded-t-[12rem] rounded-b-3xl border border-tpop20-rule bg-tpop20-paper px-6.5 pt-12 pb-6.5 shadow-[0_24px_50px_-40px_oklch(0.3_0.03_50/0.6)]">
<p class="mb-2.5 text-center text-[10.5px] font-bold uppercase tracking-[0.22em] text-tpop20-mut">Accessibility ledger</p>
<h2 class="mb-6 text-center font-tpop20-serif text-[clamp(24px,5vw,32px)] font-medium leading-[1.15] tracking-[-0.015em] text-balance">What the API does, and does not, hand you</h2>
<div class="mb-6 grid grid-cols-[repeat(auto-fit,minmax(min(100%,12rem),1fr))] gap-4">
<ul class="grid gap-2 rounded-2xl bg-tpop20-yes/10 px-4 py-3.5" aria-label="Provided by popover">
<li class="relative min-w-0 pl-5 text-[13.5px] leading-normal before:absolute before:top-[6px] before:left-0.5 before:h-1.5 before:w-2.5 before:-rotate-45 before:border-b-2 before:border-l-2 before:border-tpop20-yes before:content-['']">Top layer</li>
<li class="relative min-w-0 pl-5 text-[13.5px] leading-normal before:absolute before:top-[6px] before:left-0.5 before:h-1.5 before:w-2.5 before:-rotate-45 before:border-b-2 before:border-l-2 before:border-tpop20-yes before:content-['']">Light dismiss</li>
<li class="relative min-w-0 pl-5 text-[13.5px] leading-normal before:absolute before:top-[6px] before:left-0.5 before:h-1.5 before:w-2.5 before:-rotate-45 before:border-b-2 before:border-l-2 before:border-tpop20-yes before:content-['']">Escape to close</li>
<li class="relative min-w-0 pl-5 text-[13.5px] leading-normal before:absolute before:top-[6px] before:left-0.5 before:h-1.5 before:w-2.5 before:-rotate-45 before:border-b-2 before:border-l-2 before:border-tpop20-yes before:content-['']">Focus back to the trigger</li>
</ul>
<ul class="grid gap-2 rounded-2xl bg-tpop20-no/9 px-4 py-3.5" aria-label="Not provided by popover">
<li class="relative min-w-0 pl-5 text-[13.5px] leading-normal before:absolute before:top-[10px] before:left-0.5 before:h-0.5 before:w-2.5 before:bg-tpop20-no before:content-['']">A role</li>
<li class="relative min-w-0 pl-5 text-[13.5px] leading-normal before:absolute before:top-[10px] before:left-0.5 before:h-0.5 before:w-2.5 before:bg-tpop20-no before:content-['']">An accessible name</li>
<li class="relative min-w-0 pl-5 text-[13.5px] leading-normal before:absolute before:top-[10px] before:left-0.5 before:h-0.5 before:w-2.5 before:bg-tpop20-no before:content-['']">A described-by relationship</li>
<li class="relative min-w-0 pl-5 text-[13.5px] leading-normal before:absolute before:top-[10px] before:left-0.5 before:h-0.5 before:w-2.5 before:bg-tpop20-no before:content-['']">Any keyboard model inside</li>
</ul>
</div>
<div class="flex flex-wrap items-center justify-between gap-3.5 border-t border-tpop20-rule pt-4.5">
<p class="flex min-w-0 items-center gap-2 text-[14.5px] font-semibold">
Retention window
<button id="tpop-20-trigger" type="button" popovertarget="tpop-20-hint" aria-describedby="tpop-20-hint" aria-label="What the retention window means" class="inline-flex size-11 cursor-pointer items-center justify-center rounded-full border border-tpop20-rule text-tpop20-mut transition-colors [anchor-name:--tpop20-hint] hover:bg-tpop20-sepia hover:text-tpop20-ink focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-tpop20-yes group-has-[#tpop-20-hint:popover-open]/root:border-tpop20-ink group-has-[#tpop-20-hint:popover-open]/root:text-tpop20-ink">
<svg viewBox="0 0 20 20" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.7" aria-hidden="true"><circle cx="10" cy="10" r="7.2"/><path d="M10 9v5M10 6.3v.8"/></svg>
</button>
</p>
<button type="button" popovertarget="tpop-20-menu" aria-haspopup="menu" class="inline-flex min-h-11 cursor-pointer items-center gap-2 rounded-full bg-tpop20-ink px-4 text-[13px] font-semibold text-tpop20-paper transition-colors [anchor-name:--tpop20-menu] hover:bg-tpop20-ink/90 focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-tpop20-yes">
Row actions
<svg class="transition-transform duration-200 motion-reduce:transition-none group-has-[#tpop-20-menu:popover-open]/root:rotate-180" viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M4 9.5 8 5.5 12 9.5"/></svg>
</button>
</div>
<p class="mt-4.5 text-center text-[12.5px] leading-relaxed text-tpop20-mut">The hint is described-by. The menu is role=menu. Neither came for free.</p>
</div>
</div>
<div popover="auto" id="tpop-20-hint" class="fixed m-auto w-[min(17rem,calc(100vw-32px))] rounded-2xl border border-tpop20-rule bg-tpop20-paper px-4 py-3 text-[13px] leading-normal text-tpop20-ink opacity-0 shadow-[0_18px_38px_-24px_oklch(0.3_0.03_50/0.5)] text-pretty transition-[opacity,translate,display,overlay] duration-150 ease-out transition-discrete [position-anchor:--tpop20-hint] [position-area:block-start_center] [justify-self:anchor-center] [position-try-fallbacks:flip-block] open:opacity-100 starting:open:translate-y-1 starting:open:opacity-0 motion-reduce:transition-none supports-[anchor-name:--a]:inset-auto supports-[anchor-name:--a]:m-0 supports-[anchor-name:--a]:mb-2.5">Deleted records stay recoverable for <strong class="font-semibold">30 days</strong>, then are purged nightly.</div>
<div popover="auto" id="tpop-20-menu" role="menu" aria-label="Row actions" class="fixed m-auto w-[min(14.5rem,calc(100vw-32px))] gap-0.5 rounded-3xl border border-tpop20-rule bg-tpop20-paper p-1.5 text-tpop20-ink opacity-0 shadow-[0_18px_38px_-24px_oklch(0.3_0.03_50/0.5)] transition-[opacity,translate,display,overlay] duration-150 ease-out transition-discrete [position-anchor:--tpop20-menu] [position-area:block-start_span-inline-start] [position-try-fallbacks:flip-block,flip-inline] open:grid open:opacity-100 starting:open:translate-y-1 starting:open:opacity-0 motion-reduce:transition-none supports-[anchor-name:--a]:inset-auto supports-[anchor-name:--a]:m-0 supports-[anchor-name:--a]:mb-2.5">
<button type="button" role="menuitem" class="min-h-11 w-full min-w-0 cursor-pointer rounded-full px-3.5 text-left text-[13.5px] font-medium transition-colors hover:bg-tpop20-sepia focus-visible:outline-3 focus-visible:-outline-offset-3 focus-visible:outline-tpop20-yes">Extend retention</button>
<button type="button" role="menuitem" class="min-h-11 w-full min-w-0 cursor-pointer rounded-full px-3.5 text-left text-[13.5px] font-medium transition-colors hover:bg-tpop20-sepia focus-visible:outline-3 focus-visible:-outline-offset-3 focus-visible:outline-tpop20-yes">Export audit trail</button>
<span role="separator" class="mx-3 my-0.5 block h-px bg-tpop20-rule"></span>
<button type="button" role="menuitem" class="min-h-11 w-full min-w-0 cursor-pointer rounded-full px-3.5 text-left text-[13.5px] font-medium text-tpop20-no transition-colors hover:bg-tpop20-no/9 focus-visible:outline-3 focus-visible:-outline-offset-3 focus-visible:outline-tpop20-no">Purge now</button>
</div>
</section>
```
## CSS
```css
@import "tailwindcss";
@theme {
--color-tpop20-sepia: #f0e6da;
--color-tpop20-sepia: oklch(0.93 0.02 75);
--color-tpop20-paper: #fbf6ed;
--color-tpop20-paper: oklch(0.975 0.013 80);
--color-tpop20-ink: #2f241e;
--color-tpop20-ink: oklch(0.27 0.02 50);
--color-tpop20-mut: #68574a;
--color-tpop20-mut: oklch(0.47 0.03 60);
--color-tpop20-rule: #ddcfbc;
--color-tpop20-rule: oklch(0.86 0.03 75);
--color-tpop20-yes: #476536;
--color-tpop20-yes: oklch(0.47 0.08 135);
--color-tpop20-no: #983d1f;
--color-tpop20-no: oklch(0.48 0.13 38);
--font-tpop20-serif: 'Newsreader', Georgia, serif;
--font-tpop20-sans: 'Plus Jakarta Sans', system-ui, sans-serif;
}
/* Theme dial: 0 = light, 1 = dark. Registered so the root can transition it. */
@property --tpop20-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-20 {
width: 100%;
min-height: 100svh;
--color-tpop20-sepia: color-mix(in oklch, oklch(0.17 0.014 60) calc(var(--tpop20-d) * 100%), oklch(0.93 0.02 75));
--color-tpop20-paper: color-mix(in oklch, oklch(0.215 0.016 60) calc(var(--tpop20-d) * 100%), oklch(0.975 0.013 80));
--color-tpop20-ink: color-mix(in oklch, oklch(0.94 0.016 75) calc(var(--tpop20-d) * 100%), oklch(0.27 0.02 50));
--color-tpop20-mut: color-mix(in oklch, oklch(0.74 0.03 65) calc(var(--tpop20-d) * 100%), oklch(0.47 0.03 60));
--color-tpop20-rule: color-mix(in oklch, oklch(0.33 0.022 60) calc(var(--tpop20-d) * 100%), oklch(0.86 0.03 75));
--color-tpop20-yes: color-mix(in oklch, oklch(0.78 0.1 135) calc(var(--tpop20-d) * 100%), oklch(0.47 0.08 135));
--color-tpop20-no: color-mix(in oklch, oklch(0.76 0.13 38) calc(var(--tpop20-d) * 100%), oklch(0.48 0.13 38));
}
```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 Accessible Tooltip with ARIA
Source: https://codefronts.com/snippets/tailwind-css-popovers/tailwind-accessible-tooltip-aria/
A ledger of what the API gives you — top layer, light dismiss, Escape, focus return — and what it doesn't: a role, a name, a described-by relationship. The demo wires both correctly: a click-opened hint with aria-describedby and no tooltip role, and a role="menu" panel. The port fixes the original's always-visible menu (display: grid outranked the UA's closed state) with open:grid.
## HTML
```html
<section class="tpop-20 transition-[--tpop20-d] duration-300 ease-in-out motion-reduce:transition-none dark:[--tpop20-d:1] [[data-theme=dark]_&]:[--tpop20-d:1] [[data-theme=light]_&]:[--tpop20-d:0] group/root w-full min-h-svh bg-tpop20-sepia px-5 py-11 font-tpop20-sans text-tpop20-ink" aria-label="Accessible popover with ARIA roles demo">
<div class="grid min-h-[calc(100svh-88px)] max-w-full place-items-center">
<div class="w-full min-w-0 max-w-[32rem] rounded-t-[12rem] rounded-b-3xl border border-tpop20-rule bg-tpop20-paper px-6.5 pt-12 pb-6.5 shadow-[0_24px_50px_-40px_oklch(0.3_0.03_50/0.6)]">
<p class="mb-2.5 text-center text-[10.5px] font-bold uppercase tracking-[0.22em] text-tpop20-mut">Accessibility ledger</p>
<h2 class="mb-6 text-center font-tpop20-serif text-[clamp(24px,5vw,32px)] font-medium leading-[1.15] tracking-[-0.015em] text-balance">What the API does, and does not, hand you</h2>
<div class="mb-6 grid grid-cols-[repeat(auto-fit,minmax(min(100%,12rem),1fr))] gap-4">
<ul class="grid gap-2 rounded-2xl bg-tpop20-yes/10 px-4 py-3.5" aria-label="Provided by popover">
<li class="relative min-w-0 pl-5 text-[13.5px] leading-normal before:absolute before:top-[6px] before:left-0.5 before:h-1.5 before:w-2.5 before:-rotate-45 before:border-b-2 before:border-l-2 before:border-tpop20-yes before:content-['']">Top layer</li>
<li class="relative min-w-0 pl-5 text-[13.5px] leading-normal before:absolute before:top-[6px] before:left-0.5 before:h-1.5 before:w-2.5 before:-rotate-45 before:border-b-2 before:border-l-2 before:border-tpop20-yes before:content-['']">Light dismiss</li>
<li class="relative min-w-0 pl-5 text-[13.5px] leading-normal before:absolute before:top-[6px] before:left-0.5 before:h-1.5 before:w-2.5 before:-rotate-45 before:border-b-2 before:border-l-2 before:border-tpop20-yes before:content-['']">Escape to close</li>
<li class="relative min-w-0 pl-5 text-[13.5px] leading-normal before:absolute before:top-[6px] before:left-0.5 before:h-1.5 before:w-2.5 before:-rotate-45 before:border-b-2 before:border-l-2 before:border-tpop20-yes before:content-['']">Focus back to the trigger</li>
</ul>
<ul class="grid gap-2 rounded-2xl bg-tpop20-no/9 px-4 py-3.5" aria-label="Not provided by popover">
<li class="relative min-w-0 pl-5 text-[13.5px] leading-normal before:absolute before:top-[10px] before:left-0.5 before:h-0.5 before:w-2.5 before:bg-tpop20-no before:content-['']">A role</li>
<li class="relative min-w-0 pl-5 text-[13.5px] leading-normal before:absolute before:top-[10px] before:left-0.5 before:h-0.5 before:w-2.5 before:bg-tpop20-no before:content-['']">An accessible name</li>
<li class="relative min-w-0 pl-5 text-[13.5px] leading-normal before:absolute before:top-[10px] before:left-0.5 before:h-0.5 before:w-2.5 before:bg-tpop20-no before:content-['']">A described-by relationship</li>
<li class="relative min-w-0 pl-5 text-[13.5px] leading-normal before:absolute before:top-[10px] before:left-0.5 before:h-0.5 before:w-2.5 before:bg-tpop20-no before:content-['']">Any keyboard model inside</li>
</ul>
</div>
<div class="flex flex-wrap items-center justify-between gap-3.5 border-t border-tpop20-rule pt-4.5">
<p class="flex min-w-0 items-center gap-2 text-[14.5px] font-semibold">
Retention window
<button id="tpop-20-trigger" type="button" popovertarget="tpop-20-hint" aria-describedby="tpop-20-hint" aria-label="What the retention window means" class="inline-flex size-11 cursor-pointer items-center justify-center rounded-full border border-tpop20-rule text-tpop20-mut transition-colors [anchor-name:--tpop20-hint] hover:bg-tpop20-sepia hover:text-tpop20-ink focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-tpop20-yes group-has-[#tpop-20-hint:popover-open]/root:border-tpop20-ink group-has-[#tpop-20-hint:popover-open]/root:text-tpop20-ink">
<svg viewBox="0 0 20 20" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.7" aria-hidden="true"><circle cx="10" cy="10" r="7.2"/><path d="M10 9v5M10 6.3v.8"/></svg>
</button>
</p>
<button type="button" popovertarget="tpop-20-menu" aria-haspopup="menu" class="inline-flex min-h-11 cursor-pointer items-center gap-2 rounded-full bg-tpop20-ink px-4 text-[13px] font-semibold text-tpop20-paper transition-colors [anchor-name:--tpop20-menu] hover:bg-tpop20-ink/90 focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-tpop20-yes">
Row actions
<svg class="transition-transform duration-200 motion-reduce:transition-none group-has-[#tpop-20-menu:popover-open]/root:rotate-180" viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M4 9.5 8 5.5 12 9.5"/></svg>
</button>
</div>
<p class="mt-4.5 text-center text-[12.5px] leading-relaxed text-tpop20-mut">The hint is described-by. The menu is role=menu. Neither came for free.</p>
</div>
</div>
<div popover="auto" id="tpop-20-hint" class="fixed m-auto w-[min(17rem,calc(100vw-32px))] rounded-2xl border border-tpop20-rule bg-tpop20-paper px-4 py-3 text-[13px] leading-normal text-tpop20-ink opacity-0 shadow-[0_18px_38px_-24px_oklch(0.3_0.03_50/0.5)] text-pretty transition-[opacity,translate,display,overlay] duration-150 ease-out transition-discrete [position-anchor:--tpop20-hint] [position-area:block-start_center] [justify-self:anchor-center] [position-try-fallbacks:flip-block] open:opacity-100 starting:open:translate-y-1 starting:open:opacity-0 motion-reduce:transition-none supports-[anchor-name:--a]:inset-auto supports-[anchor-name:--a]:m-0 supports-[anchor-name:--a]:mb-2.5">Deleted records stay recoverable for <strong class="font-semibold">30 days</strong>, then are purged nightly.</div>
<div popover="auto" id="tpop-20-menu" role="menu" aria-label="Row actions" class="fixed m-auto w-[min(14.5rem,calc(100vw-32px))] gap-0.5 rounded-3xl border border-tpop20-rule bg-tpop20-paper p-1.5 text-tpop20-ink opacity-0 shadow-[0_18px_38px_-24px_oklch(0.3_0.03_50/0.5)] transition-[opacity,translate,display,overlay] duration-150 ease-out transition-discrete [position-anchor:--tpop20-menu] [position-area:block-start_span-inline-start] [position-try-fallbacks:flip-block,flip-inline] open:grid open:opacity-100 starting:open:translate-y-1 starting:open:opacity-0 motion-reduce:transition-none supports-[anchor-name:--a]:inset-auto supports-[anchor-name:--a]:m-0 supports-[anchor-name:--a]:mb-2.5">
<button type="button" role="menuitem" class="min-h-11 w-full min-w-0 cursor-pointer rounded-full px-3.5 text-left text-[13.5px] font-medium transition-colors hover:bg-tpop20-sepia focus-visible:outline-3 focus-visible:-outline-offset-3 focus-visible:outline-tpop20-yes">Extend retention</button>
<button type="button" role="menuitem" class="min-h-11 w-full min-w-0 cursor-pointer rounded-full px-3.5 text-left text-[13.5px] font-medium transition-colors hover:bg-tpop20-sepia focus-visible:outline-3 focus-visible:-outline-offset-3 focus-visible:outline-tpop20-yes">Export audit trail</button>
<span role="separator" class="mx-3 my-0.5 block h-px bg-tpop20-rule"></span>
<button type="button" role="menuitem" class="min-h-11 w-full min-w-0 cursor-pointer rounded-full px-3.5 text-left text-[13.5px] font-medium text-tpop20-no transition-colors hover:bg-tpop20-no/9 focus-visible:outline-3 focus-visible:-outline-offset-3 focus-visible:outline-tpop20-no">Purge now</button>
</div>
</section>
```
## CSS
```css
@import "tailwindcss";
@theme {
--color-tpop20-sepia: #f0e6da;
--color-tpop20-sepia: oklch(0.93 0.02 75);
--color-tpop20-paper: #fbf6ed;
--color-tpop20-paper: oklch(0.975 0.013 80);
--color-tpop20-ink: #2f241e;
--color-tpop20-ink: oklch(0.27 0.02 50);
--color-tpop20-mut: #68574a;
--color-tpop20-mut: oklch(0.47 0.03 60);
--color-tpop20-rule: #ddcfbc;
--color-tpop20-rule: oklch(0.86 0.03 75);
--color-tpop20-yes: #476536;
--color-tpop20-yes: oklch(0.47 0.08 135);
--color-tpop20-no: #983d1f;
--color-tpop20-no: oklch(0.48 0.13 38);
--font-tpop20-serif: 'Newsreader', Georgia, serif;
--font-tpop20-sans: 'Plus Jakarta Sans', system-ui, sans-serif;
}
/* Theme dial: 0 = light, 1 = dark. Registered so the root can transition it. */
@property --tpop20-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-20 {
width: 100%;
min-height: 100svh;
--color-tpop20-sepia: color-mix(in oklch, oklch(0.17 0.014 60) calc(var(--tpop20-d) * 100%), oklch(0.93 0.02 75));
--color-tpop20-paper: color-mix(in oklch, oklch(0.215 0.016 60) calc(var(--tpop20-d) * 100%), oklch(0.975 0.013 80));
--color-tpop20-ink: color-mix(in oklch, oklch(0.94 0.016 75) calc(var(--tpop20-d) * 100%), oklch(0.27 0.02 50));
--color-tpop20-mut: color-mix(in oklch, oklch(0.74 0.03 65) calc(var(--tpop20-d) * 100%), oklch(0.47 0.03 60));
--color-tpop20-rule: color-mix(in oklch, oklch(0.33 0.022 60) calc(var(--tpop20-d) * 100%), oklch(0.86 0.03 75));
--color-tpop20-yes: color-mix(in oklch, oklch(0.78 0.1 135) calc(var(--tpop20-d) * 100%), oklch(0.47 0.08 135));
--color-tpop20-no: color-mix(in oklch, oklch(0.76 0.13 38) calc(var(--tpop20-d) * 100%), oklch(0.48 0.13 38));
}
```How this works
Start with the ledger. Popover gives the top layer, light dismiss, Escape and focus return. It gives no role, no accessible name, no description and no keyboard model inside the panel.
<button popovertarget="tpop-20-hint" aria-describedby="tpop-20-hint"
aria-label="What the retention window means">…</button>
<div popover id="tpop-20-hint">Deleted records stay recoverable…</div>
<div popover id="tpop-20-menu" role="menu" aria-label="Row actions"
class="… open:grid">Pick the role from purpose, not shape. role="tooltip" describes a hover/focus hint (demo 12). A panel you click open is not a tooltip; a panel of choices is a menu; a panel of prose needs a label, not a role.
Don't hardcode aria-expanded. Engines compute it for invokers; a literal value is a lie the moment the panel opens. Names and targets finish the job: every icon button is labelled, every target is 44px, every ring is 3px.
Make it yours
- Hear the difference: add
role="tooltip"to the hint and compare in a screen reader. - Selectable name: replace
aria-labelwith asr-onlyspan inside the button. - Two descriptions:
aria-describedby="tpop-20-hint tpop-20-policy".
Gotchas — read before shipping
role="tooltip"on a click-triggered panel misdescribes it.- A popover has no implicit role or name; an unnamed panel announces as anonymous content.
grid/flexon a closed popover makes it permanently visible — useopen:grid.
Browser support
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| 125+ | 26+ | 131+ | 125+ |
Focus return and light dismiss ship with popover; implicit invoker expanded state is newer and inconsistent, so nothing visual depends on it. The floor is anchoring; the ARIA wiring has no version requirement.