Anchored Auto Layout
npm

Hover or focus a box to measure it. Click its tag, or press ] and [, to pin one. Esc unpins.

    Legend
    • Anchor: a box with data-⚓️-name
    • Placed by anchors: out of flow
    • Gap from the anchor's edge, in px
    • Edges of the box you're inspecting

    Every tag, line and number here is itself placed by data-⚓️-* attributes. JS only measures.

    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-normal container-type, this makes the target a containing block for position: fixed descendants too (confirmed live), not just a query container — a plain position: fixed element 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 to document.body — rather than relying on plain position: fixed from 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) and data-⚓️-sync-[width|height] (explicit width/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 explicit width/height from sync-* 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> get inset: 0 and fit-content sizing from the UA sheet, which over-constrains any box that sets only one inset (an inset-block-end: anchor(...) alone rendered at the wrong place). The top-layer reset (section 1) clears inset/width/height/margins inside :where(), so it has zero specificity and never beats an anchor()-driven longhand or an author class. above, before, align-bottom and align-trailing are therefore plain logical inset-block-end/inset-inline-end on every target type (an earlier translate workaround, --⚓️-tx/--⚓️-ty, was removed). Consequences: RTL and vertical writing modes work, native flip-block/flip-inline work on popovers in both directions (custom position-area flip blocks removed), and data-⚓️-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 from auto, so that swap snaps instead of tweening; same-attribute changes and size still morph.
    • anchor-valid (data-⚓️-visibility="valid") needs Chrome 157+. The CSSWG renamed anchors-visible/anchors-valid to singular anchor-visible/anchor-valid (blink-dev intents, Chrome 157, plurals kept as deprecated aliases). The old plural anchors-valid was never shippable (reverted over spec ambiguity), so in Chromium 153 CSS.supports("position-visibility", "anchors-valid") and the singular forms all return false, while plural anchors-visible, no-overflow and always work. The library declares plural then singular for every value (an engine drops the declaration it doesn't parse), so visible works everywhere and gains the singular spelling for free; any value including valid (valid, valid-no-overflow, all) silently falls back to the initial value until the engine supports anchor-valid. tests/10-visibility-state.html probes support with CSS.supports and only treats valid values as a known gap when neither spelling parses.
    • data-⚓️-try measures overflow against the target's containing block and the scrollable area. Confirmed while testing: an extra position: absolute wrapper 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-fallbacks does not re-evaluate on scroll — confirmed live, current Chromium (153.0.8010.12). An anchor-positioned element (plain or [popover], root-document scroll or an overflow: auto scroll container, data-⚓️-try on any axis) picks its fallback exactly once — apparently checking overflow against the anchor's position as of scrollY: 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 fresh showPopover() 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 via window.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 its data-⚓️-try fallback (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, scrollY baked 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-⚓️-fill handles it, stretch-x + stretch-y don'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 the inset: anchor(inside) shorthand with no explicit size, visibly collapses to ~0px wide partway through a transition: inset when its anchor moves, even though old and new size are identical. data-⚓️-fill therefore sets both the single inset: anchor(inside) shorthand and an explicit width/height from anchor-size(), which makes the size authoritative so only the position animates (verified sampling every frame: no collapse, in real Chrome). Chrome still reports four separate inset-* transitions for the shorthand, so why the shorthand plus explicit size works isn't established. Author width/height override the built-in ones.

    Copied