From 9b1bf3e732ed8596b28dacfa6ff7418cd576929c Mon Sep 17 00:00:00 2001
From: Jeroen Zwartepoorte
Date: Fri, 12 Jun 2026 11:46:38 +0200
Subject: [PATCH 1/3] Add `positionAreaContainingBlock` option that causes the
polyfill to not wrap the element when using position-area
---
README.md | 30 ++++
position-area.html | 16 ++-
src/parse.ts | 14 +-
src/polyfill.ts | 135 +++++++++++++++---
src/position-area.ts | 101 ++++++++++---
tests/e2e/position-area.test.ts | 110 ++++++++++++++
.../__snapshots__/position-area.test.ts.snap | 2 +
tests/unit/position-area.test.ts | 55 +++++++
8 files changed, 416 insertions(+), 47 deletions(-)
diff --git a/README.md b/README.md
index 156a1b78..0aa3fd77 100644
--- a/README.md
+++ b/README.md
@@ -103,6 +103,7 @@ value of `window.ANCHOR_POSITIONING_POLYFILL_OPTIONS`.
window.ANCHOR_POSITIONING_POLYFILL_OPTIONS = {
elements: undefined,
excludeInlineStyles: false,
+ positionAreaContainingBlock: true,
roots: [document],
useAnimationFrame: false,
};
@@ -122,6 +123,7 @@ an argument.
polyfill({
elements: undefined,
excludeInlineStyles: false,
+ positionAreaContainingBlock: true,
roots: [document],
useAnimationFrame: false,
});
@@ -149,6 +151,19 @@ defined. When set to `true`, elements with eligible inline styles listed in the
`elements` option will still be polyfilled, but no other elements in the
document will be implicitly polyfilled.
+### positionAreaContainingBlock
+
+type: `boolean`, default: `true`
+
+By default, `position-area` is polyfilled by wrapping the target with a
+`` element that approximates the containing block
+created by `position-area`. When set to `false`, no wrapper element is added,
+and the polyfill computes and applies inset values on the target itself
+instead. This avoids breaking selectors that rely on a direct relationship
+with the target (for instance `> target` or `:nth-child()`), but comes with
+its own trade-offs. See [Limitations](#limitations) for the differences
+between the two approaches.
+
### roots
type: `(Document | HTMLElement | ShadowRoot)[]`, default: `[document]`
@@ -201,11 +216,26 @@ following features:
which adds a few differences:
- This breaks selectors that rely on a direct relationship with the target,
for instance `~ target`, `+ target`, `> target` or using `:nth` selectors.
+ Set the [`positionAreaContainingBlock`](#positionareacontainingblock)
+ option to `false` to position the target directly, without a wrapping
+ element.
- Overflow alignment is not applied for a target that overflows its
inset-modified containing block but would still fit within its original
containing block. In other words, a polyfilled target may be placed in a
`position-area` grid section outside its containing block, where the
implementation would move the target inside the containing block.
+- When the [`positionAreaContainingBlock`](#positionareacontainingblock) option
+ is set to `false`, `position-area` is polyfilled by computing and applying
+ inset values on the target element instead of adding a wrapping element,
+ which adds a few other differences:
+ - Inset properties (`top`, `left`, etc.) set by the author on the target are
+ overridden by the polyfill, rather than resolved relative to the
+ `position-area` containing block.
+ - Self-alignment properties (`justify-self` and `align-self`) on the target
+ do not override the alignment derived from `position-area`.
+ - A target that is center-aligned on an axis is not constrained by the size
+ of the `position-area` grid area on that axis, and may overflow it.
+ - Overflow alignment is not applied (same as the default behavior above).
In addition, JS APIs like `CSSPositionTryRule` or `CSS.supports` will not be
polyfilled.
diff --git a/position-area.html b/position-area.html
index ee6b3341..c340e9f7 100644
--- a/position-area.html
+++ b/position-area.html
@@ -32,9 +32,16 @@
const btn = document.getElementById('apply-polyfill');
+ // Add `?no-containing-block` to the URL to apply the polyfill with
+ // `positionAreaContainingBlock: false`, which positions targets
+ // directly instead of wrapping them.
+ const positionAreaContainingBlock = !new URLSearchParams(
+ location.search,
+ ).has('no-containing-block');
+
if (!SUPPORTS_ANCHOR_POSITIONING) {
btn.addEventListener('click', () =>
- polyfill().then((rules) => {
+ polyfill({ positionAreaContainingBlock }).then((rules) => {
btn.innerText = 'Polyfill Applied';
btn.setAttribute('disabled', '');
console.log(rules);
@@ -113,6 +120,13 @@
Placing elements with position-area
this may impact selectors that target the positioned element using
direct child or sibling selectors.
+
+ Alternatively, set the positionAreaContainingBlock polyfill
+ option to false to compute and apply inset values directly
+ on the positioned element, without a wrapper element.
+ Try these examples without the wrapper.
+
diff --git a/src/parse.ts b/src/parse.ts
index a91a1b54..ce577f60 100644
--- a/src/parse.ts
+++ b/src/parse.ts
@@ -23,6 +23,7 @@ import type {
NormalizedAnchorPositioningPolyfillOptions,
} from './polyfill.js';
import {
+ activeTargetStyles,
activeWrapperStyles,
addPositionAreaDeclarationBlockStyles,
dataForPositionAreaTarget,
@@ -304,6 +305,8 @@ export async function parseCSS(
) {
const anchorFunctions: AnchorFunctionDeclarations = {};
const positionAreas: PositionAreaDeclarations = {};
+ const positionAreaContainingBlock =
+ options.positionAreaContainingBlock ?? true;
resetStores();
// Parse `position-try` and related declarations/rules
@@ -359,6 +362,7 @@ export async function parseCSS(
addPositionAreaDeclarationBlockStyles(
positionAreaDeclaration,
this.block,
+ positionAreaContainingBlock,
);
for (const { selector } of selectors) {
positionAreas[selector] = [
@@ -776,14 +780,20 @@ export async function parseCSS(
roots: options.roots,
});
// For every position-area declaration with this selector, create a new
- // UUID, and make sure the target has a wrapper.
+ // UUID, and make sure the target has a wrapper (or is marked with a
+ // matching attribute, when the `positionAreaContainingBlock` option is
+ // `false`).
for (const positionData of positions) {
const targetData = await dataForPositionAreaTarget(
targetEl,
positionData,
anchorEl,
+ positionAreaContainingBlock,
);
- positionAreaMappingStyleElement.css += activeWrapperStyles(
+ const activeStyles = positionAreaContainingBlock
+ ? activeWrapperStyles
+ : activeTargetStyles;
+ positionAreaMappingStyleElement.css += activeStyles(
targetData.targetUUID,
positionData.selectorUUID,
);
diff --git a/src/polyfill.ts b/src/polyfill.ts
index 9933949c..b9ee7929 100644
--- a/src/polyfill.ts
+++ b/src/polyfill.ts
@@ -20,6 +20,7 @@ import {
import {
type InsetValue,
POSITION_AREA_CASCADE_PROPERTY,
+ POSITION_AREA_TARGET_ATTRIBUTE,
POSITION_AREA_WRAPPER_ATTRIBUTE,
type PositionAreaTargetData,
} from './position-area.js';
@@ -299,7 +300,7 @@ export const getPixelValue = async ({
const isPositionAreaTarget = (
value: AnchorFunction | PositionAreaTargetData,
): value is PositionAreaTargetData => {
- return 'wrapperEl' in value;
+ return 'targetUUID' in value;
};
const isAnchorFunction = (
@@ -308,6 +309,33 @@ const isAnchorFunction = (
return 'uuid' in value;
};
+// Resolve the alignment within the position-area grid area to a pair of inset
+// values for one axis. `start` and `end` alignment only set the inset on the
+// aligned side, so the target keeps its natural size and position based on
+// normal absolute positioning rules. `center` alignment computes the centered
+// offset from the grid area size and the target's margin-box size.
+const resolveAxisInsetValues = (
+ alignment: 'start' | 'end' | 'center',
+ areaStart: string | null,
+ areaEnd: string | null,
+ containingBlockSize: number,
+ targetMarginBoxSize: number,
+): [string, string] => {
+ switch (alignment) {
+ case 'start':
+ return [areaStart ?? '0px', 'auto'];
+ case 'end':
+ return ['auto', areaEnd ?? '0px'];
+ case 'center': {
+ const start = parseFloat(areaStart ?? '0');
+ const end = parseFloat(areaEnd ?? '0');
+ const offset =
+ start + (containingBlockSize - start - end - targetMarginBoxSize) / 2;
+ return [`${offset}px`, 'auto'];
+ }
+ }
+};
+
async function applyAnchorPositions(
declarations: AnchorFunctionDeclaration,
useAnimationFrame = false,
@@ -323,7 +351,14 @@ async function applyAnchorPositions(
const target = anchorValue.targetEl;
if (anchor && target) {
if (isPositionAreaTarget(anchorValue)) {
- const wrapper = anchorValue.wrapperEl!;
+ // When the `positionAreaContainingBlock` option is `true`, the
+ // target is wrapped with an element that approximates the
+ // containing block created by `position-area`, and the insets and
+ // alignments are applied through CSS on the wrapper and target.
+ // Otherwise the alignment is resolved to inset values that are
+ // applied directly to the target.
+ const wrapper = anchorValue.wrapperEl;
+ const floating = wrapper ?? target;
const getPositionAreaPixelValue = async (
inset: InsetValue,
targetProperty: GetPixelValueOpts['targetProperty'],
@@ -331,7 +366,7 @@ async function applyAnchorPositions(
) => {
if (inset === 0) return '0px';
return await getPixelValue({
- targetEl: wrapper,
+ targetEl: floating,
targetProperty: targetProperty,
anchorRect: anchorRect,
anchorSide: inset,
@@ -340,46 +375,95 @@ async function applyAnchorPositions(
autoUpdate(
anchor,
- wrapper,
+ floating,
async () => {
// Check which `position-area` declaration would win based on the
- // cascade, and apply an attribute on the wrapper. This activates
- // the generated CSS styles that map the inset and alignment
- // values to their respective properties.
+ // cascade, and apply an attribute on the wrapper (or the target,
+ // when there is no wrapper). This activates the generated CSS
+ // styles that map the inset and alignment values to their
+ // respective properties.
const appliedId = getCSSPropertyValue(
target,
POSITION_AREA_CASCADE_PROPERTY,
);
- wrapper.setAttribute(POSITION_AREA_WRAPPER_ATTRIBUTE, appliedId);
+ if (wrapper) {
+ wrapper.setAttribute(
+ POSITION_AREA_WRAPPER_ATTRIBUTE,
+ appliedId,
+ );
+ } else {
+ target.setAttribute(POSITION_AREA_TARGET_ATTRIBUTE, appliedId);
+ }
const rects = await platform.getElementRects({
reference: anchor,
- floating: wrapper,
+ floating,
strategy: 'absolute',
});
const insets = anchorValue.insets;
- const topInset = await getPositionAreaPixelValue(
+ // Insets of the position-area grid area, relative to the
+ // target's containing block.
+ const areaTop = await getPositionAreaPixelValue(
insets.block[0],
'top',
rects.reference,
);
- const bottomInset = await getPositionAreaPixelValue(
+ const areaBottom = await getPositionAreaPixelValue(
insets.block[1],
'bottom',
rects.reference,
);
- const leftInset = await getPositionAreaPixelValue(
+ const areaLeft = await getPositionAreaPixelValue(
insets.inline[0],
'left',
rects.reference,
);
- const rightInset = await getPositionAreaPixelValue(
+ const areaRight = await getPositionAreaPixelValue(
insets.inline[1],
'right',
rects.reference,
);
+ let topInset, bottomInset, leftInset, rightInset;
+ if (wrapper) {
+ // The grid area insets are applied to the wrapper, and the
+ // target is aligned within it through `justify-self` and
+ // `align-self`.
+ [topInset, bottomInset, leftInset, rightInset] = [
+ areaTop,
+ areaBottom,
+ areaLeft,
+ areaRight,
+ ];
+ root.style.setProperty(
+ `${anchorValue.targetUUID}-justify-self`,
+ anchorValue.alignments.inline,
+ );
+ root.style.setProperty(
+ `${anchorValue.targetUUID}-align-self`,
+ anchorValue.alignments.block,
+ );
+ } else {
+ const offsetParent = await getOffsetParent(target);
+ const margins = getMargins(target);
+
+ [topInset, bottomInset] = resolveAxisInsetValues(
+ anchorValue.alignments.block,
+ areaTop,
+ areaBottom,
+ offsetParent.clientHeight,
+ rects.floating.height + margins.top + margins.bottom,
+ );
+ [leftInset, rightInset] = resolveAxisInsetValues(
+ anchorValue.alignments.inline,
+ areaLeft,
+ areaRight,
+ offsetParent.clientWidth,
+ rects.floating.width + margins.left + margins.right,
+ );
+ }
+
root.style.setProperty(
`${anchorValue.targetUUID}-top`,
topInset || null,
@@ -396,14 +480,6 @@ async function applyAnchorPositions(
`${anchorValue.targetUUID}-bottom`,
bottomInset || null,
);
- root.style.setProperty(
- `${anchorValue.targetUUID}-justify-self`,
- anchorValue.alignments.inline,
- );
- root.style.setProperty(
- `${anchorValue.targetUUID}-align-self`,
- anchorValue.alignments.block,
- );
},
{ animationFrame: useAnimationFrame },
);
@@ -582,12 +658,23 @@ export interface AnchorPositioningPolyfillOptions {
// in the `elements` option will still be polyfilled, but no other elements
// in the document will be implicitly polyfilled.
excludeInlineStyles?: boolean;
+
+ /**
+ * Whether `position-area` is polyfilled by wrapping the target with an
+ * element that approximates the containing block created by
+ * `position-area`. When set to `false`, no wrapper element is added, and
+ * the polyfill computes and applies inset values on the target itself
+ * instead.
+ * @default true
+ */
+ positionAreaContainingBlock?: boolean;
}
/** @internal */
export interface NormalizedAnchorPositioningPolyfillOptions {
elements?: HTMLElement[];
excludeInlineStyles?: boolean;
+ positionAreaContainingBlock?: boolean;
roots: AnchorPositioningRoot[];
useAnimationFrame?: boolean;
}
@@ -612,6 +699,10 @@ function normalizePolyfillOptions(
options.roots = [document];
}
+ if (options.positionAreaContainingBlock === undefined) {
+ options.positionAreaContainingBlock = true;
+ }
+
return Object.assign(options, {
useAnimationFrame,
}) as NormalizedAnchorPositioningPolyfillOptions;
@@ -646,7 +737,7 @@ export async function polyfill(
styleData = transformCSS(styleData);
}
// parse CSS
- const parsedCSS = await parseCSS(styleData, { roots: options.roots });
+ const parsedCSS = await parseCSS(styleData, options);
rules = parsedCSS.rules;
inlineStyles = parsedCSS.inlineStyles;
} catch (error) {
diff --git a/src/position-area.ts b/src/position-area.ts
index 8f3fc291..b4836ad8 100644
--- a/src/position-area.ts
+++ b/src/position-area.ts
@@ -9,16 +9,24 @@
// Because each declaration may apply to multiple targets, and the generated
// containing block for each target may be different, we create a targetUUID for
// each element targeted by a selector. This is the UUID that is used to
-// generate the inset and alignment values in polyfill.ts that are applied to
-// the root element.
+// generate the inset values in polyfill.ts that are applied to the root
+// element.
// The rules are created in a new stylesheet that matches the selectorUUID that
// won the cascade and the targetUUID. This stylesheet maps the properties set
// on the root element to `--pa-value-*:`.
-// Each target is wrapped with a `polyfill-position-area` element. It sets its
-// inset values from `--pa-value-*` values. The `justify-self` and `align-self`
-// properties are mapped on the element itself.
+// By default (the `positionAreaContainingBlock` option is `true`), each target
+// is wrapped with a `polyfill-position-area` element that approximates the
+// containing block created by `position-area`. The wrapper sets its inset
+// values from the `--pa-value-*` values, and the `justify-self` and
+// `align-self` properties are mapped on the target itself.
+
+// When the `positionAreaContainingBlock` option is `false`, no wrapper element
+// is created. The target's inset properties are set to the `--pa-value-*`
+// values in the same rule as the `position-area` declaration, and alignment
+// within the position-area grid area is resolved to pixel inset values in
+// polyfill.ts, so the target is positioned directly.
import { type Block, type CssNode, type Identifier } from 'css-tree';
import { type List } from 'css-tree/utils';
@@ -36,7 +44,13 @@ export const POSITION_AREA_CASCADE_PROPERTY = '--pa-cascade-property';
// `POSITION_AREA_CASCADE_PROPERTY` as the value.
export const POSITION_AREA_WRAPPER_ATTRIBUTE = 'data-anchor-position-wrapper';
+// Set this as an attribute on the target with the uuid of the winning
+// `POSITION_AREA_CASCADE_PROPERTY` as the value, when the
+// `positionAreaContainingBlock` option is `false`.
+export const POSITION_AREA_TARGET_ATTRIBUTE = 'data-anchor-position-area';
+
const WRAPPER_TARGET_ATTRIBUTE_PRELUDE = 'data-pa-wrapper-for-';
+const TARGET_ATTRIBUTE_PRELUDE = 'data-pa-target-for-';
const WRAPPER_ELEMENT = 'POLYFILL-POSITION-AREA';
type PositionAreaGridValue = 0 | 1 | 2 | 3;
@@ -445,7 +459,8 @@ export interface PositionAreaTargetData {
selectorUUID: string;
targetUUID: string;
anchorEl: HTMLElement | PseudoElement | null;
- wrapperEl: HTMLElement;
+ // Only set when the `positionAreaContainingBlock` option is `true`.
+ wrapperEl?: HTMLElement;
targetEl: HTMLElement;
}
@@ -515,12 +530,14 @@ export function getPositionAreaDeclaration(
export function addPositionAreaDeclarationBlockStyles(
declaration: PositionAreaDeclaration,
block: Block,
+ positionAreaContainingBlock = true,
) {
- [
- // Insets are applied to a wrapping element
- 'justify-self',
- 'align-self',
- ].forEach((prop) => {
+ const props = positionAreaContainingBlock
+ ? // Insets are applied to a wrapping element
+ ['justify-self', 'align-self']
+ : // Insets are applied to the target itself
+ ['top', 'left', 'right', 'bottom'];
+ props.forEach((prop) => {
block.children.appendData({
type: 'Declaration',
property: prop,
@@ -570,10 +587,20 @@ export function wrapperForPositionedElement(
return wrapperEl;
}
+export function markPositionAreaTarget(
+ targetEl: HTMLElement,
+ targetUUID: string,
+) {
+ // A target can be affected by multiple declarations, so set each targetUUID
+ // as a boolean attribute instead of a value.
+ targetEl.setAttribute(`${TARGET_ATTRIBUTE_PRELUDE}${targetUUID}`, '');
+}
+
export async function dataForPositionAreaTarget(
targetEl: HTMLElement,
positionAreaData: PositionAreaDeclaration,
anchorEl: HTMLElement | PseudoElement | null,
+ positionAreaContainingBlock = true,
): Promise {
const targetUUID = `--pa-target-${nanoid(12)}`;
const writingModeModifiedGrid = await getWritingModeModifiedGrid(
@@ -582,16 +609,28 @@ export async function dataForPositionAreaTarget(
);
const insets = getInsets(writingModeModifiedGrid);
- const relevantWritingMode = getRelevantWritingMode(
- positionAreaData.grid.block[2],
- positionAreaData.grid.inline[2],
- );
- const alignmentGrid = [
- WritingMode.LogicalSelf,
- WritingMode.PhysicalSelf,
- ].includes(relevantWritingMode)
- ? writingModeModifiedGrid
- : positionAreaData.grid;
+ let alignmentGrid;
+ if (positionAreaContainingBlock) {
+ // The wrapper inherits the writing mode of the containing block, so
+ // logical alignment values are resolved natively by CSS. Only `self`
+ // writing modes (based on the target's own writing mode) need the
+ // writing-mode-modified grid.
+ const relevantWritingMode = getRelevantWritingMode(
+ positionAreaData.grid.block[2],
+ positionAreaData.grid.inline[2],
+ );
+ alignmentGrid = [
+ WritingMode.LogicalSelf,
+ WritingMode.PhysicalSelf,
+ ].includes(relevantWritingMode)
+ ? writingModeModifiedGrid
+ : positionAreaData.grid;
+ } else {
+ // Alignments are resolved to physical inset values in polyfill.ts, so
+ // they are always based on the writing-mode-modified (physical) grid,
+ // like the insets.
+ alignmentGrid = writingModeModifiedGrid;
+ }
const alignments = {
block: getAxisAlignment([alignmentGrid.block[0], alignmentGrid.block[1]]),
inline: getAxisAlignment([
@@ -600,13 +639,20 @@ export async function dataForPositionAreaTarget(
]),
};
+ let wrapperEl;
+ if (positionAreaContainingBlock) {
+ wrapperEl = wrapperForPositionedElement(targetEl, targetUUID);
+ } else {
+ markPositionAreaTarget(targetEl, targetUUID);
+ }
+
return {
insets,
alignments,
targetUUID,
targetEl,
anchorEl,
- wrapperEl: wrapperForPositionedElement(targetEl, targetUUID),
+ wrapperEl,
values: positionAreaData.values,
grid: positionAreaData.grid,
selectorUUID: positionAreaData.selectorUUID,
@@ -625,3 +671,14 @@ export function activeWrapperStyles(targetUUID: string, selectorUUID: string) {
}
`.replaceAll('\n', '');
}
+
+export function activeTargetStyles(targetUUID: string, selectorUUID: string) {
+ return `
+ [${POSITION_AREA_TARGET_ATTRIBUTE}="${selectorUUID}"][${TARGET_ATTRIBUTE_PRELUDE}${targetUUID}] {
+ --pa-value-top: var(${targetUUID}-top);
+ --pa-value-left: var(${targetUUID}-left);
+ --pa-value-right: var(${targetUUID}-right);
+ --pa-value-bottom: var(${targetUUID}-bottom);
+ }
+ `.replaceAll('\n', '');
+}
diff --git a/tests/e2e/position-area.test.ts b/tests/e2e/position-area.test.ts
index 8398f769..28665fad 100644
--- a/tests/e2e/position-area.test.ts
+++ b/tests/e2e/position-area.test.ts
@@ -177,3 +177,113 @@ test('applies logical self properties based on writing mode`', async ({
0,
);
});
+
+test.describe('with `positionAreaContainingBlock: false`', () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto('/position-area.html?no-containing-block');
+ });
+
+ test('does not wrap the target element', async ({ page }) => {
+ await applyPolyfill(page);
+ await expect(page.locator('polyfill-position-area')).toHaveCount(0);
+ // The target is still a direct child of its original parent, so selectors
+ // that rely on a direct relationship with the target keep working.
+ await expect(
+ page.locator('#spanleft-top .demo-elements > .target'),
+ ).toHaveCount(1);
+ });
+
+ test('applies polyfill for position-area', async ({ page }) => {
+ await applyPolyfill(page);
+ const section = page.locator('#spanleft-top');
+ const anchor = section.locator('.anchor');
+ const anchorBox = await anchor.boundingBox();
+
+ const target = section.locator('.target');
+ const targetBox = await target.boundingBox();
+
+ // Right sides should be aligned
+ expect(targetBox!.x + targetBox!.width).toBeCloseTo(
+ anchorBox!.x + anchorBox!.width,
+ 0,
+ );
+ // Target bottom should be aligned with anchor top
+ expect(targetBox!.y + targetBox!.height).toBeCloseTo(anchorBox!.y, 0);
+ });
+
+ test('applies to declarations with different containing blocks', async ({
+ page,
+ }) => {
+ await applyPolyfill(page);
+ const section = page.locator('#different-containers');
+
+ for (const testId of ['container1', 'container2']) {
+ const container = section.getByTestId(testId);
+ const anchorBox = await container.locator('.anchor').boundingBox();
+ const targetBox = await container.locator('.target').boundingBox();
+
+ // Target left should be aligned with anchor right
+ expect(targetBox!.x).toBeCloseTo(anchorBox!.x + anchorBox!.width, 0);
+ // Target top should be aligned with anchor bottom
+ expect(targetBox!.y).toBeCloseTo(anchorBox!.y + anchorBox!.height, 0);
+ }
+ });
+
+ test('respects cascade', async ({ page }) => {
+ await applyPolyfill(page);
+ const section = page.locator('#cascade');
+ const anchor = section.locator('.anchor');
+ const target = section.locator('#cascade-target');
+
+ const anchorBox = await anchor.boundingBox();
+ const targetBox = await target.boundingBox();
+
+ // `right top` should win initially
+ expect(targetBox!.x).toBeCloseTo(anchorBox!.x + anchorBox!.width, 0);
+ expect(targetBox!.y + targetBox!.height).toBeCloseTo(anchorBox!.y, 0);
+
+ // Switch the cascade so `right bottom` wins, and trigger a position
+ // recalculation by scrolling.
+ await page.locator('#switch-cascade').click();
+ await page.mouse.wheel(0, 10);
+
+ await expect(async () => {
+ const aBox = await anchor.boundingBox();
+ const tBox = await target.boundingBox();
+ expect(tBox!.x).toBeCloseTo(aBox!.x + aBox!.width, 0);
+ expect(tBox!.y).toBeCloseTo(aBox!.y + aBox!.height, 0);
+ }).toPass();
+ });
+
+ test('applies logical properties based on writing mode', async ({ page }) => {
+ await applyPolyfill(page);
+ const section = page.getByTestId('vertical-rl-rtl');
+ const anchorBox = await section.locator('.anchor').boundingBox();
+
+ const target = section.locator('.target');
+ const targetBox = await target.boundingBox();
+ await expect(target).toHaveText('vertical-rl rtl');
+
+ // Right side should be aligned with anchor left
+ expect(targetBox!.x + targetBox!.width).toBeCloseTo(anchorBox!.x, 0);
+ // Target bottom should be aligned with anchor top
+ expect(targetBox!.y + targetBox!.height).toBeCloseTo(anchorBox!.y, 0);
+ });
+
+ test('applies logical self properties based on writing mode', async ({
+ page,
+ }) => {
+ await applyPolyfill(page);
+ const section = page.getByTestId('self-vertical-lr-rtl');
+ const anchorBox = await section.locator('.anchor').boundingBox();
+
+ const target = section.locator('.target');
+ const targetBox = await target.boundingBox();
+ await expect(target).toHaveText('vertical-lr rtl');
+
+ // Left side should be aligned with anchor right
+ expect(targetBox!.x).toBeCloseTo(anchorBox!.x + anchorBox!.width, 0);
+ // Target bottom should be aligned with anchor top
+ expect(targetBox!.y + targetBox!.height).toBeCloseTo(anchorBox!.y, 0);
+ });
+});
diff --git a/tests/unit/__snapshots__/position-area.test.ts.snap b/tests/unit/__snapshots__/position-area.test.ts.snap
index 268276f9..2a87772d 100644
--- a/tests/unit/__snapshots__/position-area.test.ts.snap
+++ b/tests/unit/__snapshots__/position-area.test.ts.snap
@@ -1,3 +1,5 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+exports[`position-area > activeTargetStyles > returns the active styles 1`] = `" [data-anchor-position-area="selectorUUID"][data-pa-target-for-targetUUID] { --pa-value-top: var(targetUUID-top); --pa-value-left: var(targetUUID-left); --pa-value-right: var(targetUUID-right); --pa-value-bottom: var(targetUUID-bottom); } "`;
+
exports[`position-area > activeWrapperStyles > returns the active styles 1`] = `" [data-anchor-position-wrapper="selectorUUID"][data-pa-wrapper-for-targetUUID] { --pa-value-top: var(targetUUID-top); --pa-value-left: var(targetUUID-left); --pa-value-right: var(targetUUID-right); --pa-value-bottom: var(targetUUID-bottom); --pa-value-justify-self: var(targetUUID-justify-self); --pa-value-align-self: var(targetUUID-align-self); } "`;
diff --git a/tests/unit/position-area.test.ts b/tests/unit/position-area.test.ts
index dfdafd12..2a81f548 100644
--- a/tests/unit/position-area.test.ts
+++ b/tests/unit/position-area.test.ts
@@ -1,10 +1,12 @@
import type { Rule, StyleSheet } from 'css-tree';
import {
+ activeTargetStyles,
activeWrapperStyles,
axisForPositionAreaValue,
dataForPositionAreaTarget,
getPositionAreaDeclaration,
+ markPositionAreaTarget,
wrapperForPositionedElement,
} from '../../src/position-area.js';
import { getAST } from '../../src/utils.js';
@@ -193,6 +195,51 @@ describe('position-area', () => {
});
});
+ describe('markPositionAreaTarget', () => {
+ let element: HTMLElement;
+ beforeEach(() => {
+ element = document.createElement('div');
+ });
+ it('marks a target', () => {
+ markPositionAreaTarget(element, 'uuid');
+ expect(element.hasAttribute('data-pa-target-for-uuid')).toBe(true);
+ });
+ it('marks a target for multiple declarations', () => {
+ markPositionAreaTarget(element, 'uuid1');
+ markPositionAreaTarget(element, 'uuid2');
+ expect(element.hasAttribute('data-pa-target-for-uuid1')).toBe(true);
+ expect(element.hasAttribute('data-pa-target-for-uuid2')).toBe(true);
+ });
+ });
+
+ describe('dataForPositionAreaTarget', () => {
+ it('wraps the target by default', async () => {
+ const element = createEl();
+ const res = await dataForPositionAreaTarget(
+ element,
+ getPositionAreaDeclaration(createPositionAreaNode(['top', 'right']))!,
+ null,
+ );
+ expect(res.wrapperEl).toBeDefined();
+ expect(res.wrapperEl!.tagName).toBe('POLYFILL-POSITION-AREA');
+ expect(element.parentElement).toBe(res.wrapperEl);
+ });
+ it('marks the target without `positionAreaContainingBlock`', async () => {
+ const element = createEl();
+ const res = await dataForPositionAreaTarget(
+ element,
+ getPositionAreaDeclaration(createPositionAreaNode(['top', 'right']))!,
+ null,
+ false,
+ );
+ expect(res.wrapperEl).toBeUndefined();
+ expect(element.parentElement).toBeNull();
+ expect(element.hasAttribute(`data-pa-target-for-${res.targetUUID}`)).toBe(
+ true,
+ );
+ });
+ });
+
describe('activeWrapperStyles', () => {
it('returns the active styles', () => {
expect(
@@ -200,4 +247,12 @@ describe('position-area', () => {
).toMatchSnapshot();
});
});
+
+ describe('activeTargetStyles', () => {
+ it('returns the active styles', () => {
+ expect(
+ activeTargetStyles('targetUUID', 'selectorUUID'),
+ ).toMatchSnapshot();
+ });
+ });
});
From 4b53b9fc2a41ee60cb6c024292173f55df931b14 Mon Sep 17 00:00:00 2001
From: Jeroen Zwartepoorte
Date: Fri, 12 Jun 2026 11:51:43 +0200
Subject: [PATCH 2/3] Remove snapshot
---
tests/unit/__snapshots__/position-area.test.ts.snap | 5 -----
1 file changed, 5 deletions(-)
delete mode 100644 tests/unit/__snapshots__/position-area.test.ts.snap
diff --git a/tests/unit/__snapshots__/position-area.test.ts.snap b/tests/unit/__snapshots__/position-area.test.ts.snap
deleted file mode 100644
index 2a87772d..00000000
--- a/tests/unit/__snapshots__/position-area.test.ts.snap
+++ /dev/null
@@ -1,5 +0,0 @@
-// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
-
-exports[`position-area > activeTargetStyles > returns the active styles 1`] = `" [data-anchor-position-area="selectorUUID"][data-pa-target-for-targetUUID] { --pa-value-top: var(targetUUID-top); --pa-value-left: var(targetUUID-left); --pa-value-right: var(targetUUID-right); --pa-value-bottom: var(targetUUID-bottom); } "`;
-
-exports[`position-area > activeWrapperStyles > returns the active styles 1`] = `" [data-anchor-position-wrapper="selectorUUID"][data-pa-wrapper-for-targetUUID] { --pa-value-top: var(targetUUID-top); --pa-value-left: var(targetUUID-left); --pa-value-right: var(targetUUID-right); --pa-value-bottom: var(targetUUID-bottom); --pa-value-justify-self: var(targetUUID-justify-self); --pa-value-align-self: var(targetUUID-align-self); } "`;
From d5340ea774bee407b02575463f6ba72287376279 Mon Sep 17 00:00:00 2001
From: Jeroen Zwartepoorte
Date: Tue, 16 Jun 2026 12:24:05 +0200
Subject: [PATCH 3/3] Experiment with "auto" setting
---
README.md | 13 +-
position-area.html | 16 +-
src/parse.ts | 52 +++++-
src/polyfill.ts | 10 +-
src/position-area.ts | 164 +++++++++++++++---
tests/e2e/position-area.test.ts | 65 +++++++
.../__snapshots__/position-area.test.ts.snap | 5 +
tests/unit/position-area.test.ts | 90 +++++++++-
8 files changed, 374 insertions(+), 41 deletions(-)
create mode 100644 tests/unit/__snapshots__/position-area.test.ts.snap
diff --git a/README.md b/README.md
index 0aa3fd77..e13be4db 100644
--- a/README.md
+++ b/README.md
@@ -153,7 +153,7 @@ document will be implicitly polyfilled.
### positionAreaContainingBlock
-type: `boolean`, default: `true`
+type: `boolean | 'auto'`, default: `true`
By default, `position-area` is polyfilled by wrapping the target with a
`` element that approximates the containing block
@@ -164,6 +164,17 @@ with the target (for instance `> target` or `:nth-child()`), but comes with
its own trade-offs. See [Limitations](#limitations) for the differences
between the two approaches.
+When set to `'auto'`, the choice is made per target: the wrapper is added only
+for targets whose styles resolve against the containing block — percentage
+sizes (`width`, `height`, `min-*`, `max-*`), `stretch`/`-webkit-fill-available`
+sizes, percentage or `auto` margins, percentage padding, or `stretch`/
+`anchor-center` self-alignment. Every other target is positioned directly,
+without a wrapper. This keeps the wrapper (and its selector trade-offs) only
+where it is needed to preserve correct sizing. The detection is conservative
+and reads authored values, so targets whose containing-block dependence is only
+expressed dynamically (e.g. set via script after the polyfill runs) may not be
+detected.
+
### roots
type: `(Document | HTMLElement | ShadowRoot)[]`, default: `[document]`
diff --git a/position-area.html b/position-area.html
index c340e9f7..3178937f 100644
--- a/position-area.html
+++ b/position-area.html
@@ -34,10 +34,14 @@
// Add `?no-containing-block` to the URL to apply the polyfill with
// `positionAreaContainingBlock: false`, which positions targets
- // directly instead of wrapping them.
- const positionAreaContainingBlock = !new URLSearchParams(
- location.search,
- ).has('no-containing-block');
+ // directly instead of wrapping them. Add `?auto` to use `'auto'`, which
+ // wraps only targets whose styles resolve against the containing block.
+ const params = new URLSearchParams(location.search);
+ const positionAreaContainingBlock = params.has('no-containing-block')
+ ? false
+ : params.has('auto')
+ ? 'auto'
+ : true;
if (!SUPPORTS_ANCHOR_POSITIONING) {
btn.addEventListener('click', () =>
@@ -125,7 +129,9 @@
Placing elements with position-area
option to false to compute and apply inset values directly
on the positioned element, without a wrapper element.
Try these examples without the wrapper.
+ >. Or set it to 'auto' to add the wrapper only for targets
+ whose styles resolve against the containing block.
+ Try these examples in auto mode.