Full spec
SPEC.md from the repo, rendered on every build.
A zero-JS, HTML-first constraint layout engine mapping CSS Anchor Positioning to an iOS Auto Layout-style DOM API.
tests/ has a live, runnable version of everything below — one demo+test HTML file per feature, viewable directly in a browser or checked headlessly via npm test. Treat it as the executable source of truth for what actually works, not just this document.
Editor autocomplete: anchored-auto-layout.html-data.json describes every data-⚓️-* attribute (with value suggestions where applicable) in VS Code's HTML custom data format. Point your project's .vscode/settings.json at it:
{ "html.customData": ["./node_modules/anchored-auto-layout/anchored-auto-layout.html-data.json"] }
(This repo's own .vscode/settings.json already wires it up locally.)
Core Principles
- Relational & Extrinsic: Position relative to any element, breaking strict DOM hierarchy.
- Zero-JS Execution: Math, collisions, and visibility handled entirely by the browser's C++ engine.
- HTML-First & Distinct: Layout logic lives in markup. The
data-⚓️-*namespace visually separates constraints from styling classes. - i18n-Native: Driven by CSS logical properties for automatic RTL layout mirroring.
Syntax & Routing
- Explicit (Multi-Anchor): String values target specific anchors (e.g.,
data-⚓️-below="--header"). - Implicit (Boolean): Setting
data-⚓️-to="--btn"allows modifiers to act as booleans (e.g.,data-⚓️-below,data-⚓️-sync-width).
API Reference
| Category | Attributes | Details |
|---|---|---|
| Anchor | data-⚓️-name="--name" |
Makes the element an anchor: sets anchor-name from the value via typed attr(), so the value must be a dashed-ident. |
| Target | data-⚓️-to="--name" |
Binds the element to a named anchor: sets position-anchor, position: absolute, container-type: anchored (see Built-In Intelligence Layers) and box-sizing: border-box (so sync-* sizes match the anchor's border box), plus interpolate-size: allow-keywords. An element with both data-⚓️-to and data-⚓️-name also gets transform-style: preserve-3d (Sub-Menu Contexts). |
| Root | data-⚓️-root |
position: relative (plus interpolate-size: allow-keywords): the containing block for parent-pinned (Parent Pins) children. |
| Parent Pins | data-⚓️-pin-[top|bottom|leading|trailing|x|y|all] |
Pins target to its relative containing block. |
| Outside Edges | data-⚓️-[above|below|before|after] |
Positions target entirely outside the anchor's edge. |
| Side | data-⚓️-side="[above|below|before|after]" |
The same four outside edges as one attribute whose value is the side. Behaves identically to the booleans (logical, RTL-mirroring, native data-⚓️-try flips, tweens with data-⚓️-tween) but a state change is one value swap instead of remove-one/add-another, and two sides can't be set at once. Implicit anchor only (data-⚓️-to). |
| Inside Align | data-⚓️-align-[top|bottom|leading|trailing] |
Flushes target to the inside edge of the anchor. |
| Stretch Bounds | data-⚓️-stretch-[x|y] |
Spans the target across the anchor's full axis. |
| Fill | data-⚓️-fill |
Fills the anchor's full box on both axes at once: inset: anchor(inside) plus width/height from anchor-size() — the animatable alternative to stretch-x + stretch-y combined (see Known Browser Limitations). Author width/height override the built-in size. |
| Tweenable edges | data-⚓️-tween |
Opt-in: puts above/before/align-bottom/align-trailing on the START inset plus a percentage translate so a below↔above (or after↔before, top↔bottom) swap tweens instead of snapping. stretch-x/stretch-y keep the start inset and size from anchor-size() instead of the end inset, and align-x/align-y="center"/center-all use anchor(center) minus half their own size instead of anchor-center, so swaps between edge, stretch and center states tween too. sync-width/sync-height and author sizes still win over the stretch size. Horizontal writing modes only (RTL handled); don't combine with data-⚓️-try. Use with morph or glide. |
| Sizing & Math | data-⚓️-sync-[width|height] |
Sizes via: (Anchor * Multiplier) + Offset. Controlled via CSS variables: --⚓️-mult-[x|y] (default: 1) and --⚓️-offset-[x|y] (default: 0px). |
| Position Area | data-⚓️-area="[top|bottom|leading|trailing]" |
Coarse placement on one side of the anchor via position-area: block-start, block-end, inline-start, inline-end respectively (logical, so it mirrors in RTL). Takes the element out of flow (position: absolute). |
| Anchor Center | data-⚓️-align-x="center", data-⚓️-align-y="center", data-⚓️-center-all |
Centers the target on the anchor via anchor-center: align-x sets justify-self (inline axis), align-y sets align-self (block axis), center-all sets place-self (both). center is the only value. Each takes the element out of flow (position: absolute). With data-⚓️-tween they switch to anchor(center) minus half their own size (see Tweenable edges). |
| Margin Align | data-⚓️-align-self="[center|start|end]" |
Aligns a target inside the space its insets define, via auto margins: center sets margin: auto (both axes), start sets margin-inline-end: auto, end sets margin-inline-start: auto (inline axis only). Meant to pair with data-⚓️-stretch-[x|y] or Parent Pins. Doesn't take the element out of flow on its own. |
| Collisions | data-⚓️-try="flip-[x|y|all]", data-⚓️-try-order="most-[x|y]" |
Flips on axis if colliding. Try-order actively hunts for the quadrant with the most available space. |
| Visibility State | data-⚓️-visibility="[always|valid|visible|no-overflow|valid-no-overflow|visible-no-overflow|all]" |
Maps to the CSS position-visibility property (spec: always || anchor-valid || anchor-visible || no-overflow, default anchor-visible; the plural anchors-* spellings are the deprecated aliases, and the library declares both so older engines keep working). valid/visible/no-overflow are independently combinable; all applies all three (strictest). always opts out entirely. |
| Scoping | data-⚓️-scope="[all|--name]", data-⚓️-scope-name="--name" |
Fences anchor names to a subtree for component reuse via anchor-scope. scope="all" scopes every anchor name; scope="--name" and scope-name="--name" are equivalent and scope just that name. |
| Morphing (AIM) | data-⚓️-transition="morph|glide", data-⚓️-morph-from[="--anchor"] |
Animates an element from a collapsed state inside an anchor's bounding box to its final layout. morph-from sets the @starting-style origin (explicit anchor, or implicit/boolean to default to the element's own target); transition="morph" applies the spatial transition (inset, width, height, position-area, translate, opacity). Requires interpolate-size: allow-keywords (auto-applied to every data-⚓️-root/data-⚓️-to). --⚓️-tx/--⚓️-ty (the translate-workaround custom properties above/before/align-bottom/align-trailing use — see Known Browser Limitations) are registered via @property as <length-percentage>, so switching between any of those and a plain inset-based attribute (e.g. below → above) tweens smoothly instead of snapping — verified live by sampling the computed translate value every frame across a real transition. |
Built-In Intelligence Layers
- Top-Layer Reset: Applying a target to
<dialog>or[popover]natively strips the browser's aggressive User-Agent centering margins. - Sub-Menu Contexts: Elements acting as both target and anchor automatically establish a pristine 3D containing block (
preserve-3d) for nested dropdowns. - Anchored Container Queries: Targets automatically inject
container-type: anchored, allowing internal elements to restyle themselves based on fallback collision flips. Side effect worth knowing: like any non-normalcontainer-type, this makes the target a containing block forposition: fixeddescendants too (confirmed live), not just a query container — a plainposition: fixedelement nested anywhere inside a target no longer pins to the true viewport, it pins to the target's own box instead. Site-wide consequence here:<main>is itself a target (data-⚓️-to="--header"), so any page content wanting real viewport-fixed UI (a floating action button, a toast) needs to render outside<main>— e.g. portaled todocument.body— rather than relying on plainposition: fixedfrom within page content. Found building the playground's mobile "Controls" FAB, which rendered ~200px below the visible viewport before the fix. - Anchor Interpolated Morphing (AIM): Elements can animate their entry from perfectly collapsed inside an anchor's geometry (
data-⚓️-morph-from) to their resolved layout, using native@starting-style— no JS FLIP technique required.
Trade-offs & Constraints
- Extrinsic Handling: Elements are
position: absolute. Internal text overflow must be handled manually (overflow: auto). - Unidirectional: Circular dependencies (A ↔ B) are ignored by the browser.
- Prefix Required: HTML string values must explicitly include double dashes (e.g.,
"--header"). - Same-Axis Sizing Conflicts:
data-⚓️-stretch-[x|y](implicit sizing via opposing insets) anddata-⚓️-sync-[width|height](explicitwidth/height) both resolve the same axis differently. Combine them on different axes freely (e.g. stretch-x + sync-height), but on the same axis the explicitwidth/heightfromsync-*wins over the stretch insets, per standard over-constrained absolute-positioning sizing rules — the stretch is silently dropped on that axis, not merged.
Browser Support
The library needs CSS Anchor Positioning and typed attr() (attr(data-⚓️-x type(<custom-ident>))), which is how every --name gets from markup into CSS. Without typed attr() the attributes are inert, so feature-detect that, not anchoring: @supports (anchor-name: attr(data-x type(<custom-ident>))).
| Engine | Status |
|---|---|
| Chrome / Edge 133+ | Supported. The library, its test suite (npm test, Playwright Chromium) and the docsite are built and verified here. |
| Safari | Not supported in a stable release: anchor positioning shipped in 26, but typed attr() is Technology Preview only. |
| Firefox | Untested by the library's own suite. MDN lists anchor positioning from 147 and typed attr() from 155, but the parts marked "no" below don't apply there. |
Per feature (Chromium versions from the docsite's browser-support page; Firefox/Safari columns from MDN browser-compat-data, checked 2026-10-30, not verified by this library's tests):
| Feature | Used by | Chrome/Edge | Firefox | Safari |
|---|---|---|---|---|
Typed attr() (required) |
every attribute that takes a --name |
133 | 155 | Preview only |
| Anchor positioning (required) | everything: anchor(), position-anchor, anchor-size() |
125 | 147 | 26 |
position-area |
data-⚓️-area |
129 | 147 | 26 |
anchor-center |
data-⚓️-align-x, -align-y, -center-all |
125 | 147 | 26 |
position-try-fallbacks |
data-⚓️-try |
128 | 147 | 26 |
position-try-order |
data-⚓️-try-order |
125 | 148 | 26 |
anchor-scope |
data-⚓️-scope, -scope-name |
131 | 147 | 26 |
position-visibility (anchors-visible, no-overflow) |
data-⚓️-visibility |
125 | 147 | 26.2 |
position-visibility: anchor-valid |
data-⚓️-visibility values that include valid |
157 | No (plural anchors-valid 147) |
27 |
container-type: anchored |
every target (fallback-aware container queries) | 143 | No | No |
@starting-style |
data-⚓️-morph-from |
117 | 129 | 17.5 |
interpolate-size |
morphs to and from auto sizes |
129 | No | No |
Known Browser Limitations (verified live, not authoring bugs)
- Resolved — the "end-only inset" problem was the UA popover stylesheet, not a browser bug.
[popover]/<dialog>getinset: 0andfit-contentsizing from the UA sheet, which over-constrains any box that sets only one inset (aninset-block-end: anchor(...)alone rendered at the wrong place). The top-layer reset (section 1) clearsinset/width/height/margins inside:where(), so it has zero specificity and never beats an anchor()-driven longhand or an author class.above,before,align-bottomandalign-trailingare therefore plain logicalinset-block-end/inset-inline-endon every target type (an earliertranslateworkaround,--⚓️-tx/--⚓️-ty, was removed). Consequences: RTL and vertical writing modes work, nativeflip-block/flip-inlinework on popovers in both directions (customposition-areaflip blocks removed), anddata-⚓️-stretch-[x|y]sizes popovers correctly. Trade-off: switching between a start attribute and an end one (below<->above) changes which inset is used and inset cannot interpolate fromauto, so that swap snaps instead of tweening; same-attribute changes and size still morph. anchor-valid(data-⚓️-visibility="valid") needs Chrome 157+. The CSSWG renamedanchors-visible/anchors-validto singularanchor-visible/anchor-valid(blink-dev intents, Chrome 157, plurals kept as deprecated aliases). The old pluralanchors-validwas never shippable (reverted over spec ambiguity), so in Chromium 153CSS.supports("position-visibility", "anchors-valid")and the singular forms all returnfalse, while pluralanchors-visible,no-overflowandalwayswork. The library declares plural then singular for every value (an engine drops the declaration it doesn't parse), sovisibleworks everywhere and gains the singular spelling for free; any value includingvalid(valid,valid-no-overflow,all) silently falls back to the initial value until the engine supportsanchor-valid.tests/10-visibility-state.htmlprobes support withCSS.supportsand only treatsvalidvalues as a known gap when neither spelling parses.data-⚓️-trymeasures overflow against the target's containing block and the scrollable area. Confirmed while testing: an extraposition: absolutewrapper between a popover's anchor and the viewport, or a tall (scrollable) document, changes what counts as overflowing, so a box hanging off the bottom of the viewport can simply not flip. Anchors placed directly on a non-scrolling viewport (position: fixed) behave as expected (tests 09 and 16).position-try-fallbacksdoes not re-evaluate on scroll — confirmed live, current Chromium (153.0.8010.12). An anchor-positioned element (plain or[popover], root-document scroll or anoverflow: autoscroll container,data-⚓️-tryon any axis) picks its fallback exactly once — apparently checking overflow against the anchor's position as ofscrollY: 0/initial layout, never the actual current scroll-adjusted viewport position. Verified 5 independent ways: scrolling an already-open popover into an overflowing position (no re-flip, holds for 4.5s+ across 15 polled frames); resizing the viewport instead of scrolling (same); closing and reopening a popover while already scrolled to an overflowing position (still no flip — rules out a stale/cached fallback choice, since this is a freshshowPopover()call); and inserting a brand-new DOM node (never laid out before) at an already-scrolled, already-overflowing position (still no flip — rules out any per-element caching entirely). A page scrolled viawindow.scrollTo()before the browser's first paint pass still doesn't get correct fallback selection either, which is what pins this down precisely: the algorithm isn't stale-caching a past result, it's not factoring scroll position into its overflow check at all, ever. This directly contradicts MDN's own description of the feature ("re-evaluates continuously as the user scrolls") — worth re-testing against a future Chromium release rather than assuming MDN is simply wrong. Practical impact: any UI built on the assumption that scrolling an anchor toward a viewport edge live-triggers itsdata-⚓️-tryfallback (e.g. a tooltip that's expected to flip sides as its anchor scrolls near the top/bottom of the screen) will not work today — the fallback is only correctly chosen at the moment the element is first laid out (its very first style/layout pass,scrollYbaked in at that instant), not on later scroll events. No workaround identified; this sits below the CSS layer entirely.- Animating a fill across a reflow:
data-⚓️-fillhandles it,stretch-x+stretch-ydon't. Confirmed live building the animated-grid-reflow demo (Bramus's technique of anchoring absolutely-positioned content to a grid cell so it can animate independently of the grid's own instant reflow): a box sized purely by 4 inset longhands (stretch-x+stretch-y), or by theinset: anchor(inside)shorthand with no explicit size, visibly collapses to ~0px wide partway through atransition: insetwhen its anchor moves, even though old and new size are identical.data-⚓️-filltherefore sets both the singleinset: anchor(inside)shorthand and an explicitwidth/heightfromanchor-size(), which makes the size authoritative so only the position animates (verified sampling every frame: no collapse, in real Chrome). Chrome still reports four separateinset-*transitions for the shorthand, so why the shorthand plus explicit size works isn't established. Authorwidth/heightoverride the built-in ones.