BaseAnchor
Floating UI + GSAP anchored popover with configurable animation modes.
Usage
BaseAnchor
Expand Motion
The default animation.type='expand' grows the panel from the corner nearest its reference and folds it back on close. Its bounce is sized to the panel: up to three rows (about 130px) the box stretches at most 6px past its content, a taller panel gets a little more with the square root of its height (about 7.6px at five rows, 10.4px at ten), and every panel still reaches full height on the same beat. show-arrow adds a triangle that follows the panel's motion and final placement. Choose transfer in the animation modes below for directional reveal.
Soft Edge
Placement
Floating UI resolves the side and alignment. The default expand motion grows from the corner nearest the reference; if flip changes the side, the panel motion and arrow follow the resolved placement.
Placement Directions
Animation Modes
One animation object configures every mode: expand (spring growth, default), transfer (directional reveal), boom (focus scale), opacity (fade), none (instant); the liquid drip / bead modes have their own sections below.
Multiple Animations
Drip
animation.type='drip' opens the menu like a drop of liquid falling out of its own trigger. The trigger body and the panel live inside one SVG goo filter (feGaussianBlur plus a hard alpha threshold via feColorMatrix), so they start out completely merged; as the panel's top edge falls, the neck between them thins, pinches, and finally snaps.
The neck is not drawn geometry — it is what is left when the Gaussian field drops below the threshold, which is exactly why both shapes have to share one filter. The grey outline is likewise never drawn on either element: it is derived from the merged silhouette by eroding the thresholded shape 1px and flooding the difference, so one continuous ring wraps the trigger, stretches down the neck, and closes around the panel. The shadow rides a twin outside the filter, because a box-shadow fed through the goo would threshold into a hard black slab.
The panel is described by its two falling edges, not as a box that grows: the top edge peels from the trigger's mid-line to its final position and stops, while the height keeps filling through the detach and past it. At the moment the neck snaps the panel is already about 83% of its body and still filling.
Liquid drop
Tag menu items with data-liquid-item to opt into per-item reveal: each item's opacity is keyed to the panel's current height, so an item can never appear before the panel has grown to hold it. Without the attribute the whole panel body reveals as a single unit — still keyed to growth rather than to the clock.
Bead
animation.type='bead' shares one engine, one geometry, and one timing table with drip. The only difference is width.
drip keeps the sheet at a constant width. bead draws its sides in by how fast it is moving, relaxing back to full width as that motion decays to nothing. The shape reports the drop's own speed rather than its progress.
The speed is not measured on p. p advances linearly, so its derivative is a constant and carries no speed at all — reading it would pin the pinch open forever. The drop has two motions and reports whichever is currently faster: the peel runs ease-out-quad and decelerates to a dead stop at the detach, while the fill runs ease-out-cubic and is still growing at the very end. Reading the peel alone cut the pinch off at 45% of the timeline — the silhouette sprang back to full width while the panel was still visibly filling. A sheet is under tension for as long as either end of it is still moving.
Both slopes are closed forms of t rather than differences between frames. Differencing has no sample before t = 0 and falls back to zero, so the seed frame reported a standing start the drop never has and the silhouette teleported from full width to full pinch. A closed form has a value at t = 0 like it has one anywhere else, so the pinch never pops and does not vary with the refresh rate — 60Hz and 120Hz derive the same pinch for the same t.
The pinch is applied symmetrically about the sheet's own centre line, so the bead necks instead of sliding sideways. Because it can draw in past the panel's own padding, the rows are clipped to the sheet with clip-path and are revealed as the neck relaxes — rather than faded, which can only make an overhang faint instead of impossible.
Bead
beadPinch sets the peak draw-in per side (px, default 60 — a 200px panel necks to 80px) and beadVelocityRef sets what counts as "fastest" (default 4). The pinch can never close the sheet to zero width — a zero-width rect drops out of the Gaussian field entirely and the neck would snap early.
Custom Animation
Type, duration, and easing all go in the animation object; the component has no top-level duration / ease props.
Custom Ease
Interactive Playground
Tune key props in one panel and focus on surface differences (pure / mask / blur / glass / refraction) plus motion adaptation modes (auto / manual / off).
Surface Playground
Best Practices
- Reach for
TxPopover,TxDropdownMenu, orTxContextMenufirst. UseTxBaseAnchorwhen you are building a new anchored primitive or need virtual-reference positioning. - Keep floating panels lightweight. Move multi-step forms, destructive confirmations, or full-screen flows to Drawer/Dialog components.
- For coordinate-anchored menus, pass
virtualReferenceand callupdatePosition()after pointer or canvas transforms change. eagerandkeepAliveContentretain measurable content, not an active anchor position. Measure their size while closed; use the reference or an open panel for placement coordinates.
API Reference
TxBaseAnchor Props
| Prop | Type | Default | Description |
|---|---|---|---|
modelValue | boolean | false | Whether popover is open (v-model). |
disabled | boolean | false | Disable the popover. |
eager | boolean | false | Mount the floating panel before first open; useful when content must measure itself up front. |
placement | BaseAnchorPlacement | 'bottom-start' | Floating placement. |
offset | number | 8 | Distance from reference element (px). |
width | number | 0 | Panel width (0 = content-driven width). |
minWidth | number | 0 | Minimum width. |
maxWidth | number | 360 | Maximum width. |
maxHeight | number | 420 | Maximum panel height before viewport clamping. |
unlimitedHeight | boolean | false | Disable panel height limiting; also active when maxHeight <= 0. |
matchReferenceWidth | boolean | false | Follow reference width when width is 0. |
referenceClass | BaseAnchorClassValue | undefined | Extra class value applied to the reference wrapper, not the floating panel. |
virtualReference | BaseAnchorVirtualReference | undefined | Use a virtual reference, such as a mouse coordinate, for placement. Useful for ContextMenu, canvas nodes, and cursor menus. |
disableFlip | boolean | false | Drop the flip middleware, so the panel keeps the side placement asks for instead of jumping to the opposite one near a viewport edge. shift still slides it back into view. Intended for a virtualReference the host re-measures itself — a selection bar or caret bar that changes sides mid-edit reads as a different control. |
animation | BaseAnchorAnimationOptions | {} | Unified animation config; omitted fields use their type defaults, with expand as the default type. |
useCard | boolean | true | Wrap floating content with built-in TxCard. |
panelVariant | 'solid' | 'dashed' | 'plain' | 'plain' | Panel border variant forwarded to TxCard when useCard=true. |
panelBackground | 'pure' | 'mask' | 'blur' | 'glass' | 'refraction' | 'refraction' | Panel background (TxCard background). |
panelShadow | 'none' | 'soft' | 'medium' | 'soft' | Panel shadow (TxCard shadow). |
panelRadius | number | 18 | Panel border radius (TxCard radius). |
panelPadding | number | 10 | Panel padding (TxCard padding). |
panelCard | Partial<TxCardProps> | undefined | Pass-through advanced TxCard props (maskOpacity, fallbackMaskOpacity, surfaceMoving, refraction*). |
surfaceMotionAdaptation | 'auto' | 'manual' | 'off' | 'auto' | Surface downgrade strategy: auto follows Anchor motion, manual reads panelCard.surfaceMoving, off disables downgrade adaptation. |
showArrow | boolean | false | Enable the triangle arrow that follows floating placement. |
arrowSize | number | 10 | Arrow size in px. |
keepAliveContent | boolean | false | Keep floating content mounted after close so inner state is preserved. |
closeOnClickOutside | boolean | true | Close on outside click. |
closeOnEsc | boolean | true | Close on Escape key. |
toggleOnReferenceClick | boolean | true | Toggle open state on reference click. |
hoverBridge | boolean | false | While open, lay an invisible hit area (the hover bridge) between the reference and the panel, so a pointer crossing the offset gap never leaves the floating layer. Rarely set by hand: TxTooltip turns it on for trigger="hover" with interactive. |
BaseAnchorAnimationOptions
| Prop | Type | Default | Description |
|---|---|---|---|
type | 'expand' | 'transfer' | 'boom' | 'opacity' | 'none' | 'drip' | 'bead' | 'expand' | Animation type. drip and bead share one engine and bring their own timing table. |
closeType | Same values as type | Same as type | Type used while closing. Omitting it keeps the run symmetric. drip / bead share one measured stage across both directions — including usesBeadMotion, which the template reads — so a liquid run must use the same type at both ends; a mismatched pair falls back to symmetric and warns in dev. |
duration | number | per type (expand 400 / classic 432 / liquid 260) | Enter duration in ms. |
closeDuration | number | per close type (expand 240 / classic: open duration × 0.45 / liquid 150) | Leave duration in ms. |
ease | string | per type (expand: a spring solved for the panel's height, spring(10, 0.6) up to about 63px / classic back.out(2) / liquid linear) | Enter ease: GSAP string, cubic-bezier(...), or spring(omega, zeta); the liquid types take only linear or cubic-bezier(...) (see below). An ease you pass runs as written, with no height adjustment. |
closeEase | string | per close type (expand power2.in / classic power3.in / liquid cubic-bezier(0.25, 0.46, 0.45, 0.94)) | Leave ease; same forms as ease. |
distance | number | per type (expand 12 / transfer 30) | expand drift and transfer travel in px; the leave uses it too by default (see exit). |
scale | number | per type (expand 0.88 / boom 0.94 / transfer 0.92) | Enter initial scale; the leave returns to it by default (see exit). |
blur | number | 12 | Boom enter initial / leave target blur radius in px. |
opacity | number | 0 | expand / boom / opacity enter initial and leave target opacity. |
exit | { scale?, distance?, blur?, opacity? } | See description | Geometry applied to the leave phase only. Each field falls back to the shared field of the same name when the caller set one, and to closeType's own table otherwise. It is needed because the types start from different scales and origins (expand at 0.88 around the anchored corner, boom at 0.94 around its centre), so when the open and the close are different types, a shared value written for the open would drive the close as well. |
gooBlur | number | 4.5 | drip / bead only. Goo feGaussianBlur stdDeviation. With gooThreshold this decides how wide a gap the neck survives. |
gooThreshold | number | 20 | drip / bead only. Alpha slope of the threshold colour matrix. |
gooThresholdOffset | number | -9 | drip / bead only. Alpha offset of the threshold colour matrix. |
outlineColor | string | --tx-border-color | drip / bead only. Colour flooded into the ring derived from the merged silhouette. Resolved from the token so dark mode follows. |
triggerRadius | number | measured | drip / bead only. Corner radius of the trigger ghost; read from the reference when omitted. |
seedHeight | number | 12 | drip / bead only. Panel height at p=0 — the seed the drop is torn from. |
itemSelector | string | '[data-liquid-item]' | drip / bead only. Items faded in against the panel's own growth. |
beadPinch | number | 60 | bead only. Peak draw-in per side (px). Reports the drop's velocity, so it decays to 0 as the motion settles. Where it draws in past the panel padding the rows are clipped to the sheet. |
beadVelocityRef | number | 4 | bead only. Speed at which the pinch saturates — the larger of the peel and fill slopes, per unit of normalised time. |
When type is drip or bead the timing defaults change: duration is 260, closeDuration is 150 (markedly faster, and on its own curve rather than the open reversed), ease is linear and closeEase is cubic-bezier(0.25, 0.46, 0.45, 0.94).
ease defaults to linear deliberately. p is raw progress; all the shaping lives in the two per-edge easings — ease-out-quad on the peel, ease-out-cubic on the fill. Stacking a front-loaded master curve on top of those compounds into roughly eighth-order ease-out: at 60Hz the entire tear collapses into two frames and the rest of the duration is an invisible few-pixel crawl. Any cubic-bezier(...) is still accepted; GSAP ease strings and spring formulations are rejected and fall back to the default.
Events
| Event | Params | Description |
|---|---|---|
open | - | Fired when popover opens. |
close | - | Fired when popover closes. |
update:modelValue | boolean | v-model update. |
floating-enter | MouseEvent | The pointer entered the floating layer: the panel box, or the hover bridge when hoverBridge is on. |
floating-leave | MouseEvent | The pointer left the floating layer altogether. |
Slots
| Slot | Description |
|---|---|
reference | Trigger element. |
default | Popover content; receives { side } from final Floating UI placement. |
Exposed Methods
| Method | Params | Description |
|---|---|---|
close | - | Programmatically close. |
toggle | - | Programmatically toggle. |
updatePosition | - | Manually refresh Floating UI placement. |
getPanelRect | - | The panel's rect as currently drawn (DOMRect), or null before it mounts. Read from the clip, not the floating root: a side=top panel grows with a translate that pins its reference-facing edge, and only the clip's rect reports that edge where it is seen. |
containsFloating | (target: Node) | Whether target sits in the floating layer: the panel, the hover bridge, or anything inside them. |
getSide | - | The side Floating UI finally placed the panel on: top / right / bottom / left. |
Overview
modelValuemay be controlled or uncontrolled. Reference clicks emitupdate:modelValue, plusopenorclosewhen the state changes.disabledblocks opening and closes an already open uncontrolled anchor.closeOnClickOutsideandcloseOnEscindependently control outside pointer and Escape closing.toggleOnReferenceClick=falsekeeps reference clicks from changing open state; use it for editable references that own their click behavior.floating-enter/floating-leaveare bounded by the whole floating layer: the panel box, card padding included, plus the hover bridge — not just the slot content. The bridge exists only while open withhoverBridgeon. It is the hull of the reference's edge facing the panel and the panel's edge facing the reference (a trapezoid), so it never reaches over a control sitting beside the reference. Its geometry comes from the last middleware in the Floating UI chain, in the same pass and frame as the panel, and followsflip/shift; it is removed the moment a close begins.virtualReferenceoverrides the DOM reference used by Floating UI, while the reference slot still renders to preserve structure and slot contracts. Use it for coordinate-anchored components.class,style, and non-class attrs are applied to the floating panel. UsereferenceClassfor the reference wrapper.maxHeightis clamped by available viewport height. UseunlimitedHeightonly for panels with their own scroll container.- Content past
maxHeightscrolls in the card's body, not on.tx-base-anchor__carditself, which only clips. The panel background is painted by an absolutely positioned surface layer inside the card, and scrolling the card would carry that layer away with the content. Read the scroll position off the card's body rather than the card. surfaceMotionAdaptationis an active hard-cut strategy:autouses anchor motion state,manualforwardspanelCard.surfaceMoving, andoffforcessurfaceMoving=false.- Motion is configured only through the
animationobject; the component has no top-levelduration/easeprops, and any field left out takes the type's own default. - An
expandwith noeasepicks its spring by panel height. Up to about 63px it isspring(10, 0.6)(~10% overshoot); a taller panel gets more damping, holding the box's overshoot to a budget, and more stiffness, so it first reaches full height at the same moment.expandBounceBudgetsets the budget: 6px up to 130px, then growing with the square root of the height, about 7.6px at 206px and 10.4px at 394px. Only vertical card panels stretch a real box; side placements use the window reveal and are unaffected. - The whole anchor family draws no arrow by default: BaseAnchor, Tooltip, and Popover all default
showArrowtofalse, and DropdownMenu, ContextMenu, Select and the rest built on them show none either; opt in per instance. - The
showArrowarrow is part of the panel: it sits on the panel's content layer, so whatever drift, scale, blur, or opacity that layer carries, the arrow carries too, and it stays hidden until the panel starts moving and after it has closed. Each animation type adds only its own beat:expandpokes it out of the edge once the panel has formed, overshooting a beat after the panel, and on close tucks it back in before the panel folds;transfertucks it into the panel and pops it as the slide lands;boompops it late and fast;opacityadds nothing and fades it with the panel. eagermounts hidden, measurable content before first open;keepAliveContentpreserves content after close. Settled closed roots are parked outside the viewport with their own overflow clipped, so retained panels do not widen the document after a resize. Explicit/reference widths and intrinsic content remain measurable withoutdisplay: noneor page-level clipping.- Open and leaving panels use absolute document positioning with a root translation, so page scrolling carries them with the reference on the compositor. Parking starts only after leave visuals finish; reopening restores document geometry before the first positioning/size pass and invalidates the old close completion.
drip/beadis defined on the vertical axis only. Aleft*/right*placement — including one produced byflip— degrades to the opacity path, keeping the same timings.drip/beadpaints its own opaque surface through the goo filter, soTxCardis not rendered andpanelBackground/panelShadow/panelVariantdo not apply. The four non-purebackgrounds all rely onbackdrop-filter, which does not survive inside an SVG filter and would threshold into hard edges.drip/beadsuppressesshowArrow: an arrow contradicts the neck. It also replaces the rounded-rect outline with the ring derived from the merged silhouette, so there is never a double border.drip/beadpunches the trigger's interior out of the goo fill so the trigger's own fill and text always show through, whatever the page's stacking contexts are. The outlineuseis left unmasked, so the derived ring still wraps the trigger. Because of this the trigger must supply its own opaque background — a transparent trigger will show the page through the punched hole.drip/beadneeds a measurable panel height;unlimitedHeight(ormaxHeight <= 0) falls back to the instant show/hide path.- Under
prefers-reduced-motion: reduce, every animating type snaps to its end state instead of running: the GSAP-drivenexpand/transfer/boom/opacitypaths finish immediately, anddrip/beadsnaps through its prepare step (noneis already instant).
Technologies
- Reviewed against
packages/tuffex/packages/components/src/base-anchor/src/TxBaseAnchor.vue,base-anchor-motion.ts,types.ts, andbase-anchor.test.ts. - Existing tests cover uncontrolled toggling, controlled outside/Escape close paths, disabled blocking, close/toggle switches, floating attrs and reference classes,
animationobject variants, and surface-motion adaptation strategies. - Rejected design: letting
.tx-base-anchor__cardscroll itself withoverflow: auto. That was the original wiring, but the card's surface layer is an absolutely positioned child of it, so its containing block scrolls with the card's content — every pixel the panel was scrolled left that much of its bottom painted on the page behind it. Panels with their own list scroller (select, search-select) never hit it; a dropdown-menu handing its whole body to the card did. The card now clips and the body scrolls, so the surface stays put. - Rejected design: the arrow as a direct child of the floating root, outside the clip. The clip's
visibilitynever reached it, so it showed at full size for two or three frames before the panel started moving; a:not(.is-open)rule hid it on the first frame of a close, so its exit never showed; and underexpandit held its final spot while the panel drifted 12px, its base sinking 15px into the panel and then lifting 1.3px off the edge at the overshoot. It is now a child of.tx-base-anchor__contentand stays on the panel edge throughout. floating-ui's offsets land where they did:transfer's bounce padding sits on the far edge, while the arrow is placed against the near edge and along the cross axis, which that padding never shifts. - Rejected design: one
spring(10, 0.6)for every panel. Overshoot scales with height: the three-row theme menu (130.8px) overshot by 12.8px in a real browser and took 348ms to settle within ±0.5px, which reads as rubber rather than a landing. Compressing only the part past 100% was tried as well; the velocity drops abruptly on the frame that crosses full height — the same kind of corner that ruled outback.out.expandSpringFornow solves damping and stiffness from the height: the same menu overshoots 6.2px, settles by 248ms, and still first reaches full height at 112ms. - Rejected design: a flat 6px cap at every height. It was right on a three-row menu and read as stiff, held down, from five rows (206px) up in hand testing (2026-10-08 feedback). Growing in proportion is the rubber again, so the budget takes the middle: past 130px it grows with the square root of the height.
- Rejected design: drawing the hover bridge as a strip as wide as the panel, or as a pseudo-element of the clip. A strip covers the control beside the reference (in the Nexus header the language toggle sits right next to the theme toggle); the clip is
overflow: hidden, so a pseudo-element reaching outside the box is clipped, and its hit area with it. The bridge is a child of the floating root cut to a trapezoid withclip-path, which clips hit testing too. - Retained-portal geometry is owned by the close lifecycle: the run-token-guarded motion completion parks the outer root, including arrow and liquid-stage descendants.
.is-parkedkeeps intrinsic width atmax-contentbecauseright: 100%leaves no shrink-to-fit space; inline explicit/reference widths and middleware max-width still apply. Hiding only the inner clip leaves the old root translation and width in document overflow; clipping the page or making open panels fixed would conceal the defect or break compositor scroll following. --tx-ba-max-heightis written only by thesizemiddleware:min(availableHeight, maxHeight)in pixels, ornonewhen unlimited. The root:styledoes not declare it — it used to bindisUnlimitedHeight ? 'none' : undefined, and Vue's style patcher turns anundefinedcustom property intosetProperty(name, ''), so every re-render after a positioning pass deleted the value the middleware had just written and the panel always fell back to the 420px CSS default, overflowing past its trigger.- Accessibility note:
TxBaseAnchoris positioning infrastructure only. The built primitive must provide menu/dialog/listbox roles, focus handling, and keyboard traversal appropriate to its content. drip/beadcoverage lives inbase-anchor-liquid.test.ts(cubic-bezier solver, spring rejection, spec geometry at p=0 / 0.45 / 1, height-edge independence, item reveal) and inbase-anchor.test.ts(single merged goo filter, erode-derived outline ring, shadow twin outside the filter, arrow/outline/card suppression, horizontal degradation, no gsap path).- The GSAP-driven types (
expand,transfer,boom,opacity) resolve both their open and close eases throughresolveGsapEaseinpackages/tuffex/packages/utils/animation/easing.ts:spring(...)andcubic-bezier(...)become progress functions handed to GSAP, and GSAP's own ease names pass through untouched. Onlyexpandused to resolve them; the classic types handed the raw string to GSAP, which does not know either form and silently fell back to its default ease. drip/beadis driven by a rAF loop rather than GSAP: the motion is specified as two CSS cubic-beziers, and every frame derives SVG geometry, the shadow twin, and per-item opacity from one progress scalar.- Browser support:
liquidusesfilter: url(#…), which Safari and Firefox both support. Onlybackdrop-filter: url(#…)is restricted, andliquiddoes not use it — no fallback ladder is needed. - Component source:
packages/tuffex/packages/components/src/base-anchor/src/TxBaseAnchor.vue. - Type contracts:
packages/tuffex/packages/components/src/base-anchor/src/types.tsexportsBaseAnchorProps,BaseAnchorAnimationOptions, and virtual-reference types. - Verified coverage:
packages/tuffex/packages/components/src/base-anchor/__tests__/base-anchor.test.tsverifies uncontrolled toggling, controlled outside/Escape closes, disabled behavior, close/toggle switches, floating attrs/reference classes,animationobject variants, and surface-motion adaptation.base-anchor-max-height.test.tsverifies--tx-ba-max-heightownership: after the open settles and after a re-render the value is still the pixel value the middleware wrote (noneforunlimitedHeight), and no empty write ever clears it.base-anchor-hover-bridge.test.tscovers the hover bridge: the middleware is added only withhoverBridgeand runs last, a closed gap writesbox: null, the bridge renders only while open and inside the floating layer, the floating root emitsfloating-enter/floating-leave, and the exposed geometry methods.base-anchor-animation-phases.test.tssteps the real GSAP timeline frame by frame: a 146px panel overshoots by its budget (about 6.4px) and first reaches full height on the base spring's beat; the budget is flat up to 130px and grows with the square root past it; an expliciteaseruns as written.