Skip to content

feat(useLayer): size a layer to the space its anchor leaves it - #5230

Draft
cixzhang wants to merge 2 commits into
mainfrom
layer-available-space
Draft

feat(useLayer): size a layer to the space its anchor leaves it#5230
cixzhang wants to merge 2 commits into
mainfrom
layer-available-space

Conversation

@cixzhang

@cixzhang cixzhang commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

The gap

A layer that does not fit can only move. getPositionTryFallbacks emits
flip-block / flip-inline plus span-based slides, so the browser will put
the layer on the other side of its anchor or slide it along the alignment
axis — but nothing ever sizes it. A layer carrying a long list in a short
viewport is taller than every fallback, so none of them fit, the browser keeps
the original placement, and the layer runs off the screen at its full height.

The number itself was already reachable, and the PR is honest about that:
style merges last, so a caller who knows to write max-block-size: 100%
gets the same clamp today. What nobody gets right by hand is the rest of it —
see the two details below, both of which silently break the naive version.

Layer-bearing components show the shape of the gap by guessing instead:
DropdownMenu, DropdownMenuSubMenu, ContextMenu and Selector all cap at
a hardcoded 300px, ComplexSelector at min(480px, calc(100vh - 32px)). A
fixed cap is wrong in both directions — it amputates the list on a tall window
and still overflows on a short one — and none of them cap the inline axis at
all.

The change

clampToAvailableSpace?: 'block' | 'inline' | 'both' on the context render
props.

{layer.render(
  <div style={{maxBlockSize: '100%', overflow: 'auto'}}>{items}</div>,
  {placement: 'below', offset: 8, clampToAvailableSpace: 'block'},
)}

No measurement. position-area already makes the anchor's cell the
layer's containing block, so 100% along an axis is the distance from the
anchor to the viewport edge. The clamp is three CSS declarations; there is no
resize observer, no scroll listener, no extra render pass, and the browser
re-resolves it wherever a fallback puts the layer.

Two details separate this from a clamp that only half works. Both were found
by measuring in Chromium, not by reading the spec:

  • The fallbacks have to be ordered by size. A clamped layer always fits, so
    "first option that fits" degenerates to "never flip" and the layer keeps a
    cramped side: measured 172px taken below while 300px sat free above.
    position-try-order: most-block-size (or most-inline-size) on the
    placement axis restores the choice, and only the placement axis is flipped,
    so it is set only when that axis is clamped.
  • The layer has to become a flex column when the block axis is clamped, and
    that column has to be scoped to :popover-open. The clamp bounds the layer
    box, but a percentage size on the content inside only resolves against a
    definite parent — without the column the content keeps its full height and
    paints straight out of the box. Set inline, though, display beats the UA's
    [popover]:not(:popover-open) {display: none} and a closed layer paints
    its whole list on the page; it lives in a StyleX rule for that reason.

offset puts a margin on both edges of the placement axis, outside the clamped
box, so it comes out of that axis's budget; otherwise the layer clears the
viewport edge by exactly the offset.

Off by default — nothing changes for any existing caller — and ignored under
positioning: 'custom', which has no cell to measure against and would size to
the whole viewport.

Verification

Real Chromium against the two new stories, measuring the layer's box against
the viewport. 40 rows, 1280px of content, offset: 8 so the budget is the room
minus 16px.

Viewport sweep, trigger fixed, room below is the short side:

viewport unclamped clamped room on chosen side
320 1280px, 968px off-screen 160px, 0 off-screen 176 above
420 1280px, 868px off-screen 196px, 0 off-screen 212 below
600 1280px, 688px off-screen 376px, 0 off-screen 392 below
900 1280px, 388px off-screen 676px, 0 off-screen 692 below

Both flip directions, and the clamp lands on the budget exactly:

room above / below side chosen layer height budget
176 / 112 above 160px 176 − 16
176 / 292 below 276px 292 − 16
176 / 692 below 676px 692 − 16

