Components/BaseAnchor

BaseAnchor

Floating UI + GSAP anchored popover with configurable animation modes.

VerifiedSince 0.3.4

Usage

BaseAnchor

Loading demo...

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

Loading demo...

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

Loading demo...

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

Loading demo...

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

Loading demo...

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

Loading demo...

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

Loading demo...

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

Loading demo...

Best Practices

  • Reach for TxPopover, TxDropdownMenu, or TxContextMenu first. Use TxBaseAnchor when 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 virtualReference and call updatePosition() after pointer or canvas transforms change.
  • eager and keepAliveContent retain 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

PropTypeDefaultDescription
modelValuebooleanfalseWhether popover is open (v-model).
disabledbooleanfalseDisable the popover.
eagerbooleanfalseMount the floating panel before first open; useful when content must measure itself up front.
placementBaseAnchorPlacement'bottom-start'Floating placement.
offsetnumber8Distance from reference element (px).
widthnumber0Panel width (0 = content-driven width).
minWidthnumber0Minimum width.
maxWidthnumber360Maximum width.
maxHeightnumber420Maximum panel height before viewport clamping.
unlimitedHeightbooleanfalseDisable panel height limiting; also active when maxHeight <= 0.
matchReferenceWidthbooleanfalseFollow reference width when width is 0.
referenceClassBaseAnchorClassValueundefinedExtra class value applied to the reference wrapper, not the floating panel.
virtualReferenceBaseAnchorVirtualReferenceundefinedUse a virtual reference, such as a mouse coordinate, for placement. Useful for ContextMenu, canvas nodes, and cursor menus.
disableFlipbooleanfalseDrop 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.
animationBaseAnchorAnimationOptions{}Unified animation config; omitted fields use their type defaults, with expand as the default type.
useCardbooleantrueWrap 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).
panelRadiusnumber18Panel border radius (TxCard radius).
panelPaddingnumber10Panel padding (TxCard padding).
panelCardPartial<TxCardProps>undefinedPass-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.
showArrowbooleanfalseEnable the triangle arrow that follows floating placement.
arrowSizenumber10Arrow size in px.
keepAliveContentbooleanfalseKeep floating content mounted after close so inner state is preserved.
closeOnClickOutsidebooleantrueClose on outside click.
closeOnEscbooleantrueClose on Escape key.
toggleOnReferenceClickbooleantrueToggle open state on reference click.
hoverBridgebooleanfalseWhile 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

PropTypeDefaultDescription
type'expand' | 'transfer' | 'boom' | 'opacity' | 'none' | 'drip' | 'bead''expand'Animation type. drip and bead share one engine and bring their own timing table.
closeTypeSame values as typeSame as typeType 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.
durationnumberper type (expand 400 / classic 432 / liquid 260)Enter duration in ms.
closeDurationnumberper close type (expand 240 / classic: open duration × 0.45 / liquid 150)Leave duration in ms.
easestringper 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.
closeEasestringper 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.
distancenumberper type (expand 12 / transfer 30)expand drift and transfer travel in px; the leave uses it too by default (see exit).
scalenumberper type (expand 0.88 / boom 0.94 / transfer 0.92)Enter initial scale; the leave returns to it by default (see exit).
blurnumber12Boom enter initial / leave target blur radius in px.
opacitynumber0expand / boom / opacity enter initial and leave target opacity.
exit{ scale?, distance?, blur?, opacity? }See descriptionGeometry 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.
gooBlurnumber4.5drip / bead only. Goo feGaussianBlur stdDeviation. With gooThreshold this decides how wide a gap the neck survives.
gooThresholdnumber20drip / bead only. Alpha slope of the threshold colour matrix.
gooThresholdOffsetnumber-9drip / bead only. Alpha offset of the threshold colour matrix.
outlineColorstring--tx-border-colordrip / bead only. Colour flooded into the ring derived from the merged silhouette. Resolved from the token so dark mode follows.
triggerRadiusnumbermeasureddrip / bead only. Corner radius of the trigger ghost; read from the reference when omitted.
seedHeightnumber12drip / bead only. Panel height at p=0 — the seed the drop is torn from.
itemSelectorstring'[data-liquid-item]'drip / bead only. Items faded in against the panel's own growth.
beadPinchnumber60bead 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.
beadVelocityRefnumber4bead 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

EventParamsDescription
open-Fired when popover opens.
close-Fired when popover closes.
update:modelValuebooleanv-model update.
floating-enterMouseEventThe pointer entered the floating layer: the panel box, or the hover bridge when hoverBridge is on.
floating-leaveMouseEventThe pointer left the floating layer altogether.

Slots

SlotDescription
referenceTrigger element.
defaultPopover content; receives { side } from final Floating UI placement.

Exposed Methods

MethodParamsDescription
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

  • modelValue may be controlled or uncontrolled. Reference clicks emit update:modelValue, plus open or close when the state changes.
  • disabled blocks opening and closes an already open uncontrolled anchor.
  • closeOnClickOutside and closeOnEsc independently control outside pointer and Escape closing.
  • toggleOnReferenceClick=false keeps reference clicks from changing open state; use it for editable references that own their click behavior.
  • floating-enter / floating-leave are 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 with hoverBridge on. 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 follows flip / shift; it is removed the moment a close begins.
  • virtualReference overrides 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. Use referenceClass for the reference wrapper.
  • maxHeight is clamped by available viewport height. Use unlimitedHeight only for panels with their own scroll container.
  • Content past maxHeight scrolls in the card's body, not on .tx-base-anchor__card itself, 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.
  • surfaceMotionAdaptation is an active hard-cut strategy: auto uses anchor motion state, manual forwards panelCard.surfaceMoving, and off forces surfaceMoving=false.
  • Motion is configured only through the animation object; the component has no top-level duration / ease props, and any field left out takes the type's own default.
  • An expand with no ease picks its spring by panel height. Up to about 63px it is spring(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. expandBounceBudget sets 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 showArrow to false, and DropdownMenu, ContextMenu, Select and the rest built on them show none either; opt in per instance.
  • The showArrow arrow 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: expand pokes 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; transfer tucks it into the panel and pops it as the slide lands; boom pops it late and fast; opacity adds nothing and fades it with the panel.
  • eager mounts hidden, measurable content before first open; keepAliveContent preserves 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 without display: none or 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 / bead is defined on the vertical axis only. A left* / right* placement — including one produced by flip — degrades to the opacity path, keeping the same timings.
  • drip / bead paints its own opaque surface through the goo filter, so TxCard is not rendered and panelBackground / panelShadow / panelVariant do not apply. The four non-pure backgrounds all rely on backdrop-filter, which does not survive inside an SVG filter and would threshold into hard edges.
  • drip / bead suppresses showArrow: 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 / bead punches 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 outline use is 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 / bead needs a measurable panel height; unlimitedHeight (or maxHeight <= 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-driven expand / transfer / boom / opacity paths finish immediately, and drip / bead snaps through its prepare step (none is already instant).

Technologies

  • Reviewed against packages/tuffex/packages/components/src/base-anchor/src/TxBaseAnchor.vue, base-anchor-motion.ts, types.ts, and base-anchor.test.ts.
  • Existing tests cover uncontrolled toggling, controlled outside/Escape close paths, disabled blocking, close/toggle switches, floating attrs and reference classes, animation object variants, and surface-motion adaptation strategies.
  • Rejected design: letting .tx-base-anchor__card scroll itself with overflow: 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 visibility never 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 under expand it 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__content and 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 out back.out. expandSpringFor now 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 with clip-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-parked keeps intrinsic width at max-content because right: 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-height is written only by the size middleware: min(availableHeight, maxHeight) in pixels, or none when unlimited. The root :style does not declare it — it used to bind isUnlimitedHeight ? 'none' : undefined, and Vue's style patcher turns an undefined custom property into setProperty(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: TxBaseAnchor is positioning infrastructure only. The built primitive must provide menu/dialog/listbox roles, focus handling, and keyboard traversal appropriate to its content.
  • drip / bead coverage lives in base-anchor-liquid.test.ts (cubic-bezier solver, spring rejection, spec geometry at p=0 / 0.45 / 1, height-edge independence, item reveal) and in base-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 through resolveGsapEase in packages/tuffex/packages/utils/animation/easing.ts: spring(...) and cubic-bezier(...) become progress functions handed to GSAP, and GSAP's own ease names pass through untouched. Only expand used 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 / bead is 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: liquid uses filter: url(#…), which Safari and Firefox both support. Only backdrop-filter: url(#…) is restricted, and liquid does 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.ts exports BaseAnchorProps, BaseAnchorAnimationOptions, and virtual-reference types.
  • Verified coverage: packages/tuffex/packages/components/src/base-anchor/__tests__/base-anchor.test.ts verifies uncontrolled toggling, controlled outside/Escape closes, disabled behavior, close/toggle switches, floating attrs/reference classes, animation object variants, and surface-motion adaptation. base-anchor-max-height.test.ts verifies --tx-ba-max-height ownership: after the open settles and after a re-render the value is still the pixel value the middleware wrote (none for unlimitedHeight), and no empty write ever clears it. base-anchor-hover-bridge.test.ts covers the hover bridge: the middleware is added only with hoverBridge and runs last, a closed gap writes box: null, the bridge renders only while open and inside the floating layer, the floating root emits floating-enter / floating-leave, and the exposed geometry methods. base-anchor-animation-phases.test.ts steps 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 explicit ease runs as written.
查看源码
packages/tuffex/packages/components/src/base-anchor/index.ts