Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions position-area.html
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,29 @@ <h2>
</div>
</section>

<section class="position-area-demo-item" id="inline-shifted">
<h2>
<a href="#inline-shifted" aria-hidden="true">🔗</a>
<code>span-left top, padding set inline ✅</code>
</h2>
<div style="position: relative" class="demo-elements">
<div class="anchor">Anchor</div>
<div class="target inline-shifted" style="padding-right: 50%">
Target with longer content
</div>
</div>
<p>
The same as the demo above, except that
<code>padding-right: 50%</code> is an inline style rather than a
stylesheet rule. Inline styles are shifted into custom properties like
the rest of the CSS, so <a href="?auto"><code>auto</code> mode</a> can
still see the percentage padding and wraps the target. Without that
shift the padding reads back as empty, the target is positioned
directly, and the padding resolves against the original containing block
instead of the <code>position-area</code> cell.
</p>
</section>

<section class="position-area-demo-item" id="nested-alignment">
<h2>
<a href="#nested-alignment" aria-hidden="true">🔗</a>
Expand Down
6 changes: 6 additions & 0 deletions public/position-area-page.css
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,12 @@
position-area: span-left top;
}

/* Same as `.spanleft-top`, but the containing-block-dependent padding is set
* as an inline style on the target instead. */
.target.inline-shifted {
position-area: span-left top;
}

.target.spanall-left {
position-area: span-all left;
}
Expand Down
34 changes: 23 additions & 11 deletions src/fetch.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { nanoid } from 'nanoid/non-secure';

import { POLYFILLED_STYLE_ATTRIBUTE } from './cascade.js';
import { POLYFILLED_STYLE_ATTRIBUTE, SHIFTED_PROPERTIES } from './cascade.js';
import { querySelectorAllRoots } from './dom.js';
import {
type AnchorPositioningRoot,
Expand Down Expand Up @@ -31,7 +31,7 @@
if (!data.url) {
return data as StyleData;
}
// TODO: Add MutationObserver to watch for disabled links being enabled

Check warning on line 34 in src/fetch.ts

View workflow job for this annotation

GitHub Actions / Lint

Unexpected 'todo' comment: 'TODO: Add MutationObserver to watch for...'
// https://github.com/oddbird/css-anchor-positioning/issues/246
if ((data.el as HTMLLinkElement | undefined)?.disabled) {
// Do not fetch or parse disabled stylesheets
Expand Down Expand Up @@ -63,8 +63,26 @@
return results.filter((loaded) => loaded !== null);
}

const ELEMENTS_WITH_INLINE_ANCHOR_STYLES_QUERY = '[style*="anchor"]';
const ELEMENTS_WITH_INLINE_POSITION_AREA = '[style*="position-area"]';
// Inline styles are collected so that `cascadeCSS` can shift their declarations
// into custom properties, like it does for the rest of the CSS. That has to
// cover every property the polyfill later reads back through
// `getCSSPropertyValue` — insets, margins, sizing, padding, self-alignment,
// `position-area` — and not just the anchor-specific ones: a target can take
// its `position-area` from a stylesheet while setting its margin inline.
// `anchor` is matched on its own as well, for `anchor()`/`anchor-size()` values.
// Built on first use rather than at module evaluation: `cascade.js` and this

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't the first time we've run into this cycle- is there a different file org that would avoid that?

@jpzwarte jpzwarte Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checking the real runtime graph, there are exactly two cycles:

1. cascade → utils → dom → cascade (with utils ↔ dom nested inside it)

This is the one that actually bites. Probing module-body evaluation shows SHIFTED_PROPERTIES is not yet initialized when dom.ts's body runs — it only gets away with it because the read happens inside getCSSPropertyValue rather than at module scope. All three edges are single-use:

  • dom → cascade exists solely for SHIFTED_PROPERTIES (dom.ts:51)
  • utils → dom exists solely for strategyForElement (utils.ts:292getCSSPropertyValue)
  • dom → utils exists solely for getRootStyleContainer (dom.ts:105)

2. parse ↔ fallback

Only isIdentifier is a real value edge — AnchorPosition, AnchorPositions and TryBlock are types and already erase. parse needs parsePositionFallbacks; fallback needs those four.

Perhaps look at improving this in a new PR?

// module are part of an import cycle, so `SHIFTED_PROPERTIES` is not
// necessarily initialized yet when this module is evaluated.
let inlineAnchorStylesQuery: string | undefined;
function elementsWithInlineAnchorStylesQuery() {
inlineAnchorStylesQuery ??= [

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are there performance implications with this length of query?

If it helps, anchor-scope and anchor-name are in SHIFTED_PROPERTIES, and duplicated by the style*=anchor query. Also, all the padding variants could be caught with a single wildcard, all the margin variants, etc.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are there performance implications with this length of query?

Yes. Engines don't bucket [style*=…] by attribute presence, so all 53 clauses get tested against every element in the traversal — cost tracks document size, not how many elements actually have inline styles.

Median ms per querySelectorAll, 20k elements / 1k styled:

Engine current (53) minimized (16) [style] + one regex
Chromium 14.04 3.94 0.35
Firefox 7.75 2.45 0.15
WebKit 3.25 0.95 0.15

(Quadrupling the styled elements to 4k barely moves the first column — 16.9ms in Chromium — confirming it's per-element-traversed.)

…duplicated by the style*=anchor query. Also, all the padding variants could be caught with a single wildcard…

Agreed, and it generalizes: any term containing another term is redundant. Applied mechanically, 53 terms → 16 (anchor, left, right, top, bottom, inset, margin, width, height, block-size, inline-size, justify-self, align-self, place-self, position-area, padding).

Matched set is unchanged: a term is dropped only when a strictly shorter term is a substring of it, so every removal chain ends at a retained term that matches the same strings. Kept terms are a subset of the originals, so no false positives either. All three variants matched identical sets in every benchmark case.

'[style*="anchor"]',
...Object.keys(SHIFTED_PROPERTIES).map(
(property) => `[style*="${property}"]`,
),
].join(',');
return inlineAnchorStylesQuery;
}
// Searches for all elements with inline style attributes that include `anchor`.
// 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
Expand All @@ -74,16 +92,10 @@
? elements.filter(
(el) =>
el instanceof HTMLElement &&
(el.matches(ELEMENTS_WITH_INLINE_ANCHOR_STYLES_QUERY) ||
el.matches(ELEMENTS_WITH_INLINE_POSITION_AREA)),
el.matches(elementsWithInlineAnchorStylesQuery()),
)
: Array.from(
document.querySelectorAll(
[
ELEMENTS_WITH_INLINE_ANCHOR_STYLES_QUERY,
ELEMENTS_WITH_INLINE_POSITION_AREA,
].join(','),
),
document.querySelectorAll(elementsWithInlineAnchorStylesQuery()),
);
const inlineStyles: Partial<StyleData>[] = [];

Expand Down
25 changes: 25 additions & 0 deletions tests/e2e/position-area.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,31 @@ test.describe('with `positionAreaContainingBlock: auto`', () => {
).toHaveCount(1);
});

test('wraps a target whose containing-block-dependent style is inline', async ({
page,
}) => {
// `#inline-shifted .target` takes its `position-area` from a stylesheet and
// sets `padding-right: 50%` inline. Inline styles are shifted into custom
// properties like the rest of the CSS, so the percentage padding is still
// seen here and the target is wrapped. Without the shift it reads back as
// empty and the target is positioned directly.
await applyPolyfill(page);

const section = page.locator('#inline-shifted');
const targetWrapper = section.locator('polyfill-position-area');
await expect(targetWrapper).toHaveCount(1);

// The reason it needs the wrapper: the padding has to resolve against the
// position-area cell, not the original parent.
const wrapperContentWidth = await targetWrapper.evaluate(
(el) => el.clientWidth,
);
const paddingRight = await section
.locator('.target')
.evaluate((el) => parseFloat(getComputedStyle(el).paddingRight));
expect(paddingRight).toBeCloseTo(wrapperContentWidth / 2, 0);
});

test('positions a wrapped target correctly', async ({ page }) => {
await applyPolyfill(page);
const section = page.locator('#spanleft-top');
Expand Down
Loading