Content shorter than the space is left alone: 4 rows measures 128px in both
stories, identical, no scrollbar. Content longer scrolls inside the clamp
(scrollHeight 1280 in a 676px box).

Scroll positions, reopened at each offset — exact every time:

scrollY room below layer height
0 292 276px
60 352 336px
120 412 396px
180 472 456px

One honest limitation, and it is the one anchor positioning already has: a
layer left open while the page scrolls keeps the size it opened with, the
same way it keeps the side it opened on. I confirmed the existing behaviour
first — an unclamped layer does not re-run its fallbacks on scroll either
(measured: still above and 144px off the top after scrolling, with 322px free
below). The clamp is resolved at layout time, exactly like the placement it
sits next to; it is not a new class of staleness.

Reproducing it

Two stories, Core/LayerAvailable Space Unclamped and Available Space
Clamped
, same content and same trigger. Shrink the Storybook viewport to
~320px: unclamped, the layer covers the trigger and runs off both edges;
clamped, it flips above the trigger and ends inside the viewport. The row
count is adjustable so you can walk the list past the available space and
back.

Tests

Unit tests for the emitted styles, covering each axis, the try-order rule on
the placement axis only, the offset arithmetic, the two cases that emit
nothing (unset, and positioning: 'custom'), and that no inline display is
emitted. 52 tests in useLayer.test.tsx; 558 across every layer-bearing
component still pass. Closed → open → closed → open measured in Chromium:
display is none/flex/none/flex and the height 0/276/0/276.

Deliberately not in this PR

Moving DropdownMenu, ContextMenu, Selector or ComplexSelector off their
hardcoded caps. Each is a visible behaviour change to a shipped component and
deserves its own PR and its own screenshots; this one only opens the seam.

Also out of scope: clamping to a boundary other than the viewport. A layer
anchored inside a scrollable column arguably wants to clamp to that column, and
no amount of CSS gets there — the layer is in the top layer, so its containing
block is the viewport. That needs a different mechanism.

@vercel

vercel Bot commented Aug 19, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
astryx Ready Ready Preview Aug 20, 2026 9:25pm

Request Review

@github-actions

github-actions Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

PR Analysis Report

📚 Storybook Preview

View Storybook for this PR
GitHub Pages may take up to a minute to hydrate after deploy.

🧪 Sandbox Preview

View Sandbox for this PR
GitHub Pages may take up to a minute to hydrate after deploy.

Modified Components

Layer (@astryxdesign/core) · View in Storybook
Metric Before After Delta
Bundle Size (ESM) N/A N/A N/A
Lines of Code N/A 828 -
Complexity N/A Very High (102) -

Bundle Size Summary

Package Size (ESM) Size (CJS) Gzipped
@astryxdesign/core N/A 4.8KB 1.2KB

Accessibility Audit

Status: No accessibility violations detected.


Generated by PR Enrichment workflow | Storybook | Sandbox | View full report

github-actions Bot added a commit that referenced this pull request Aug 19, 2026
A layer that does not fit can only move: position-try-fallbacks flips it to
the opposite side or slides it along the alignment axis, and a layer taller
than every option overflows the viewport outright. Layer-bearing components
work around this with a fixed cap, which is wrong in both directions and
never clamps width.

clampToAvailableSpace: 'block' | 'inline' | 'both' holds the layer inside the
space that is there. position-area makes the anchor cell the layer's
containing block, so 100% along an axis is that distance in pure CSS, with no
measurement and no observers.

The same declarations were always reachable through style; what they get
wrong is the rest. Fallbacks are ordered by size on the placement axis, or a
layer that always fits stops flipping and keeps a cramped side. The flex
column that lets content size against the clamp is scoped to :popover-open,
because an inline display beats the closed-popover rule and paints a shut
layer.
…settings

Prose literals are written the way the rest of the doc files write them —
double-quoted inside the single-quoted description — so the union members in
`render`'s description do not close the string they sit in.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Meta Open Source bot. needs:design-review Affects visuals — Design should review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant