Skip to content

fix(webkit): place overlay panels with translate so re-anchors can transition - #932

Open
gabriel-lisboa-azion wants to merge 2 commits into
mainfrom
fix/use-placement-translate-anchoring
Open

fix(webkit): place overlay panels with translate so re-anchors can transition#932
gabriel-lisboa-azion wants to merge 2 commits into
mainfrom
fix/use-placement-translate-anchoring

Conversation

@gabriel-lisboa-azion

Copy link
Copy Markdown
Collaborator

Three fixes to usePlacement, all about a panel that moves after it is already up.

1. Placed with translate, not top/left

Two reasons:

  • A translate is a compositor property, so re-anchoring an open panel can be transitioned into place instead of snapping a layout property.
  • translate is a property of its own, so it composes with the transform: scale(...) the open/close keyframes animate. A transform: translate3d(...) here would overwrite them.

Note the transition names translate, not transform — in Tailwind v4 the translate utilities compile to the standalone translate property, so a transition-[transform] would animate nothing, silently (.claude/rules/styling.md).

2. anchored gates that transition

It turns true one frame after the opening placements land, so the entrance stays the panel's own scale animation and only later re-anchors glide. Without the gate a panel would visibly travel in from the off-screen seed.

It resets on close, but the panel is deliberately not moved off-screen there — it is still in the DOM playing its leave animation, and moving it would replace that animation with a disappearance.

3. Measured with offsetWidth, not getBoundingClientRect

getBoundingClientRect reports the transformed box, and the panel opens under scale(0.9) → scale(1). Read mid-animation it was up to 10% narrow, which placed a centred tooltip 4.1px off its trigger — and left it there, because nothing re-measured afterwards.

offsetWidth/offsetHeight are the layout box, so placement no longer depends on which frame of the entrance it is read on.

4. A ResizeObserver re-anchors an open panel whose content resizes

A centred panel's position is computed from its measured width, so a panel that resizes after placement keeps coordinates for the width it used to have. Nothing else catches this: the window has not resized and the page has not scrolled.

Measured on the copy control, whose tooltip swaps Copy code for Copied while the pointer is still on it — the panel lost 21px of width, slid 4px, and ended 10.4px off centre in a single frame. Reads as a glitch, and the same applies to any overlay whose content changes while it is up.

Safe from feedback: a transform does not affect the observed box, and updatePosition only writes position.


⚠️ #891 is preserved — this is a merge, not a cherry-pick

#891 (stop re-anchoring the popover panel when its own content scrolls) landed on main after this work started and touches the same two files. Naively replaying this branch would have reverted it.

Verified with a real 3-way merge (git merge-file against the merge base):

  • use-placement/index.js — the only conflict is the comment. The guard code is character-identical on both sides:

    const target = /** @type {Node | null} */ (event?.target ?? null)
    if (target && panelRef.value?.contains(target)) return

    The comment kept here is a superset: it retains fix(webkit): stop re-anchoring the popover panel when its own content scrolls #891's capture-phase rationale and adds why repositioning on an inner scroll also cost two bounding rects and an inline-style write per scrolled frame.

  • popover.test.ts — also comment-only. Test-name sets on both sides are identical (comm diff empty in both directions), so fix(webkit): stop re-anchoring the popover panel when its own content scrolls #891's does not re-measure the trigger when the panel scrolls, but does when the page does regression test survives intact.

Also

  • tooltip.vue consumes anchored via data-anchored, with motion-reduce:transition-none.
  • .specs/tooltip.md records the re-anchor motion row and the measurements.

…ansition

Three fixes to `usePlacement`, all of them about a panel that moves after it is
already up.

THE PANEL IS PLACED WITH `translate`, NOT `top`/`left`. A translate is a
compositor property, so re-anchoring an OPEN panel can be transitioned into
place instead of snapping a layout property. And `translate` is a property of
its own, so it composes with the `transform: scale(...)` the open/close
keyframes animate rather than overwriting it — which a `transform:
translate3d(...)` here would.

`anchored` GATES THAT TRANSITION. It turns true one frame AFTER the opening
placements land, so the entrance stays the panel's own scale animation and only
later re-anchors glide. Without it a panel would travel in from the off-screen
seed. It resets on close but the panel is NOT moved off-screen there: it is
still in the DOM playing its leave animation.

THE PANEL IS MEASURED WITH `offsetWidth`/`offsetHeight`, NOT
`getBoundingClientRect`. The rect reports the TRANSFORMED box and the panel
opens under `scale(0.9) → scale(1)`: read mid-animation it was up to 10% narrow,
which placed a centred tooltip 4.1px off its trigger and left it there. The
layout box does not depend on which frame of the entrance it is read on.

A ResizeObserver RE-ANCHORS AN OPEN PANEL WHOSE CONTENT CHANGES SIZE. A centred
panel's position is computed from its measured width, so a panel that resizes
after placement keeps coordinates for the width it used to have — and nothing
else catches it, since the window has not resized and the page has not scrolled.
Measured on the copy control, whose tooltip swaps "Copy code" for "Copied" while
the pointer is still on it: the panel lost 21px of width, slid 4px, and ended
10.4px off centre in one frame. A transform does not affect the observed box and
placement only writes position, so this cannot feed back into itself.

Preserves #891 (`fix(webkit): stop re-anchoring the popover panel when its own
content scrolls`), which landed on main after this work started. The inner-scroll
guard and its regression test are byte-identical in behaviour; only the comments
differ, and the ones kept here are supersets that add why repositioning on an
inner scroll also cost two bounding rects and an inline-style write per frame.
gabriel-lisboa-azion added a commit that referenced this pull request Aug 26, 2026
Brings in the 17 commits main has since 2026-08-17 — the webkit
documentation category (#919), the shared leading column for Select /
MultiSelect / Dropdown (#935), resizable-panel (#902), the copy-button
tooltip and card-box header inset (#936), --container-site (#923), the
page-enter / content-enter arrivals (#896), the colored-icon currentColor
fix (#930) and the skills that landed with them.

Conflict resolution, all fifteen:

Demo wins where it already carries work that is still an OPEN PR on main
and main's side is the older shape that PR rewrote — toast-item (#900's
`h-[1lh]` line box over #892's `self-center`), use-placement and
popover.test (#932's translate anchoring over #891's prose), and the
theme animation tokens, where the entry-count canary confirms every
main entry survived alongside demo's.

Main wins where its side is a superset or the corrected form — DESIGN.md
(gains `text-body-prose-md` and `.layout-column-docs`), package.json
(keeping demo's footer block would have duplicated the four keys main
already merged in above it), and resizable-panel.test, whose named
imports are the form that keeps CodeQL from reading a compiler-generated
`.vue` default export as undefined.

Both sides where each added something: .size-limit.json keeps demo's
ratcheted 1.1 KB footer-root budget and takes main's fourteen doc-*
entries; the ui-craft skill and the CLI plan keep demo's microcopy
routing without duplicating main's entry.

Generated artifacts were rebuilt rather than hand-resolved — theme dist
via build:tokens, catalog.json via catalog:build — and both carry each
side's tokens and all 258 exports.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants