16 / 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 Popover Fallback and Polyfill
Popover and anchor positioning ship on different timelines, so one test isn't enough. The script detects three features — popover, anchor positioning and discrete transitions — writes each to a data-* attribute on the root, and every visual reads them through group-data-[anchor=yes]/root: variants. The panel layers up: a plain positioned box with no API, popover dismissal on top, anchoring on top of that.
Untitled UI Pro: 5,000+ React + Tailwind CSS v4 components · from $349 one-time
Untitled UI React · Affiliate link
The code
<section data-tpop16-root data-popover="unknown" data-anchor="unknown" data-discrete="unknown" class="tpop-16 transition-[--tpop16-d] duration-300 ease-in-out motion-reduce:transition-none dark:[--tpop16-d:1] [[data-theme=dark]_&]:[--tpop16-d:1] [[data-theme=light]_&]:[--tpop16-d:0] group/root relative w-full min-h-svh bg-tpop16-bg px-4 py-9.5 font-tpop16-sans text-tpop16-ink" aria-label="Popover fallback and polyfill demo">
<div class="grid min-h-[calc(100svh-76px)] max-w-full place-items-center">
<div class="w-full min-w-0 max-w-[27rem] rounded-xl border border-tpop16-rule bg-tpop16-s1 px-4.5 pt-5 pb-4.5 shadow-[0_16px_36px_-28px_oklch(0.2_0.02_255/0.5)]">
<p class="mb-2 text-[10.5px] font-bold uppercase tracking-[0.2em] text-tpop16-mut">Support probe</p>
<h2 class="mb-4 text-[clamp(20px,4.4vw,25px)] font-semibold leading-tight tracking-[-0.02em]">Three features, three tests</h2>
<dl class="mb-4 grid gap-px overflow-hidden rounded-lg border border-tpop16-rule bg-tpop16-rule">
<div class="flex min-w-0 flex-wrap items-center justify-between gap-2 bg-tpop16-s2 px-3 py-2.5">
<dt class="min-w-0 font-tpop16-mono text-[12.5px] font-semibold">popover attribute</dt>
<dd id="tpop-16-r1" class="rounded-full bg-tpop16-rule/60 px-2 py-0.5 text-[11px] font-bold uppercase tracking-[0.06em] text-tpop16-mut group-data-[popover=yes]/root:bg-tpop16-ok/12 group-data-[popover=yes]/root:text-tpop16-ok group-data-[popover=no]/root:bg-tpop16-no/12 group-data-[popover=no]/root:text-tpop16-no">checking…</dd>
</div>
<div class="flex min-w-0 flex-wrap items-center justify-between gap-2 bg-tpop16-s2 px-3 py-2.5">
<dt class="min-w-0 font-tpop16-mono text-[12.5px] font-semibold">anchor-name</dt>
<dd id="tpop-16-r2" class="rounded-full bg-tpop16-rule/60 px-2 py-0.5 text-[11px] font-bold uppercase tracking-[0.06em] text-tpop16-mut group-data-[anchor=yes]/root:bg-tpop16-ok/12 group-data-[anchor=yes]/root:text-tpop16-ok group-data-[anchor=no]/root:bg-tpop16-no/12 group-data-[anchor=no]/root:text-tpop16-no">checking…</dd>
</div>
<div class="flex min-w-0 flex-wrap items-center justify-between gap-2 bg-tpop16-s2 px-3 py-2.5">
<dt class="min-w-0 font-tpop16-mono text-[12.5px] font-semibold">allow-discrete</dt>
<dd id="tpop-16-r3" class="rounded-full bg-tpop16-rule/60 px-2 py-0.5 text-[11px] font-bold uppercase tracking-[0.06em] text-tpop16-mut group-data-[discrete=yes]/root:bg-tpop16-ok/12 group-data-[discrete=yes]/root:text-tpop16-ok group-data-[discrete=no]/root:bg-tpop16-no/12 group-data-[discrete=no]/root:text-tpop16-no">checking…</dd>
</div>
</dl>
<button id="tpop-16-trigger" type="button" popovertarget="tpop-16-panel" aria-expanded="false" class="min-h-11 w-full cursor-pointer rounded-lg bg-tpop16-ink px-3.5 text-[13.5px] font-semibold text-tpop16-s1 transition-colors [anchor-name:--tpop16-a] hover:bg-tpop16-ink/88 focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-tpop16-ok aria-expanded:bg-tpop16-ink/80">Open the layered panel</button>
<p class="mt-3 text-[12.5px] leading-relaxed text-tpop16-mut">The panel is a plain positioned box first, a popover second, and anchored third.</p>
</div>
</div>
<div popover="auto" id="tpop-16-panel" role="group" aria-label="Layered panel" class="absolute inset-x-4 bottom-4 m-0 w-auto max-w-[calc(100%-32px)] rounded-xl border border-tpop16-rule bg-tpop16-s1 p-3.5 text-tpop16-ink opacity-100 shadow-[0_18px_36px_-22px_oklch(0.2_0.02_255/0.5)] transition-[opacity,translate,display,overlay] duration-150 ease-out transition-discrete [position-anchor:--tpop16-a] [position-area:block-end_span-inline-end] [position-try-fallbacks:flip-block] starting:open:-translate-y-1 starting:open:opacity-0 motion-reduce:transition-none supports-[anchor-name:--a]:fixed supports-[anchor-name:--a]:inset-auto supports-[anchor-name:--a]:mt-2 supports-[anchor-name:--a]:w-[min(21rem,calc(100vw-32px))] supports-[anchor-name:--a]:max-w-[calc(100vw-32px)]">
<p class="mb-2.5 text-[11px] font-bold uppercase tracking-[0.16em] text-tpop16-mut">Layer report</p>
<ul class="grid gap-2">
<li class="flex min-w-0 items-start gap-2.5 text-[13px] leading-normal"><span aria-hidden="true" class="mt-1.5 size-1.5 flex-none rounded-full bg-tpop16-ok"></span><span><strong class="font-semibold">Baseline</strong> · absolutely positioned, no top layer</span></li>
<li class="flex min-w-0 items-start gap-2.5 text-[13px] leading-normal opacity-45 group-data-[popover=yes]/root:opacity-100"><span aria-hidden="true" class="mt-1.5 size-1.5 flex-none rounded-full bg-tpop16-rule group-data-[popover=yes]/root:bg-tpop16-ok"></span><span><strong class="font-semibold">Enhanced</strong> · popover owns dismissal and the top layer</span></li>
<li class="flex min-w-0 items-start gap-2.5 text-[13px] leading-normal opacity-45 group-data-[anchor=yes]/root:opacity-100"><span aria-hidden="true" class="mt-1.5 size-1.5 flex-none rounded-full bg-tpop16-rule group-data-[anchor=yes]/root:bg-tpop16-ok"></span><span><strong class="font-semibold">Enhanced</strong> · anchor positioning owns placement</span></li>
<li class="flex min-w-0 items-start gap-2.5 text-[13px] leading-normal opacity-45 group-data-[discrete=yes]/root:opacity-100"><span aria-hidden="true" class="mt-1.5 size-1.5 flex-none rounded-full bg-tpop16-rule group-data-[discrete=yes]/root:bg-tpop16-ok"></span><span><strong class="font-semibold">Enhanced</strong> · allow-discrete owns the entry fade</span></li>
</ul>
<p class="mt-3 border-t border-tpop16-rule pt-2.5 text-[12px] leading-normal text-tpop16-mut">Polyfill for the attribute: <span class="font-tpop16-mono text-tpop16-ink">popover-polyfill</span> from Open UI. No equivalent exists for anchoring.</p>
</div>
</section><section data-tpop16-root data-popover="unknown" data-anchor="unknown" data-discrete="unknown" class="tpop-16 transition-[--tpop16-d] duration-300 ease-in-out motion-reduce:transition-none dark:[--tpop16-d:1] [[data-theme=dark]_&]:[--tpop16-d:1] [[data-theme=light]_&]:[--tpop16-d:0] group/root relative w-full min-h-svh bg-tpop16-bg px-4 py-9.5 font-tpop16-sans text-tpop16-ink" aria-label="Popover fallback and polyfill demo">
<div class="grid min-h-[calc(100svh-76px)] max-w-full place-items-center">
<div class="w-full min-w-0 max-w-[27rem] rounded-xl border border-tpop16-rule bg-tpop16-s1 px-4.5 pt-5 pb-4.5 shadow-[0_16px_36px_-28px_oklch(0.2_0.02_255/0.5)]">
<p class="mb-2 text-[10.5px] font-bold uppercase tracking-[0.2em] text-tpop16-mut">Support probe</p>
<h2 class="mb-4 text-[clamp(20px,4.4vw,25px)] font-semibold leading-tight tracking-[-0.02em]">Three features, three tests</h2>
<dl class="mb-4 grid gap-px overflow-hidden rounded-lg border border-tpop16-rule bg-tpop16-rule">
<div class="flex min-w-0 flex-wrap items-center justify-between gap-2 bg-tpop16-s2 px-3 py-2.5">
<dt class="min-w-0 font-tpop16-mono text-[12.5px] font-semibold">popover attribute</dt>
<dd id="tpop-16-r1" class="rounded-full bg-tpop16-rule/60 px-2 py-0.5 text-[11px] font-bold uppercase tracking-[0.06em] text-tpop16-mut group-data-[popover=yes]/root:bg-tpop16-ok/12 group-data-[popover=yes]/root:text-tpop16-ok group-data-[popover=no]/root:bg-tpop16-no/12 group-data-[popover=no]/root:text-tpop16-no">checking…</dd>
</div>
<div class="flex min-w-0 flex-wrap items-center justify-between gap-2 bg-tpop16-s2 px-3 py-2.5">
<dt class="min-w-0 font-tpop16-mono text-[12.5px] font-semibold">anchor-name</dt>
<dd id="tpop-16-r2" class="rounded-full bg-tpop16-rule/60 px-2 py-0.5 text-[11px] font-bold uppercase tracking-[0.06em] text-tpop16-mut group-data-[anchor=yes]/root:bg-tpop16-ok/12 group-data-[anchor=yes]/root:text-tpop16-ok group-data-[anchor=no]/root:bg-tpop16-no/12 group-data-[anchor=no]/root:text-tpop16-no">checking…</dd>
</div>
<div class="flex min-w-0 flex-wrap items-center justify-between gap-2 bg-tpop16-s2 px-3 py-2.5">
<dt class="min-w-0 font-tpop16-mono text-[12.5px] font-semibold">allow-discrete</dt>
<dd id="tpop-16-r3" class="rounded-full bg-tpop16-rule/60 px-2 py-0.5 text-[11px] font-bold uppercase tracking-[0.06em] text-tpop16-mut group-data-[discrete=yes]/root:bg-tpop16-ok/12 group-data-[discrete=yes]/root:text-tpop16-ok group-data-[discrete=no]/root:bg-tpop16-no/12 group-data-[discrete=no]/root:text-tpop16-no">checking…</dd>
</div>
</dl>
<button id="tpop-16-trigger" type="button" popovertarget="tpop-16-panel" aria-expanded="false" class="min-h-11 w-full cursor-pointer rounded-lg bg-tpop16-ink px-3.5 text-[13.5px] font-semibold text-tpop16-s1 transition-colors [anchor-name:--tpop16-a] hover:bg-tpop16-ink/88 focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-tpop16-ok aria-expanded:bg-tpop16-ink/80">Open the layered panel</button>
<p class="mt-3 text-[12.5px] leading-relaxed text-tpop16-mut">The panel is a plain positioned box first, a popover second, and anchored third.</p>
</div>
</div>
<div popover="auto" id="tpop-16-panel" role="group" aria-label="Layered panel" class="absolute inset-x-4 bottom-4 m-0 w-auto max-w-[calc(100%-32px)] rounded-xl border border-tpop16-rule bg-tpop16-s1 p-3.5 text-tpop16-ink opacity-100 shadow-[0_18px_36px_-22px_oklch(0.2_0.02_255/0.5)] transition-[opacity,translate,display,overlay] duration-150 ease-out transition-discrete [position-anchor:--tpop16-a] [position-area:block-end_span-inline-end] [position-try-fallbacks:flip-block] starting:open:-translate-y-1 starting:open:opacity-0 motion-reduce:transition-none supports-[anchor-name:--a]:fixed supports-[anchor-name:--a]:inset-auto supports-[anchor-name:--a]:mt-2 supports-[anchor-name:--a]:w-[min(21rem,calc(100vw-32px))] supports-[anchor-name:--a]:max-w-[calc(100vw-32px)]">
<p class="mb-2.5 text-[11px] font-bold uppercase tracking-[0.16em] text-tpop16-mut">Layer report</p>
<ul class="grid gap-2">
<li class="flex min-w-0 items-start gap-2.5 text-[13px] leading-normal"><span aria-hidden="true" class="mt-1.5 size-1.5 flex-none rounded-full bg-tpop16-ok"></span><span><strong class="font-semibold">Baseline</strong> · absolutely positioned, no top layer</span></li>
<li class="flex min-w-0 items-start gap-2.5 text-[13px] leading-normal opacity-45 group-data-[popover=yes]/root:opacity-100"><span aria-hidden="true" class="mt-1.5 size-1.5 flex-none rounded-full bg-tpop16-rule group-data-[popover=yes]/root:bg-tpop16-ok"></span><span><strong class="font-semibold">Enhanced</strong> · popover owns dismissal and the top layer</span></li>
<li class="flex min-w-0 items-start gap-2.5 text-[13px] leading-normal opacity-45 group-data-[anchor=yes]/root:opacity-100"><span aria-hidden="true" class="mt-1.5 size-1.5 flex-none rounded-full bg-tpop16-rule group-data-[anchor=yes]/root:bg-tpop16-ok"></span><span><strong class="font-semibold">Enhanced</strong> · anchor positioning owns placement</span></li>
<li class="flex min-w-0 items-start gap-2.5 text-[13px] leading-normal opacity-45 group-data-[discrete=yes]/root:opacity-100"><span aria-hidden="true" class="mt-1.5 size-1.5 flex-none rounded-full bg-tpop16-rule group-data-[discrete=yes]/root:bg-tpop16-ok"></span><span><strong class="font-semibold">Enhanced</strong> · allow-discrete owns the entry fade</span></li>
</ul>
<p class="mt-3 border-t border-tpop16-rule pt-2.5 text-[12px] leading-normal text-tpop16-mut">Polyfill for the attribute: <span class="font-tpop16-mono text-tpop16-ink">popover-polyfill</span> from Open UI. No equivalent exists for anchoring.</p>
</div>
</section>@import "tailwindcss";
@theme {
--color-tpop16-bg: #eef0f3;
--color-tpop16-bg: oklch(0.955 0.005 255);
--color-tpop16-s1: #ffffff;
--color-tpop16-s1: oklch(1 0 0);
--color-tpop16-s2: #f7f8fa;
--color-tpop16-s2: oklch(0.98 0.003 255);
--color-tpop16-ink: #171b22;
--color-tpop16-ink: oklch(0.22 0.015 260);
--color-tpop16-mut: #575e69;
--color-tpop16-mut: oklch(0.48 0.02 258);
--color-tpop16-rule: #d7dbe1;
--color-tpop16-rule: oklch(0.89 0.01 258);
--color-tpop16-ok: #006647;
--color-tpop16-ok: oklch(0.45 0.1 165);
--color-tpop16-no: #99351b;
--color-tpop16-no: oklch(0.47 0.14 35);
--font-tpop16-sans: 'Geist', system-ui, sans-serif;
--font-tpop16-mono: 'Geist Mono', ui-monospace, monospace;
}
/* Theme dial: 0 = light, 1 = dark. Registered so the root can transition it. */
@property --tpop16-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-16 {
width: 100%;
min-height: 100svh;
--color-tpop16-bg: color-mix(in oklch, oklch(0.16 0.01 258) calc(var(--tpop16-d) * 100%), oklch(0.955 0.005 255));
--color-tpop16-s1: color-mix(in oklch, oklch(0.21 0.012 258) calc(var(--tpop16-d) * 100%), oklch(1 0 0));
--color-tpop16-s2: color-mix(in oklch, oklch(0.185 0.011 258) calc(var(--tpop16-d) * 100%), oklch(0.98 0.003 255));
--color-tpop16-ink: color-mix(in oklch, oklch(0.94 0.008 258) calc(var(--tpop16-d) * 100%), oklch(0.22 0.015 260));
--color-tpop16-mut: color-mix(in oklch, oklch(0.73 0.02 258) calc(var(--tpop16-d) * 100%), oklch(0.48 0.02 258));
--color-tpop16-rule: color-mix(in oklch, oklch(0.3 0.014 258) calc(var(--tpop16-d) * 100%), oklch(0.89 0.01 258));
--color-tpop16-ok: color-mix(in oklch, oklch(0.78 0.11 165) calc(var(--tpop16-d) * 100%), oklch(0.45 0.1 165));
--color-tpop16-no: color-mix(in oklch, oklch(0.76 0.13 35) calc(var(--tpop16-d) * 100%), oklch(0.47 0.14 35));
}@import "tailwindcss";
@theme {
--color-tpop16-bg: #eef0f3;
--color-tpop16-bg: oklch(0.955 0.005 255);
--color-tpop16-s1: #ffffff;
--color-tpop16-s1: oklch(1 0 0);
--color-tpop16-s2: #f7f8fa;
--color-tpop16-s2: oklch(0.98 0.003 255);
--color-tpop16-ink: #171b22;
--color-tpop16-ink: oklch(0.22 0.015 260);
--color-tpop16-mut: #575e69;
--color-tpop16-mut: oklch(0.48 0.02 258);
--color-tpop16-rule: #d7dbe1;
--color-tpop16-rule: oklch(0.89 0.01 258);
--color-tpop16-ok: #006647;
--color-tpop16-ok: oklch(0.45 0.1 165);
--color-tpop16-no: #99351b;
--color-tpop16-no: oklch(0.47 0.14 35);
--font-tpop16-sans: 'Geist', system-ui, sans-serif;
--font-tpop16-mono: 'Geist Mono', ui-monospace, monospace;
}
/* Theme dial: 0 = light, 1 = dark. Registered so the root can transition it. */
@property --tpop16-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-16 {
width: 100%;
min-height: 100svh;
--color-tpop16-bg: color-mix(in oklch, oklch(0.16 0.01 258) calc(var(--tpop16-d) * 100%), oklch(0.955 0.005 255));
--color-tpop16-s1: color-mix(in oklch, oklch(0.21 0.012 258) calc(var(--tpop16-d) * 100%), oklch(1 0 0));
--color-tpop16-s2: color-mix(in oklch, oklch(0.185 0.011 258) calc(var(--tpop16-d) * 100%), oklch(0.98 0.003 255));
--color-tpop16-ink: color-mix(in oklch, oklch(0.94 0.008 258) calc(var(--tpop16-d) * 100%), oklch(0.22 0.015 260));
--color-tpop16-mut: color-mix(in oklch, oklch(0.73 0.02 258) calc(var(--tpop16-d) * 100%), oklch(0.48 0.02 258));
--color-tpop16-rule: color-mix(in oklch, oklch(0.3 0.014 258) calc(var(--tpop16-d) * 100%), oklch(0.89 0.01 258));
--color-tpop16-ok: color-mix(in oklch, oklch(0.78 0.11 165) calc(var(--tpop16-d) * 100%), oklch(0.45 0.1 165));
--color-tpop16-no: color-mix(in oklch, oklch(0.76 0.13 35) calc(var(--tpop16-d) * 100%), oklch(0.47 0.14 35));
}(() => {
const root = document.querySelector('[data-tpop16-root]');
if (!root || root.dataset.wired) return;
root.dataset.wired = '1';
const hasPopover = HTMLElement.prototype.hasOwnProperty('popover');
const hasAnchor = !!(window.CSS && CSS.supports('anchor-name: --a'));
const hasDiscrete = !!(window.CSS && CSS.supports('transition-behavior: allow-discrete'));
root.dataset.popover = hasPopover ? 'yes' : 'no';
root.dataset.anchor = hasAnchor ? 'yes' : 'no';
root.dataset.discrete = hasDiscrete ? 'yes' : 'no';
document.getElementById('tpop-16-r1').textContent = hasPopover ? 'supported' : 'missing · polyfill';
document.getElementById('tpop-16-r2').textContent = hasAnchor ? 'supported' : 'missing · fallback';
document.getElementById('tpop-16-r3').textContent = hasDiscrete ? 'supported' : 'missing · no fade';
const panel = document.getElementById('tpop-16-panel');
const trigger = document.getElementById('tpop-16-trigger');
if (hasPopover) {
panel.addEventListener('toggle', (e) => trigger.setAttribute('aria-expanded', String(e.newState === 'open')));
} else {
panel.hidden = true;
trigger.addEventListener('click', () => {
panel.hidden = !panel.hidden;
trigger.setAttribute('aria-expanded', String(!panel.hidden));
});
}
})();(() => {
const root = document.querySelector('[data-tpop16-root]');
if (!root || root.dataset.wired) return;
root.dataset.wired = '1';
const hasPopover = HTMLElement.prototype.hasOwnProperty('popover');
const hasAnchor = !!(window.CSS && CSS.supports('anchor-name: --a'));
const hasDiscrete = !!(window.CSS && CSS.supports('transition-behavior: allow-discrete'));
root.dataset.popover = hasPopover ? 'yes' : 'no';
root.dataset.anchor = hasAnchor ? 'yes' : 'no';
root.dataset.discrete = hasDiscrete ? 'yes' : 'no';
document.getElementById('tpop-16-r1').textContent = hasPopover ? 'supported' : 'missing · polyfill';
document.getElementById('tpop-16-r2').textContent = hasAnchor ? 'supported' : 'missing · fallback';
document.getElementById('tpop-16-r3').textContent = hasDiscrete ? 'supported' : 'missing · no fade';
const panel = document.getElementById('tpop-16-panel');
const trigger = document.getElementById('tpop-16-trigger');
if (hasPopover) {
panel.addEventListener('toggle', (e) => trigger.setAttribute('aria-expanded', String(e.newState === 'open')));
} else {
panel.hidden = true;
trigger.addEventListener('click', () => {
panel.hidden = !panel.hidden;
trigger.setAttribute('aria-expanded', String(!panel.hidden));
});
}
})();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-tpop16-bg: #eef0f3;
--color-tpop16-bg: oklch(0.955 0.005 255);
--color-tpop16-s1: #ffffff;
--color-tpop16-s1: oklch(1 0 0);
--color-tpop16-s2: #f7f8fa;
--color-tpop16-s2: oklch(0.98 0.003 255);
--color-tpop16-ink: #171b22;
--color-tpop16-ink: oklch(0.22 0.015 260);
--color-tpop16-mut: #575e69;
--color-tpop16-mut: oklch(0.48 0.02 258);
--color-tpop16-rule: #d7dbe1;
--color-tpop16-rule: oklch(0.89 0.01 258);
--color-tpop16-ok: #006647;
--color-tpop16-ok: oklch(0.45 0.1 165);
--color-tpop16-no: #99351b;
--color-tpop16-no: oklch(0.47 0.14 35);
--font-tpop16-sans: 'Geist', system-ui, sans-serif;
--font-tpop16-mono: 'Geist Mono', ui-monospace, monospace;
}
/* Theme dial: 0 = light, 1 = dark. Registered so the root can transition it. */
@property --tpop16-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-16 {
width: 100%;
min-height: 100svh;
--color-tpop16-bg: color-mix(in oklch, oklch(0.16 0.01 258) calc(var(--tpop16-d) * 100%), oklch(0.955 0.005 255));
--color-tpop16-s1: color-mix(in oklch, oklch(0.21 0.012 258) calc(var(--tpop16-d) * 100%), oklch(1 0 0));
--color-tpop16-s2: color-mix(in oklch, oklch(0.185 0.011 258) calc(var(--tpop16-d) * 100%), oklch(0.98 0.003 255));
--color-tpop16-ink: color-mix(in oklch, oklch(0.94 0.008 258) calc(var(--tpop16-d) * 100%), oklch(0.22 0.015 260));
--color-tpop16-mut: color-mix(in oklch, oklch(0.73 0.02 258) calc(var(--tpop16-d) * 100%), oklch(0.48 0.02 258));
--color-tpop16-rule: color-mix(in oklch, oklch(0.3 0.014 258) calc(var(--tpop16-d) * 100%), oklch(0.89 0.01 258));
--color-tpop16-ok: color-mix(in oklch, oklch(0.78 0.11 165) calc(var(--tpop16-d) * 100%), oklch(0.45 0.1 165));
--color-tpop16-no: color-mix(in oklch, oklch(0.76 0.13 35) calc(var(--tpop16-d) * 100%), oklch(0.47 0.14 35));
}@import "tailwindcss";
@theme {
--color-tpop16-bg: #eef0f3;
--color-tpop16-bg: oklch(0.955 0.005 255);
--color-tpop16-s1: #ffffff;
--color-tpop16-s1: oklch(1 0 0);
--color-tpop16-s2: #f7f8fa;
--color-tpop16-s2: oklch(0.98 0.003 255);
--color-tpop16-ink: #171b22;
--color-tpop16-ink: oklch(0.22 0.015 260);
--color-tpop16-mut: #575e69;
--color-tpop16-mut: oklch(0.48 0.02 258);
--color-tpop16-rule: #d7dbe1;
--color-tpop16-rule: oklch(0.89 0.01 258);
--color-tpop16-ok: #006647;
--color-tpop16-ok: oklch(0.45 0.1 165);
--color-tpop16-no: #99351b;
--color-tpop16-no: oklch(0.47 0.14 35);
--font-tpop16-sans: 'Geist', system-ui, sans-serif;
--font-tpop16-mono: 'Geist Mono', ui-monospace, monospace;
}
/* Theme dial: 0 = light, 1 = dark. Registered so the root can transition it. */
@property --tpop16-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-16 {
width: 100%;
min-height: 100svh;
--color-tpop16-bg: color-mix(in oklch, oklch(0.16 0.01 258) calc(var(--tpop16-d) * 100%), oklch(0.955 0.005 255));
--color-tpop16-s1: color-mix(in oklch, oklch(0.21 0.012 258) calc(var(--tpop16-d) * 100%), oklch(1 0 0));
--color-tpop16-s2: color-mix(in oklch, oklch(0.185 0.011 258) calc(var(--tpop16-d) * 100%), oklch(0.98 0.003 255));
--color-tpop16-ink: color-mix(in oklch, oklch(0.94 0.008 258) calc(var(--tpop16-d) * 100%), oklch(0.22 0.015 260));
--color-tpop16-mut: color-mix(in oklch, oklch(0.73 0.02 258) calc(var(--tpop16-d) * 100%), oklch(0.48 0.02 258));
--color-tpop16-rule: color-mix(in oklch, oklch(0.3 0.014 258) calc(var(--tpop16-d) * 100%), oklch(0.89 0.01 258));
--color-tpop16-ok: color-mix(in oklch, oklch(0.78 0.11 165) calc(var(--tpop16-d) * 100%), oklch(0.45 0.1 165));
--color-tpop16-no: color-mix(in oklch, oklch(0.76 0.13 35) calc(var(--tpop16-d) * 100%), oklch(0.47 0.14 35));
}<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">
<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.
.pop-16[data-popover="yes"] #pop-16-r1descendant rules becamegroup-data-[popover=yes]/root:variants on the readouts themselves — the script still only writesdata-*attributes.- The absolute baseline → fixed anchored upgrade became
absolute …plussupports-[anchor-name:--a]:fixed; preflight's[hidden]{display:none !important}covers the no-popover toggle. - UI upgrade from the CSS original: a third probe for
transition-behavior: allow-discrete(the original suggested it as a customisation), and the layer report now lights each layer only when its feature is actually live, instead of listing all three regardless. - UI upgrade: readouts became coloured status pills, the trigger shows its expanded state via
aria-expanded:, the controller is an idempotent IIFE, and the panel fades in where discrete transitions exist. - UI upgrade — dark mode: the stage now follows
prefers-color-scheme(and adata-themeoverride on any ancestor) through one registered dial,--tpop16-d. The root rule re-declares each@themecolour ascolor-mix(dark calc(var(--tpop16-d)*100%), light), the root sets the dial withdark:[--tpop16-d:1], andtransition-[--tpop16-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 Popover Fallback and Polyfill
Source: https://codefronts.com/snippets/tailwind-css-popovers/tailwind-popover-fallback-polyfill/
Popover and anchor positioning ship on different timelines, so one test isn't enough. The script detects three features — popover, anchor positioning and discrete transitions — writes each to a data-* attribute on the root, and every visual reads them through group-data-[anchor=yes]/root: variants. The panel layers up: a plain positioned box with no API, popover dismissal on top, anchoring on top of that.
## HTML
```html
<section data-tpop16-root data-popover="unknown" data-anchor="unknown" data-discrete="unknown" class="tpop-16 transition-[--tpop16-d] duration-300 ease-in-out motion-reduce:transition-none dark:[--tpop16-d:1] [[data-theme=dark]_&]:[--tpop16-d:1] [[data-theme=light]_&]:[--tpop16-d:0] group/root relative w-full min-h-svh bg-tpop16-bg px-4 py-9.5 font-tpop16-sans text-tpop16-ink" aria-label="Popover fallback and polyfill demo">
<div class="grid min-h-[calc(100svh-76px)] max-w-full place-items-center">
<div class="w-full min-w-0 max-w-[27rem] rounded-xl border border-tpop16-rule bg-tpop16-s1 px-4.5 pt-5 pb-4.5 shadow-[0_16px_36px_-28px_oklch(0.2_0.02_255/0.5)]">
<p class="mb-2 text-[10.5px] font-bold uppercase tracking-[0.2em] text-tpop16-mut">Support probe</p>
<h2 class="mb-4 text-[clamp(20px,4.4vw,25px)] font-semibold leading-tight tracking-[-0.02em]">Three features, three tests</h2>
<dl class="mb-4 grid gap-px overflow-hidden rounded-lg border border-tpop16-rule bg-tpop16-rule">
<div class="flex min-w-0 flex-wrap items-center justify-between gap-2 bg-tpop16-s2 px-3 py-2.5">
<dt class="min-w-0 font-tpop16-mono text-[12.5px] font-semibold">popover attribute</dt>
<dd id="tpop-16-r1" class="rounded-full bg-tpop16-rule/60 px-2 py-0.5 text-[11px] font-bold uppercase tracking-[0.06em] text-tpop16-mut group-data-[popover=yes]/root:bg-tpop16-ok/12 group-data-[popover=yes]/root:text-tpop16-ok group-data-[popover=no]/root:bg-tpop16-no/12 group-data-[popover=no]/root:text-tpop16-no">checking…</dd>
</div>
<div class="flex min-w-0 flex-wrap items-center justify-between gap-2 bg-tpop16-s2 px-3 py-2.5">
<dt class="min-w-0 font-tpop16-mono text-[12.5px] font-semibold">anchor-name</dt>
<dd id="tpop-16-r2" class="rounded-full bg-tpop16-rule/60 px-2 py-0.5 text-[11px] font-bold uppercase tracking-[0.06em] text-tpop16-mut group-data-[anchor=yes]/root:bg-tpop16-ok/12 group-data-[anchor=yes]/root:text-tpop16-ok group-data-[anchor=no]/root:bg-tpop16-no/12 group-data-[anchor=no]/root:text-tpop16-no">checking…</dd>
</div>
<div class="flex min-w-0 flex-wrap items-center justify-between gap-2 bg-tpop16-s2 px-3 py-2.5">
<dt class="min-w-0 font-tpop16-mono text-[12.5px] font-semibold">allow-discrete</dt>
<dd id="tpop-16-r3" class="rounded-full bg-tpop16-rule/60 px-2 py-0.5 text-[11px] font-bold uppercase tracking-[0.06em] text-tpop16-mut group-data-[discrete=yes]/root:bg-tpop16-ok/12 group-data-[discrete=yes]/root:text-tpop16-ok group-data-[discrete=no]/root:bg-tpop16-no/12 group-data-[discrete=no]/root:text-tpop16-no">checking…</dd>
</div>
</dl>
<button id="tpop-16-trigger" type="button" popovertarget="tpop-16-panel" aria-expanded="false" class="min-h-11 w-full cursor-pointer rounded-lg bg-tpop16-ink px-3.5 text-[13.5px] font-semibold text-tpop16-s1 transition-colors [anchor-name:--tpop16-a] hover:bg-tpop16-ink/88 focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-tpop16-ok aria-expanded:bg-tpop16-ink/80">Open the layered panel</button>
<p class="mt-3 text-[12.5px] leading-relaxed text-tpop16-mut">The panel is a plain positioned box first, a popover second, and anchored third.</p>
</div>
</div>
<div popover="auto" id="tpop-16-panel" role="group" aria-label="Layered panel" class="absolute inset-x-4 bottom-4 m-0 w-auto max-w-[calc(100%-32px)] rounded-xl border border-tpop16-rule bg-tpop16-s1 p-3.5 text-tpop16-ink opacity-100 shadow-[0_18px_36px_-22px_oklch(0.2_0.02_255/0.5)] transition-[opacity,translate,display,overlay] duration-150 ease-out transition-discrete [position-anchor:--tpop16-a] [position-area:block-end_span-inline-end] [position-try-fallbacks:flip-block] starting:open:-translate-y-1 starting:open:opacity-0 motion-reduce:transition-none supports-[anchor-name:--a]:fixed supports-[anchor-name:--a]:inset-auto supports-[anchor-name:--a]:mt-2 supports-[anchor-name:--a]:w-[min(21rem,calc(100vw-32px))] supports-[anchor-name:--a]:max-w-[calc(100vw-32px)]">
<p class="mb-2.5 text-[11px] font-bold uppercase tracking-[0.16em] text-tpop16-mut">Layer report</p>
<ul class="grid gap-2">
<li class="flex min-w-0 items-start gap-2.5 text-[13px] leading-normal"><span aria-hidden="true" class="mt-1.5 size-1.5 flex-none rounded-full bg-tpop16-ok"></span><span><strong class="font-semibold">Baseline</strong> · absolutely positioned, no top layer</span></li>
<li class="flex min-w-0 items-start gap-2.5 text-[13px] leading-normal opacity-45 group-data-[popover=yes]/root:opacity-100"><span aria-hidden="true" class="mt-1.5 size-1.5 flex-none rounded-full bg-tpop16-rule group-data-[popover=yes]/root:bg-tpop16-ok"></span><span><strong class="font-semibold">Enhanced</strong> · popover owns dismissal and the top layer</span></li>
<li class="flex min-w-0 items-start gap-2.5 text-[13px] leading-normal opacity-45 group-data-[anchor=yes]/root:opacity-100"><span aria-hidden="true" class="mt-1.5 size-1.5 flex-none rounded-full bg-tpop16-rule group-data-[anchor=yes]/root:bg-tpop16-ok"></span><span><strong class="font-semibold">Enhanced</strong> · anchor positioning owns placement</span></li>
<li class="flex min-w-0 items-start gap-2.5 text-[13px] leading-normal opacity-45 group-data-[discrete=yes]/root:opacity-100"><span aria-hidden="true" class="mt-1.5 size-1.5 flex-none rounded-full bg-tpop16-rule group-data-[discrete=yes]/root:bg-tpop16-ok"></span><span><strong class="font-semibold">Enhanced</strong> · allow-discrete owns the entry fade</span></li>
</ul>
<p class="mt-3 border-t border-tpop16-rule pt-2.5 text-[12px] leading-normal text-tpop16-mut">Polyfill for the attribute: <span class="font-tpop16-mono text-tpop16-ink">popover-polyfill</span> from Open UI. No equivalent exists for anchoring.</p>
</div>
</section>
```
## CSS
```css
@import "tailwindcss";
@theme {
--color-tpop16-bg: #eef0f3;
--color-tpop16-bg: oklch(0.955 0.005 255);
--color-tpop16-s1: #ffffff;
--color-tpop16-s1: oklch(1 0 0);
--color-tpop16-s2: #f7f8fa;
--color-tpop16-s2: oklch(0.98 0.003 255);
--color-tpop16-ink: #171b22;
--color-tpop16-ink: oklch(0.22 0.015 260);
--color-tpop16-mut: #575e69;
--color-tpop16-mut: oklch(0.48 0.02 258);
--color-tpop16-rule: #d7dbe1;
--color-tpop16-rule: oklch(0.89 0.01 258);
--color-tpop16-ok: #006647;
--color-tpop16-ok: oklch(0.45 0.1 165);
--color-tpop16-no: #99351b;
--color-tpop16-no: oklch(0.47 0.14 35);
--font-tpop16-sans: 'Geist', system-ui, sans-serif;
--font-tpop16-mono: 'Geist Mono', ui-monospace, monospace;
}
/* Theme dial: 0 = light, 1 = dark. Registered so the root can transition it. */
@property --tpop16-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-16 {
width: 100%;
min-height: 100svh;
--color-tpop16-bg: color-mix(in oklch, oklch(0.16 0.01 258) calc(var(--tpop16-d) * 100%), oklch(0.955 0.005 255));
--color-tpop16-s1: color-mix(in oklch, oklch(0.21 0.012 258) calc(var(--tpop16-d) * 100%), oklch(1 0 0));
--color-tpop16-s2: color-mix(in oklch, oklch(0.185 0.011 258) calc(var(--tpop16-d) * 100%), oklch(0.98 0.003 255));
--color-tpop16-ink: color-mix(in oklch, oklch(0.94 0.008 258) calc(var(--tpop16-d) * 100%), oklch(0.22 0.015 260));
--color-tpop16-mut: color-mix(in oklch, oklch(0.73 0.02 258) calc(var(--tpop16-d) * 100%), oklch(0.48 0.02 258));
--color-tpop16-rule: color-mix(in oklch, oklch(0.3 0.014 258) calc(var(--tpop16-d) * 100%), oklch(0.89 0.01 258));
--color-tpop16-ok: color-mix(in oklch, oklch(0.78 0.11 165) calc(var(--tpop16-d) * 100%), oklch(0.45 0.1 165));
--color-tpop16-no: color-mix(in oklch, oklch(0.76 0.13 35) calc(var(--tpop16-d) * 100%), oklch(0.47 0.14 35));
}
```
## JavaScript
```js
(() => {
const root = document.querySelector('[data-tpop16-root]');
if (!root || root.dataset.wired) return;
root.dataset.wired = '1';
const hasPopover = HTMLElement.prototype.hasOwnProperty('popover');
const hasAnchor = !!(window.CSS && CSS.supports('anchor-name: --a'));
const hasDiscrete = !!(window.CSS && CSS.supports('transition-behavior: allow-discrete'));
root.dataset.popover = hasPopover ? 'yes' : 'no';
root.dataset.anchor = hasAnchor ? 'yes' : 'no';
root.dataset.discrete = hasDiscrete ? 'yes' : 'no';
document.getElementById('tpop-16-r1').textContent = hasPopover ? 'supported' : 'missing · polyfill';
document.getElementById('tpop-16-r2').textContent = hasAnchor ? 'supported' : 'missing · fallback';
document.getElementById('tpop-16-r3').textContent = hasDiscrete ? 'supported' : 'missing · no fade';
const panel = document.getElementById('tpop-16-panel');
const trigger = document.getElementById('tpop-16-trigger');
if (hasPopover) {
panel.addEventListener('toggle', (e) => trigger.setAttribute('aria-expanded', String(e.newState === 'open')));
} else {
panel.hidden = true;
trigger.addEventListener('click', () => {
panel.hidden = !panel.hidden;
trigger.setAttribute('aria-expanded', String(!panel.hidden));
});
}
})();
```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 Fallback and Polyfill
Source: https://codefronts.com/snippets/tailwind-css-popovers/tailwind-popover-fallback-polyfill/
Popover and anchor positioning ship on different timelines, so one test isn't enough. The script detects three features — popover, anchor positioning and discrete transitions — writes each to a data-* attribute on the root, and every visual reads them through group-data-[anchor=yes]/root: variants. The panel layers up: a plain positioned box with no API, popover dismissal on top, anchoring on top of that.
## HTML
```html
<section data-tpop16-root data-popover="unknown" data-anchor="unknown" data-discrete="unknown" class="tpop-16 transition-[--tpop16-d] duration-300 ease-in-out motion-reduce:transition-none dark:[--tpop16-d:1] [[data-theme=dark]_&]:[--tpop16-d:1] [[data-theme=light]_&]:[--tpop16-d:0] group/root relative w-full min-h-svh bg-tpop16-bg px-4 py-9.5 font-tpop16-sans text-tpop16-ink" aria-label="Popover fallback and polyfill demo">
<div class="grid min-h-[calc(100svh-76px)] max-w-full place-items-center">
<div class="w-full min-w-0 max-w-[27rem] rounded-xl border border-tpop16-rule bg-tpop16-s1 px-4.5 pt-5 pb-4.5 shadow-[0_16px_36px_-28px_oklch(0.2_0.02_255/0.5)]">
<p class="mb-2 text-[10.5px] font-bold uppercase tracking-[0.2em] text-tpop16-mut">Support probe</p>
<h2 class="mb-4 text-[clamp(20px,4.4vw,25px)] font-semibold leading-tight tracking-[-0.02em]">Three features, three tests</h2>
<dl class="mb-4 grid gap-px overflow-hidden rounded-lg border border-tpop16-rule bg-tpop16-rule">
<div class="flex min-w-0 flex-wrap items-center justify-between gap-2 bg-tpop16-s2 px-3 py-2.5">
<dt class="min-w-0 font-tpop16-mono text-[12.5px] font-semibold">popover attribute</dt>
<dd id="tpop-16-r1" class="rounded-full bg-tpop16-rule/60 px-2 py-0.5 text-[11px] font-bold uppercase tracking-[0.06em] text-tpop16-mut group-data-[popover=yes]/root:bg-tpop16-ok/12 group-data-[popover=yes]/root:text-tpop16-ok group-data-[popover=no]/root:bg-tpop16-no/12 group-data-[popover=no]/root:text-tpop16-no">checking…</dd>
</div>
<div class="flex min-w-0 flex-wrap items-center justify-between gap-2 bg-tpop16-s2 px-3 py-2.5">
<dt class="min-w-0 font-tpop16-mono text-[12.5px] font-semibold">anchor-name</dt>
<dd id="tpop-16-r2" class="rounded-full bg-tpop16-rule/60 px-2 py-0.5 text-[11px] font-bold uppercase tracking-[0.06em] text-tpop16-mut group-data-[anchor=yes]/root:bg-tpop16-ok/12 group-data-[anchor=yes]/root:text-tpop16-ok group-data-[anchor=no]/root:bg-tpop16-no/12 group-data-[anchor=no]/root:text-tpop16-no">checking…</dd>
</div>
<div class="flex min-w-0 flex-wrap items-center justify-between gap-2 bg-tpop16-s2 px-3 py-2.5">
<dt class="min-w-0 font-tpop16-mono text-[12.5px] font-semibold">allow-discrete</dt>
<dd id="tpop-16-r3" class="rounded-full bg-tpop16-rule/60 px-2 py-0.5 text-[11px] font-bold uppercase tracking-[0.06em] text-tpop16-mut group-data-[discrete=yes]/root:bg-tpop16-ok/12 group-data-[discrete=yes]/root:text-tpop16-ok group-data-[discrete=no]/root:bg-tpop16-no/12 group-data-[discrete=no]/root:text-tpop16-no">checking…</dd>
</div>
</dl>
<button id="tpop-16-trigger" type="button" popovertarget="tpop-16-panel" aria-expanded="false" class="min-h-11 w-full cursor-pointer rounded-lg bg-tpop16-ink px-3.5 text-[13.5px] font-semibold text-tpop16-s1 transition-colors [anchor-name:--tpop16-a] hover:bg-tpop16-ink/88 focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-tpop16-ok aria-expanded:bg-tpop16-ink/80">Open the layered panel</button>
<p class="mt-3 text-[12.5px] leading-relaxed text-tpop16-mut">The panel is a plain positioned box first, a popover second, and anchored third.</p>
</div>
</div>
<div popover="auto" id="tpop-16-panel" role="group" aria-label="Layered panel" class="absolute inset-x-4 bottom-4 m-0 w-auto max-w-[calc(100%-32px)] rounded-xl border border-tpop16-rule bg-tpop16-s1 p-3.5 text-tpop16-ink opacity-100 shadow-[0_18px_36px_-22px_oklch(0.2_0.02_255/0.5)] transition-[opacity,translate,display,overlay] duration-150 ease-out transition-discrete [position-anchor:--tpop16-a] [position-area:block-end_span-inline-end] [position-try-fallbacks:flip-block] starting:open:-translate-y-1 starting:open:opacity-0 motion-reduce:transition-none supports-[anchor-name:--a]:fixed supports-[anchor-name:--a]:inset-auto supports-[anchor-name:--a]:mt-2 supports-[anchor-name:--a]:w-[min(21rem,calc(100vw-32px))] supports-[anchor-name:--a]:max-w-[calc(100vw-32px)]">
<p class="mb-2.5 text-[11px] font-bold uppercase tracking-[0.16em] text-tpop16-mut">Layer report</p>
<ul class="grid gap-2">
<li class="flex min-w-0 items-start gap-2.5 text-[13px] leading-normal"><span aria-hidden="true" class="mt-1.5 size-1.5 flex-none rounded-full bg-tpop16-ok"></span><span><strong class="font-semibold">Baseline</strong> · absolutely positioned, no top layer</span></li>
<li class="flex min-w-0 items-start gap-2.5 text-[13px] leading-normal opacity-45 group-data-[popover=yes]/root:opacity-100"><span aria-hidden="true" class="mt-1.5 size-1.5 flex-none rounded-full bg-tpop16-rule group-data-[popover=yes]/root:bg-tpop16-ok"></span><span><strong class="font-semibold">Enhanced</strong> · popover owns dismissal and the top layer</span></li>
<li class="flex min-w-0 items-start gap-2.5 text-[13px] leading-normal opacity-45 group-data-[anchor=yes]/root:opacity-100"><span aria-hidden="true" class="mt-1.5 size-1.5 flex-none rounded-full bg-tpop16-rule group-data-[anchor=yes]/root:bg-tpop16-ok"></span><span><strong class="font-semibold">Enhanced</strong> · anchor positioning owns placement</span></li>
<li class="flex min-w-0 items-start gap-2.5 text-[13px] leading-normal opacity-45 group-data-[discrete=yes]/root:opacity-100"><span aria-hidden="true" class="mt-1.5 size-1.5 flex-none rounded-full bg-tpop16-rule group-data-[discrete=yes]/root:bg-tpop16-ok"></span><span><strong class="font-semibold">Enhanced</strong> · allow-discrete owns the entry fade</span></li>
</ul>
<p class="mt-3 border-t border-tpop16-rule pt-2.5 text-[12px] leading-normal text-tpop16-mut">Polyfill for the attribute: <span class="font-tpop16-mono text-tpop16-ink">popover-polyfill</span> from Open UI. No equivalent exists for anchoring.</p>
</div>
</section>
```
## CSS
```css
@import "tailwindcss";
@theme {
--color-tpop16-bg: #eef0f3;
--color-tpop16-bg: oklch(0.955 0.005 255);
--color-tpop16-s1: #ffffff;
--color-tpop16-s1: oklch(1 0 0);
--color-tpop16-s2: #f7f8fa;
--color-tpop16-s2: oklch(0.98 0.003 255);
--color-tpop16-ink: #171b22;
--color-tpop16-ink: oklch(0.22 0.015 260);
--color-tpop16-mut: #575e69;
--color-tpop16-mut: oklch(0.48 0.02 258);
--color-tpop16-rule: #d7dbe1;
--color-tpop16-rule: oklch(0.89 0.01 258);
--color-tpop16-ok: #006647;
--color-tpop16-ok: oklch(0.45 0.1 165);
--color-tpop16-no: #99351b;
--color-tpop16-no: oklch(0.47 0.14 35);
--font-tpop16-sans: 'Geist', system-ui, sans-serif;
--font-tpop16-mono: 'Geist Mono', ui-monospace, monospace;
}
/* Theme dial: 0 = light, 1 = dark. Registered so the root can transition it. */
@property --tpop16-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-16 {
width: 100%;
min-height: 100svh;
--color-tpop16-bg: color-mix(in oklch, oklch(0.16 0.01 258) calc(var(--tpop16-d) * 100%), oklch(0.955 0.005 255));
--color-tpop16-s1: color-mix(in oklch, oklch(0.21 0.012 258) calc(var(--tpop16-d) * 100%), oklch(1 0 0));
--color-tpop16-s2: color-mix(in oklch, oklch(0.185 0.011 258) calc(var(--tpop16-d) * 100%), oklch(0.98 0.003 255));
--color-tpop16-ink: color-mix(in oklch, oklch(0.94 0.008 258) calc(var(--tpop16-d) * 100%), oklch(0.22 0.015 260));
--color-tpop16-mut: color-mix(in oklch, oklch(0.73 0.02 258) calc(var(--tpop16-d) * 100%), oklch(0.48 0.02 258));
--color-tpop16-rule: color-mix(in oklch, oklch(0.3 0.014 258) calc(var(--tpop16-d) * 100%), oklch(0.89 0.01 258));
--color-tpop16-ok: color-mix(in oklch, oklch(0.78 0.11 165) calc(var(--tpop16-d) * 100%), oklch(0.45 0.1 165));
--color-tpop16-no: color-mix(in oklch, oklch(0.76 0.13 35) calc(var(--tpop16-d) * 100%), oklch(0.47 0.14 35));
}
```
## JavaScript
```js
(() => {
const root = document.querySelector('[data-tpop16-root]');
if (!root || root.dataset.wired) return;
root.dataset.wired = '1';
const hasPopover = HTMLElement.prototype.hasOwnProperty('popover');
const hasAnchor = !!(window.CSS && CSS.supports('anchor-name: --a'));
const hasDiscrete = !!(window.CSS && CSS.supports('transition-behavior: allow-discrete'));
root.dataset.popover = hasPopover ? 'yes' : 'no';
root.dataset.anchor = hasAnchor ? 'yes' : 'no';
root.dataset.discrete = hasDiscrete ? 'yes' : 'no';
document.getElementById('tpop-16-r1').textContent = hasPopover ? 'supported' : 'missing · polyfill';
document.getElementById('tpop-16-r2').textContent = hasAnchor ? 'supported' : 'missing · fallback';
document.getElementById('tpop-16-r3').textContent = hasDiscrete ? 'supported' : 'missing · no fade';
const panel = document.getElementById('tpop-16-panel');
const trigger = document.getElementById('tpop-16-trigger');
if (hasPopover) {
panel.addEventListener('toggle', (e) => trigger.setAttribute('aria-expanded', String(e.newState === 'open')));
} else {
panel.hidden = true;
trigger.addEventListener('click', () => {
panel.hidden = !panel.hidden;
trigger.setAttribute('aria-expanded', String(!panel.hidden));
});
}
})();
```How this works
Three features, three tests. A browser can pass popover and fail anchoring (common today), or support both and still lack allow-discrete. One check produces a panel that opens correctly in the wrong place, or animates in and snaps out.
root.dataset.popover = hasPopover ? 'yes' : 'no';
root.dataset.anchor = hasAnchor ? 'yes' : 'no';
// markup:
<li class="opacity-45 group-data-[anchor=yes]/root:opacity-100">…</li>CSS still does the branching. Tethering lives behind supports-[anchor-name:--a]:, so it never depends on the script; the data attributes only drive the report. The script sets attributes, never classes, so the variants stay declarative.
On polyfills. Open UI's popover-polyfill covers the attribute and light dismiss — load it only when data-popover="no". There's no comparable shim for anchor positioning; treat it as an enhancement.
Make it yours
- Gate the polyfill:
if (!hasPopover) import('https://unpkg.com/@oddbird/popover-polyfill'). - Hide the report in production and keep only the
data-*attributes for analytics. - Full-width sheet fallback: change the baseline to
inset-x-0 bottom-0 rounded-b-none.
Gotchas — read before shipping
- Testing only for popover and assuming anchoring gives a working panel in the wrong place.
- User-agent sniffing breaks on the next release; each feature test is one line.
- Loading the polyfill unconditionally costs every modern visitor bytes they don't need.
Browser support
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| 125+ | 26+ | not yet | 125+ |
Floor set by oklch(), color-mix(), @property, transition-behavior, anchor positioning as this demo is written. The core technique itself goes back further — Chrome 114+.
The floor is deliberately the popover attribute: this demo is the fallback strategy. With neither feature the baseline positioned panel still opens and closes via the script's hidden toggle.