From c77736956f7a70cc4069c6f0fda45dd974269b34 Mon Sep 17 00:00:00 2001
From: Jeroen Zwartepoorte
Date: Sat, 25 Jul 2026 12:17:15 +0200
Subject: [PATCH 01/13] Add a shadow DOM demo for anchors wired up through the
CSSOM
`anchor-name` and `position-anchor` assigned from JavaScript are dropped
by the CSSOM in a browser without native support, so nothing lands in the
`style` attribute the polyfill reads. The demo wires up its anchor at
runtime the way a design system component would, and does not work as a
result.
Refs #445
Co-Authored-By: Claude Opus 5
---
shadow-dom.html | 134 ++++++++++++++++++++++++++++++++++++++++++++++--
1 file changed, 131 insertions(+), 3 deletions(-)
diff --git a/shadow-dom.html b/shadow-dom.html
index b835fb9..753b0c4 100644
--- a/shadow-dom.html
+++ b/shadow-dom.html
@@ -24,6 +24,7 @@
Anchor
Target
`;
@@ -161,20 +161,26 @@
// Load the shadow entrypoint first so the `replaceSync` and
// `adoptedStyleSheets` patches are installed before any custom
// element's `connectedCallback` runs.
- const { default: polyfill, patchAndPolyfillConstructedStylesheets } =
- await import('/src/index-fn.ts');
+ const {
+ default: polyfill,
+ patchAndPolyfillConstructedStylesheets,
+ patchCSSOM,
+ } = await import('/src/index-fn.ts');
// Patch Constructed stylesheets
patchAndPolyfillConstructedStylesheets();
+ // Make `anchor-name` and `position-anchor` settable through the
+ // CSSOM, for the demo below.
+ patchCSSOM();
+
// Now define the custom elements
defineCustomElements();
- // Load the polyfill explicitly for the and
- // demos. The ,
- // and demos use
- // `adoptedStyleSheets`, so they are already covered by the adopted
- // stylesheet patch.
+ // Load the polyfill explicitly for the demos that don't use
+ // `adoptedStyleSheets`. The and
+ // demos do, so they are already covered by
+ // the adopted stylesheet patch.
await polyfill({
roots: [
document.querySelector('anchor-web-component').shadowRoot,
@@ -182,6 +188,15 @@
],
});
+ // The demo is positioned directly rather than
+ // wrapped: its target is a popover, and a popover is promoted to the
+ // top layer, where its containing block is the viewport rather than
+ // the `` wrapper.
+ await polyfill({
+ roots: [document.querySelector('anchor-position-area').shadowRoot],
+ positionAreaContainingBlock: false,
+ });
+
btn.innerText = 'Polyfill Applied';
btn.setAttribute('disabled', '');
} else {
@@ -553,42 +568,65 @@
button.
- This demo does not work. Like a design system
- component would, it sets anchor-name and
- position-anchor from JavaScript through the CSSOM. A
- browser that needs this polyfill does not support those properties, so
- the assignments are silently dropped instead of becoming CSS
- declarations — nothing is written to the elements'
- style attributes, which is what the polyfill reads. The
- anchor is never resolved and the popover stays unpositioned. Writing
- the declarations into the style attribute directly is
- what makes them visible to the polyfill.
+ Like a design system component would, it sets
+ anchor-name and position-anchor from
+ JavaScript through the CSSOM. A browser that needs this polyfill does
+ not support those properties, and the CSSOM drops what it does not
+ know, so the assignments never become CSS declarations and nothing is
+ written to the style attributes the polyfill reads.
+ patchCSSOM() makes those properties settable, at the cost
+ of 'anchorName' in element.style reporting support that
+ isn't there.
+
+
+ So detect native support with CSS.supports(), which the
+ patch leaves alone:
+
+ A popover is promoted to the top layer, where its containing block is
+ the viewport rather than the
+ <polyfill-position-area>
+ wrapper, so this demo is polyfilled with
+ positionAreaContainingBlock: false and positioned
+ directly.
<anchor-position-area></anchor-position-area>
<script>
-<!-- Load the shadow entrypoint before defining custom elements,
-so the replaceSync and adoptedStyleSheets patches are installed
-before any connectedCallback runs. -->
-import { patchAndPolyfillConstructedStylesheets } from '@oddbird/css-anchor-positioning/fn';
-patchAndPolyfillConstructedStylesheets();
+import polyfill, { patchCSSOM } from '@oddbird/css-anchor-positioning/fn';
+
+<!-- Call before any connectedCallback runs, so the anchor properties
+are settable by the time an element wires up its anchor. -->
+patchCSSOM();
+
class AnchorPositionArea extends HTMLElement {
connectedCallback() {
this.attachShadow({ mode: "open" });
-
- const sheet = new CSSStyleSheet();
- sheet.replaceSync(`
- #shadow-position-area-target {
- margin: initial;
- position-area: top;
- width: 14em;
- }
- `);
- this.shadowRoot.adoptedStyleSheets = [sheet];
-
this.shadowRoot.innerHTML = `
+ <style>
+ #shadow-position-area-target {
+ margin: initial;
+ position-area: top;
+ width: 14em;
+ }
+ </style>
<div id="shadow-position-area-anchor">Anchor</div>
<div id="shadow-position-area-target" popover="manual">Popover (Target)</div>
`;
@@ -596,8 +634,7 @@
const anchor = this.shadowRoot.getElementById("shadow-position-area-anchor"),
target = this.shadowRoot.getElementById("shadow-position-area-target");
- // Set from JavaScript, the way a design system component would. Without
- // native support the CSSOM drops both, so the polyfill never sees them.
+ // Dropped by the CSSOM without `patchCSSOM()`.
anchor.style.anchorName = target.style.positionAnchor =
"--shadow-position-area-anchor";
@@ -605,6 +642,11 @@
diff --git a/src/cssom.ts b/src/cssom.ts
new file mode 100644
index 0000000..07f38ee
--- /dev/null
+++ b/src/cssom.ts
@@ -0,0 +1,220 @@
+// The properties this patch makes settable through the CSSOM, mapped to the CSS
+// property they write. A browser without native anchor positioning does not
+// know them, and the CSSOM drops what it does not know: `el.style.anchorName =
+// '--foo'` only sets a property on the style object and never produces a CSS
+// declaration, while `el.style.setProperty('anchor-name', '--foo')` is ignored
+// outright. Reading the value back still returns it, so the assignment looks
+// like it worked. The polyfill reads the `style` attribute, so without this
+// patch it never sees anchors that are wired up from JavaScript.
+const PATCHED_PROPERTIES = {
+ anchorName: 'anchor-name',
+ positionAnchor: 'position-anchor',
+} as const;
+
+const PATCHED_CSS_PROPERTIES: string[] = Object.values(PATCHED_PROPERTIES);
+
+// The element an inline `CSSStyleDeclaration` belongs to. Inline styles have no
+// `ownerNode`, so the only way back to the element is to record it while
+// `element.style` is being accessed — which is exactly when a declaration is
+// about to be read or written.
+const inlineStyleOwners = new WeakMap();
+
+// The values set through the patched accessors, and the authoritative copy of
+// them. They are mirrored into the `style` attribute for the polyfill (and for
+// devtools) to read, but any later CSSOM write reserializes the declaration
+// block and drops declarations the browser does not understand, so the
+// attribute on its own cannot be trusted:
+//
+// el.setAttribute('style', 'anchor-name: --foo; color: red');
+// el.style.top = '1px';
+// el.getAttribute('style'); // 'color: red; top: 1px;'
+const inlineValues = new WeakMap>();
+
+// Every element that has a value in `inlineValues`, so the mirrors can be
+// restored before a polyfill run reads them. Held weakly, so this never keeps
+// an element alive.
+const patchedElements = new Set>();
+
+let patched = false;
+
+/** Reads a declaration from an element's `style` attribute. */
+function getInlineDeclaration(element: Element, property: string) {
+ const [, value = ''] =
+ new RegExp(`(?:^|;)\\s*${property}\\s*:([^;]*)`, 'i').exec(
+ element.getAttribute('style') ?? '',
+ ) ?? [];
+ return value.trim();
+}
+
+/**
+ * Writes a declaration into an element's `style` attribute, leaving the other
+ * declarations untouched. An empty value removes the declaration.
+ *
+ * The attribute is edited as text rather than through the CSSOM, which would
+ * drop the declaration again.
+ */
+function setInlineDeclaration(
+ element: Element,
+ property: string,
+ value: string,
+) {
+ let style = (element.getAttribute('style') ?? '')
+ .replace(new RegExp(`(^|;)\\s*${property}\\s*:[^;]*;?`, 'i'), '$1')
+ .replace(/^\s*;/, '')
+ .trim();
+
+ if (value) {
+ style = style && !style.endsWith(';') ? `${style};` : style;
+ style = `${style}${style ? ' ' : ''}${property}: ${value};`;
+ }
+
+ if (style) {
+ element.setAttribute('style', style);
+ } else if (element.hasAttribute('style')) {
+ element.setAttribute('style', '');
+ }
+}
+
+function writeValue(element: Element, property: string, value: string) {
+ let values = inlineValues.get(element);
+ if (!values) {
+ values = new Map();
+ inlineValues.set(element, values);
+ patchedElements.add(new WeakRef(element));
+ }
+
+ if (value) {
+ values.set(property, value);
+ } else {
+ values.delete(property);
+ }
+
+ setInlineDeclaration(element, property, value);
+}
+
+/**
+ * Re-applies the values set through the patched accessors to the `style`
+ * attributes they were mirrored into, in case a later CSSOM write dropped them,
+ * and drops elements that have been garbage collected.
+ *
+ * Called at the start of a polyfill run, so that everything downstream can keep
+ * reading the `style` attribute as the single source of inline styles.
+ */
+export function restoreInlineAnchorValues() {
+ if (!patched) return;
+
+ for (const ref of patchedElements) {
+ const element = ref.deref();
+ if (!element) {
+ patchedElements.delete(ref);
+ continue;
+ }
+
+ for (const [property, value] of inlineValues.get(element) ?? []) {
+ if (getInlineDeclaration(element, property) !== value) {
+ setInlineDeclaration(element, property, value);
+ }
+ }
+ }
+}
+
+/**
+ * Makes `anchor-name` and `position-anchor` settable through the CSSOM, so that
+ * anchors wired up from JavaScript — `element.style.anchorName = '--foo'` — are
+ * visible to the polyfill in browsers without native anchor positioning.
+ *
+ * This is opt-in, and does nothing when anchor positioning is supported
+ * natively, because it has a side effect worth knowing about: defining these
+ * properties makes `'anchorName' in document.documentElement.style` return
+ * `true`, which is a common way to detect native support. Use
+ * `CSS.supports('anchor-name: --a')` for that instead — it is unaffected.
+ *
+ * Values set before this is called are not picked up; call it as early as
+ * possible, alongside the other patches.
+ */
+export function patchCSSOM() {
+ if (patched || CSS.supports('anchor-name: --a')) return;
+ patched = true;
+
+ // Record which element an inline declaration belongs to.
+ for (const proto of [HTMLElement.prototype, SVGElement.prototype]) {
+ const descriptor = Object.getOwnPropertyDescriptor(proto, 'style');
+ const getStyle = descriptor?.get;
+ if (!descriptor || !getStyle) continue;
+
+ Object.defineProperty(proto, 'style', {
+ ...descriptor,
+ get(this: Element) {
+ const style = getStyle.call(this) as CSSStyleDeclaration;
+ inlineStyleOwners.set(style, this);
+ return style;
+ },
+ });
+ }
+
+ const ownerOf = (style: CSSStyleDeclaration) => inlineStyleOwners.get(style);
+
+ for (const [property, cssProperty] of Object.entries(PATCHED_PROPERTIES)) {
+ Object.defineProperty(CSSStyleDeclaration.prototype, property, {
+ configurable: true,
+ enumerable: true,
+ get(this: CSSStyleDeclaration) {
+ const element = ownerOf(this);
+ // Not an inline style (a computed style, say), so there is no value of
+ // ours to report.
+ return (element && inlineValues.get(element)?.get(cssProperty)) ?? '';
+ },
+ set(this: CSSStyleDeclaration, value: string) {
+ const element = ownerOf(this);
+ if (element) {
+ writeValue(element, cssProperty, `${value ?? ''}`.trim());
+ }
+ },
+ });
+ }
+
+ // The dashed form goes through these, and is dropped just the same.
+ const { getPropertyValue, removeProperty, setProperty } =
+ CSSStyleDeclaration.prototype;
+
+ CSSStyleDeclaration.prototype.setProperty = function (
+ property: string,
+ value: string | null,
+ priority?: string,
+ ) {
+ const element = PATCHED_CSS_PROPERTIES.includes(property)
+ ? ownerOf(this)
+ : undefined;
+ if (element) {
+ writeValue(element, property, `${value ?? ''}`.trim());
+ return;
+ }
+ return setProperty.call(this, property, value, priority);
+ };
+
+ CSSStyleDeclaration.prototype.getPropertyValue = function (
+ property: string,
+ ): string {
+ const element = PATCHED_CSS_PROPERTIES.includes(property)
+ ? ownerOf(this)
+ : undefined;
+ if (element) {
+ return inlineValues.get(element)?.get(property) ?? '';
+ }
+ return getPropertyValue.call(this, property);
+ };
+
+ CSSStyleDeclaration.prototype.removeProperty = function (
+ property: string,
+ ): string {
+ const element = PATCHED_CSS_PROPERTIES.includes(property)
+ ? ownerOf(this)
+ : undefined;
+ if (element) {
+ const previous = inlineValues.get(element)?.get(property) ?? '';
+ writeValue(element, property, '');
+ return previous;
+ }
+ return removeProperty.call(this, property);
+ };
+}
diff --git a/src/fetch.ts b/src/fetch.ts
index 6e86ef3..2e058f9 100644
--- a/src/fetch.ts
+++ b/src/fetch.ts
@@ -69,7 +69,10 @@ const ELEMENTS_WITH_INLINE_POSITION_AREA = '[style*="position-area"]';
// For each element found, adds a new 'data-has-inline-styles' attribute with a
// random UUID value, and then formats the styles in the same manner as CSS from
// style tags.
-function fetchInlineStyles(elements?: HTMLElement[]) {
+function fetchInlineStyles(
+ roots: AnchorPositioningRoot[],
+ elements?: HTMLElement[],
+) {
const elementsWithInlineAnchorStyles: HTMLElement[] = elements
? elements.filter(
(el) =>
@@ -77,14 +80,23 @@ function fetchInlineStyles(elements?: HTMLElement[]) {
(el.matches(ELEMENTS_WITH_INLINE_ANCHOR_STYLES_QUERY) ||
el.matches(ELEMENTS_WITH_INLINE_POSITION_AREA)),
)
- : Array.from(
- document.querySelectorAll(
- [
- ELEMENTS_WITH_INLINE_ANCHOR_STYLES_QUERY,
- ELEMENTS_WITH_INLINE_POSITION_AREA,
- ].join(','),
- ),
- );
+ : (() => {
+ const query = [
+ ELEMENTS_WITH_INLINE_ANCHOR_STYLES_QUERY,
+ ELEMENTS_WITH_INLINE_POSITION_AREA,
+ ].join(',');
+ // The document is searched as well as the roots: a run scoped to a
+ // shadow root still needs inline styles from the outer tree, where its
+ // host and any light-DOM anchors live. Searching the roots on top of
+ // that is what finds inline styles *inside* a shadow root, which
+ // `document.querySelectorAll()` does not reach.
+ return [
+ ...new Set([
+ ...(document.querySelectorAll(query) as NodeListOf),
+ ...querySelectorAllRoots(roots, query),
+ ]),
+ ];
+ })();
const inlineStyles: Partial[] = [];
elementsWithInlineAnchorStyles
@@ -160,7 +172,7 @@ export async function fetchCSS(
? (options.elements ?? [])
: undefined;
- const inlines = fetchInlineStyles(elementsForInlines);
+ const inlines = fetchInlineStyles(options.roots, elementsForInlines);
// Collect constructed stylesheets adopted on the polyfill roots. Skipped when
// an explicit `elements` list is provided, as that opts into element-only
diff --git a/src/index-fn.ts b/src/index-fn.ts
index b03d4db..a5a969d 100644
--- a/src/index-fn.ts
+++ b/src/index-fn.ts
@@ -1,5 +1,6 @@
import { polyfill } from './polyfill.js';
+export { patchCSSOM } from './cssom.js';
export { patchAndPolyfillConstructedStylesheets } from './shadow.js';
export default polyfill;
diff --git a/src/polyfill.ts b/src/polyfill.ts
index f55ff92..a283132 100644
--- a/src/polyfill.ts
+++ b/src/polyfill.ts
@@ -6,6 +6,7 @@ import {
} from '@floating-ui/dom';
import { cascadeCSS } from './cascade.js';
+import { restoreInlineAnchorValues } from './cssom.js';
import { getCSSPropertyValue, getOffsetParent } from './dom.js';
import { fetchCSS } from './fetch.js';
import {
@@ -760,6 +761,11 @@ export async function polyfill(
useAnimationFrameOrOption ?? window.ANCHOR_POSITIONING_POLYFILL_OPTIONS,
);
+ // Anchors set through the patched CSSOM accessors live in a registry; make
+ // sure the `style` attributes they mirror into are up to date before
+ // anything reads them.
+ restoreInlineAnchorValues();
+
// fetch CSS from stylesheet and inline style
let styleData = await fetchCSS(options);
diff --git a/tests/e2e/shadow-dom.test.ts b/tests/e2e/shadow-dom.test.ts
index e0f1f21..1f394ee 100644
--- a/tests/e2e/shadow-dom.test.ts
+++ b/tests/e2e/shadow-dom.test.ts
@@ -299,3 +299,53 @@ test('emulates non-inheritance of shifted properties inside a shadow root withou
expect(result.containerHeight).toBe('400px');
expect(result.targetHeight).toBe('');
});
+
+test('picks up anchors set through the CSSOM', async ({ page }) => {
+ // `patchCSSOM()` makes `anchor-name` and `position-anchor` settable through
+ // the CSSOM. Without it those assignments are dropped by a browser that has
+ // no native anchor positioning, and never reach the `style` attribute the
+ // polyfill reads. The elements live in a shadow root, so this also covers
+ // inline styles being collected per polyfill root rather than from
+ // `document`.
+ await applyPolyfill(page);
+
+ await page.evaluate(async () => {
+ // Resolved by the Vite dev server at runtime; the indirection keeps `tsc`
+ // and the import linter from trying to resolve it statically.
+ const fnEntry = '/src/index-fn.ts';
+ const { default: polyfill } = await import(fnEntry);
+
+ const host = document.createElement('div');
+ host.id = 'cssom-anchors';
+ document.body.append(host);
+
+ const shadow = host.attachShadow({ mode: 'open' });
+ shadow.innerHTML = `
+
+
+ Anchor
+
Target
+
`;
+
+ const anchor = shadow.getElementById('anchor')!,
+ target = shadow.getElementById('target')!;
+
+ anchor.style.anchorName = '--cssom-anchor';
+ target.style.positionAnchor = '--cssom-anchor';
+
+ await polyfill({ roots: [shadow] });
+ });
+
+ const anchor = page.locator('#cssom-anchors #anchor');
+ const target = page.locator('#cssom-anchors #target');
+
+ const anchorBox = (await anchor.boundingBox())!;
+ const targetBox = (await target.boundingBox())!;
+
+ // `top: anchor(bottom)` and `left: anchor(right)` put the target's top-left
+ // corner on the anchor's bottom-right corner.
+ expect(targetBox.y).toBeCloseTo(anchorBox.y + anchorBox.height, 0);
+ expect(targetBox.x).toBeCloseTo(anchorBox.x + anchorBox.width, 0);
+});
From b35753d2c10d95e0ffc9fac6b559160dd925f427 Mon Sep 17 00:00:00 2001
From: Jeroen Zwartepoorte
Date: Sat, 25 Jul 2026 13:43:45 +0200
Subject: [PATCH 03/13] Update README
---
README.md | 69 +++++++++++++++++++++++++++++++++++++++++++++++++++----
1 file changed, 65 insertions(+), 4 deletions(-)
diff --git a/README.md b/README.md
index af10bf3..dc27b81 100644
--- a/README.md
+++ b/README.md
@@ -37,7 +37,7 @@ To use the polyfill, add this script tag to your document ``:
```js
@@ -58,6 +58,21 @@ polyfill();
The `polyfill` function returns a promise that resolves when the polyfill has
been applied.
+### Feature detection
+
+Use `CSS.supports()` to check for native support:
+
+```js
+if (!CSS.supports('anchor-name: --a')) {
+ // Load the polyfill.
+}
+```
+
+Checking for the property on a style declaration —
+`'anchorName' in document.documentElement.style` — works too, but only until
+something defines that property. [`patchCSSOM()`](#setting-anchor-properties-from-javascript)
+does exactly that, so `CSS.supports()` is the check to rely on.
+
### Constructed stylesheets (`adoptedStyleSheets`)
If your custom elements use [constructed stylesheets](https://developer.mozilla.org/en-US/docs/Web/API/CSSStyleSheet/CSSStyleSheet)
@@ -65,7 +80,7 @@ If your custom elements use [constructed stylesheets](https://developer.mozilla.
```html
@@ -58,21 +58,6 @@ polyfill();
The `polyfill` function returns a promise that resolves when the polyfill has
been applied.
-### Feature detection
-
-Use `CSS.supports()` to check for native support:
-
-```js
-if (!CSS.supports('anchor-name: --a')) {
- // Load the polyfill.
-}
-```
-
-Checking for the property on a style declaration —
-`'anchorName' in document.documentElement.style` — works too, but only until
-something defines that property. [`patchCSSOM()`](#setting-anchor-properties-from-javascript)
-does exactly that, so `CSS.supports()` is the check to rely on.
-
### Constructed stylesheets (`adoptedStyleSheets`)
If your custom elements use [constructed stylesheets](https://developer.mozilla.org/en-US/docs/Web/API/CSSStyleSheet/CSSStyleSheet)
@@ -80,7 +65,7 @@ If your custom elements use [constructed stylesheets](https://developer.mozilla.
```html