Polyfills
Eunomia relies on two fairly recent platform APIs: the
<eun-select> or <eun-tooltip> correctly positioned on a
browser that lacks native anchor positioning. The other one, the
Popover API itself, Eunomia assumes is there, so it's worth knowing
exactly when that assumption needs help. This page covers both, the two
situations where reaching for a real polyfill is worth it, and, since
"import it somewhere" means something different in every setup, exactly
where that import belongs in React, Next.js, Vue, Angular, and plain
JavaScript. Pick a framework in the toolbar above the page and every
code sample below adapts to it.
What Eunomia already handles for you
CSS Anchor Positioning: every Eunomia component that positions a panel
against another element (Select, Date picker, Time picker, Tooltip,
Search, the dropdown menu…) checks CSS.supports("anchor-name: --a")
once, and automatically falls back to a getBoundingClientRect()-based
positioning path, with the same flip-to-the-opposite-side behavior,
everywhere the native API isn't available. That's why the tooltip below
already opens in the right place, with nothing installed, on a browser
without native support:
Native anchor positioning itself has shipped in every major engine (Chrome/Edge 125+, Firefox 147+, Safari 26+), but plenty of real traffic still runs older versions of each, which is exactly the gap this fallback closes. There's nothing to configure either way: the check runs on its own, and Eunomia's components use whichever path the browser qualifies for.
Popover API: this one Eunomia does not soften for you. Select's
listbox, Tooltip's panel, Date picker's calendar, and the dropdown
menu all call .showPopover() / .hidePopover() directly on their
panel element. On a browser new enough to matter today (Safari 17+,
Firefox 125+, Chrome/Edge 114+) that's the real, native method: nothing
to do. On something genuinely old, that call throws (showPopover is not a function), which is exactly the gap the polyfill below closes.
Neither of these is new JS you have to load: it already ships inside @eunomia/elements today.
When you actually need a polyfill
| Situation | What to do |
|---|---|
You only use Eunomia components (eun-select, eun-tooltip, eun-date-picker, …) and support current browsers |
Nothing, already handled above |
You use popover, anchor-name/position-anchor, or anchor() yourself, in your own markup, outside any Eunomia component, and need it on browsers older than Chrome 125 / Firefox 147 / Safari 26 |
@oddbird/css-anchor-positioning |
You use a native <button command commandfor> yourself, outside eun-button/eun-fab, targeting Modal, Drawer, or your own commandfor-aware element, and need it on browsers older than Chrome 135 / Firefox 144 / Safari 26.2 |
invokers-polyfill |
| You need Eunomia's popover-driven components to keep working on browsers without the Popover API at all (older than Safari 17 / Firefox 125 / Chrome 114) | @oddbird/popover-polyfill |
Two of the three below, CSS Anchor Positioning and Popover, are maintained by eun-button, eun-fab, eun-modal, and eun-drawer implement command/commandfor in JavaScript directly rather than depending on the browser (see the <button> you write yourself.
CSS Anchor Positioning polyfill
This one is for your own anchor-positioned UI, not for Eunomia
components: Eunomia's own mixin decides whether to use the native path or
its JS fallback by asking CSS.supports("anchor-name: --a") directly,
which reports the browser's real support and is unaffected by whether
this polyfill is loaded. So installing it changes nothing for
<eun-select> or <eun-tooltip>, and only helps if you're writing
anchor-name / position-anchor / anchor() yourself, elsewhere in
your app.
npm install @oddbird/css-anchor-positioning
import polyfill from "@oddbird/css-anchor-positioning/fn";
polyfill();
When and how to call it
polyfill() reads layout from the real DOM (document, and, via
roots, any shadow roots you pass it), so unlike the Popover polyfill
above, which only patches a prototype and is safe to import
unconditionally at the very top of anything, this one needs to run
after the elements it's scanning actually exist, and only in the
browser. Pick a framework in the toolbar above:
Call it once your anchored markup is in the DOM: right after import is
enough for markup that's part of the initial page. Guard it with the
same CSS.supports check Eunomia's own components use, so browsers that
already support anchor positioning natively skip the scan entirely:
import polyfill from "@oddbird/css-anchor-positioning/fn";
if (!CSS.supports("anchor-name: --a")) {
polyfill();
}
Same rule as any bundler-free setup: place the <script type="module">
after the anchored markup in the document, so polyfill() sees real
elements when it scans:
<div id="anchor">Anchor</div>
<div id="target" style="position-anchor: --a">Target</div>
<script type="module">
import polyfill from "@oddbird/css-anchor-positioning/fn";
if (!CSS.supports("anchor-name: --a")) {
polyfill();
}
</script>
Call it from a useEffect with an empty dependency array, so it runs
once after the DOM commits, never during render, which happens before
anything is actually mounted:
import { useEffect } from "react";
import polyfill from "@oddbird/css-anchor-positioning/fn";
function App() {
useEffect(() => {
if (!CSS.supports("anchor-name: --a")) {
polyfill();
}
}, []);
return <div /* your anchored markup */ />;
}
instrumentation-client.ts (see the Popover tab above for why it's the
right place for client-only setup like this) runs after the
server-rendered HTML has loaded but before hydration: by that point
your SSR-rendered anchors already exist in the DOM, which is exactly
what polyfill() needs to scan:
// instrumentation-client.ts
import polyfill from "@oddbird/css-anchor-positioning/fn";
if (!CSS.supports("anchor-name: --a")) {
polyfill();
}
If anchors are added client-side later (behind a useState toggle, for
instance), also call polyfill() again from a useEffect in that
component. See the note below about it only scanning once.
Call it from onMounted, which fires after Vue has committed the
component's DOM:
Call it from afterNextRender, Angular's own hook for browser-only code
that needs the DOM to exist: it never runs during server-side
rendering, so there's no need to guard it with isPlatformBrowser
yourself:
import { Component, afterNextRender } from "@angular/core";
import polyfill from "@oddbird/css-anchor-positioning/fn";
@Component({
/* ... */
})
export class AppComponent {
constructor() {
afterNextRender(() => {
if (!CSS.supports("anchor-name: --a")) {
polyfill();
}
});
}
}
- It only scans once, and doesn't track DOM changes. The polyfill doesn't (yet) support anchors or targets added or removed after that initial call: call
polyfill()again yourself once the DOM settles if you're positioning something added dynamically (see the Next.js and React tabs above for where that second call belongs in practice). - Anchor and target must share a shadow root. An anchor inside one custom element's Shadow DOM can't be linked to a target in a different one, the same constraint the native API itself has, though Eunomia's own
AnchorPositionMixinworks around it internally wherever it would otherwise apply. Pass your own shadow roots viapolyfill({ roots: [someElement.shadowRoot] })if your anchors live inside one.
Configuring what it scans
polyfill() takes an options object if the defaults (scan the whole
document, recalculate on scroll/resize) aren't right for your case:
| Option | Type | Default | What it does |
|---|---|---|---|
roots |
(Document | ShadowRoot)[] |
[document] |
Which root(s) to scan. Pass your own shadow roots here for the shared-shadow-root constraint above. |
elements |
Element[] |
undefined |
Scan only these elements instead of the whole root. |
excludeInlineStyles |
boolean |
false |
With elements set, also stop implicitly polyfilling inline style="anchor-name: ..." on elements outside that list. |
useAnimationFrame |
boolean |
false |
Recalculate every animation frame instead of only on scroll/resize (costs more CPU), so only turn it on for anchors that move via JS/animation outside normal layout flow. |
positionAreaContainingBlock |
boolean | "auto" |
true |
Whether the polyfill wraps a target in an extra element to emulate the containing block position-area creates natively. |
import polyfill from "@oddbird/css-anchor-positioning/fn";
polyfill({ roots: [someElement.shadowRoot], useAnimationFrame: true });
TypeScript
Like the Popover polyfill, this package ships its own .d.ts files for
both the default import and /fn: no extra install, no hand-written
ambient declarations needed for the polyfill() call or its options
object.
See
Invoker Commands API polyfill
This one isn't for Eunomia either. eun-button, eun-fab, eun-modal,
and eun-drawer all implement command/commandfor themselves in
JavaScript, so they already work with each other on every browser Eunomia
supports, with nothing installed. What doesn't, on an older browser, is a
plain native <button command commandfor>, written outside any of those
four, targeting one of them (or any other element you've wired up to
listen for command yourself). See the
npm install invokers-polyfill
Import it once, as early as possible, exactly like the Popover polyfill above: it checks for native support itself and only patches what's missing, so it's always safe to import unconditionally.
import "invokers-polyfill";
Where to import it
The same "as early as possible" rule as the Popover polyfill above applies here too. Pick a framework in the toolbar above:
Put the import at the very top of whatever module boots your app, before it, or anything it imports, ever renders a native invoker button:
import "invokers-polyfill";
// ...the rest of your app's entry point
No bundler? Load it as its own <script type="module">, ahead of the
markup it applies to:
<script type="module">
import "invokers-polyfill";
</script>
<button command="show-modal" commandfor="my-modal">Open</button>
<dialog id="my-modal">...</dialog>
In main.tsx, before createRoot(...).render(...):
import "invokers-polyfill";
import { createRoot } from "react-dom/client";
import App from "./App";
createRoot(document.getElementById("root")!).render(<App />);
Same reasoning as the Popover polyfill above: use
instrumentation-client.ts
// instrumentation-client.ts — project root, or inside src/
import "invokers-polyfill";
On an older Next.js without instrumentation-client, import it at the
top of a "use client" component rendered high in the tree instead.
In main.ts, before createApp(App).mount(...):
import "invokers-polyfill";
import { createApp } from "vue";
import App from "./App.vue";
createApp(App).mount("#app");
List the package directly in angular.json, next to zone.js, the same
way as the Popover polyfill above:
{
"projects": {
"your-app": {
"architect": {
"build": {
"options": {
"polyfills": ["zone.js", "invokers-polyfill"]
}
}
}
}
}
}
On an Angular version that still generates src/polyfills.ts, add the
import there instead of touching angular.json directly.
aria-expanded on
the invoker in sync with a toggle-popover target's open
state automatically. This polyfill doesn't manage that attribute for
you, so set it yourself if you're relying on it for a raw native
invoker on an older browser.
TypeScript
Ships its own .d.ts files for both the default import and the /fn
entry point: nothing extra to install. The default import (what every
example above uses) patches unconditionally and gives you nothing to
type beyond "this module has side effects." To decide yourself whether
to apply the patch instead, use /fn:
import { isSupported, apply } from "invokers-polyfill/fn";
if (!isSupported()) {
apply();
}
Popover API polyfill
npm install @oddbird/popover-polyfill
Import it once, as early as possible (before any Eunomia component that
opens a popover gets used): it checks "showPopover" in HTMLElement.prototype itself and only patches the prototype when that's
missing, so it's always safe to import unconditionally:
import "@oddbird/popover-polyfill";
Where to import it
"As early as possible" means something different depending on how your app boots. Pick a framework in the toolbar above:
Put the import at the very top of whatever module boots your app, before it, or anything it imports, ever calls a Eunomia component that opens a popover:
import "@oddbird/popover-polyfill";
// ...the rest of your app's entry point
No bundler? Load it as its own <script type="module">, ahead of the
<script> tags for any Eunomia component that opens a popover:
<script type="module">
import "@oddbird/popover-polyfill";
import { EunomiaSelect } from "@eunomia/elements";
</script>
In main.tsx, before createRoot(...).render(...):
import "@oddbird/popover-polyfill";
import { createRoot } from "react-dom/client";
import App from "./App";
createRoot(document.getElementById("root")!).render(<App />);
A plain top-level import in a Server Component or layout.tsx also runs
during the server render, where there's no HTMLElement to patch. Use
an
instrumentation-client.ts
// instrumentation-client.ts — project root, or inside src/
import "@oddbird/popover-polyfill";
On an older Next.js without instrumentation-client, import it at the
top of a "use client" component rendered high in the tree instead,
the same constraint the Get started guide covers for any client-only Eunomia
usage in the App Router.
In main.ts, before createApp(App).mount(...):
import "@oddbird/popover-polyfill";
import { createApp } from "vue";
import App from "./App.vue";
createApp(App).mount("#app");
Angular CLI 15+ dropped the dedicated src/polyfills.ts file: list the
package directly in angular.json, next to zone.js. It's a
side-effect-only package, so a bare specifier is enough, with no need
for an actual .ts file to wrap the import:
{
"projects": {
"your-app": {
"architect": {
"build": {
"options": {
"polyfills": ["zone.js", "@oddbird/popover-polyfill"]
}
}
}
}
}
}
On an Angular version that still generates src/polyfills.ts, add the
import there instead of touching angular.json directly.
The button below is plain native HTML (no Eunomia component, no JS of
ours at all), using the browser's own popovertarget attribute, the
exact same showPopover()/hidePopover() machinery Select, Tooltip,
Date picker, and the dropdown menu call directly on their own panels:
<button popovertarget> + <div popover>: the browser wires up show/hide entirely on its own.
On a browser that already supports the Popover API natively, this already works with nothing installed. On one old enough to lack it, the import above is what makes this exact markup keep working instead of throwing.
:popover-openbecomes a real class (.\:popover-open) under the hood rather than a true pseudo-class, which only matters if you query for it yourself from JS, sinceelement.matches(":popover-open")still works through the polyfill's own patched method.- There's no real
::backdrop: the polyfill ships a workaround, but a custom backdrop style targeting::backdropdirectly won't apply. - It injects a
<style>tag into the document (or into each Shadow Root that needs it): harmless, but worth knowing if something in your build inspects injected styles.
TypeScript
Both the default import and the /fn entry point ship their own .d.ts
files: nothing to add to a global declaration file, no @types/...
package to install. The default import (what every example above uses)
patches the prototype unconditionally and gives you nothing to type
beyond "this module has side effects." If you'd rather decide yourself
whether to apply the patch (say, to log which visitors actually needed
it), use the /fn entry point instead:
import { apply, isPolyfilled, isSupported } from "@oddbird/popover-polyfill/fn";
if (!isSupported()) {
apply();
}
isSupported() mirrors the check the default import already runs
internally ("showPopover" in HTMLElement.prototype), while
isPolyfilled() tells you afterward whether apply() actually did
anything.
See also
@oddbird/css-anchor-positioning on GitHub invokers-polyfill on GitHub @oddbird/popover-polyfill on GitHub Next.js: instrumentation-client.ts Get started : the same framework-adaptive toolbar, applied to install/import/useTooltip : using the CSS Anchor Positioning API with a JS fallbackSelect : apopover="auto"listbox positioned with the sameAnchorPositionMixinInvoker Commands API : whatcommand/commandfordo and why Eunomia doesn't need this polyfill for its own components