npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@zakkster/lite-floating

v1.0.1

Published

Zero-GC signal-native anchored positioning. The floating-ui mechanics -- placement, flip, shift, arrow -- as fine-grained lite-signal signals. Per-instance mutable state, sequential middleware pipeline, lazy auto-update wired through lite-observe. The fou

Readme

@zakkster/lite-floating

npm version Zero-GC sponsor npm bundle size npm downloads npm total downloads lite-signal peer lite-observe peer TypeScript Dependencies License: MIT

Anchored positioning as fine-grained signals. Tooltips, menus, popovers, comboboxes -- pinned to their anchor with zero allocation on scroll/resize.

createFloating returns x, y, placement, and isPositioned as lite-signal signals. A sequential middleware pipeline -- offset, flip, shift, arrow -- mutates a per-instance state struct in place; the engine never touches the DOM. Consumers bind signals to whatever style strategy they want, with bindTransform as the opt-in convenience helper.

import { createFloating, offset, flip, shift, bindTransform } from '@zakkster/lite-floating';
import { signal, effect } from '@zakkster/lite-signal';

const triggerRef = signal(null);
const popoverRef = signal(null);

const floating = createFloating(triggerRef, popoverRef, {
    placement: 'bottom-start',
    middleware: [offset(8), flip(), shift({ padding: 8 })]
});

effect(() => {
    const el = popoverRef();
    if (el) bindTransform(el, floating.x, floating.y);
});

Auto-update is on by default: ResizeObserver on both elements (shared through @zakkster/lite-observe), capturing window scroll for every nested scroller, window resize, and the IntersectionObserver layout-shift trick. Position updates coalesce into a single requestAnimationFrame per frame.


Contents


Why

Floating-UI is the de-facto solution for anchored positioning, and for good reason. But its update loop allocates every frame -- a fresh middleware-data object per pass, fresh rect objects on every measurement, a string for the placement key. For a single tooltip that's irrelevant. For an editor with dozens of inline popovers all updating during a scroll, those allocations matter.

lite-floating makes positioning a reactive primitive instead of an imperative function. A popover is x() and y() and placement() signals; the engine recomputes them when something changes. Consumers re-render only the slice they read. The state struct is allocated once at createFloating time and reused; middleware mutate it in place. The only frame-time allocations are the browser-allocated rect objects from getBoundingClientRect (one per element, unavoidable) and the eventual style string the consumer writes (one per change).

Install

npm install @zakkster/lite-floating @zakkster/lite-signal @zakkster/lite-observe

@zakkster/lite-signal and @zakkster/lite-observe are peer dependencies.

Quick start

import { createFloating, offset, flip, shift, arrow, bindTransform } from '@zakkster/lite-floating';
import { signal, effect } from '@zakkster/lite-signal';

const trigger = signal(null);
const popover = signal(null);
const arrowEl = signal(null);

// arrow middleware exposes its own x/y signals.
const arrowMw = arrow({ element: () => arrowEl(), padding: 4 });

const floating = createFloating(trigger, popover, {
    placement: 'bottom-start',
    middleware: [
        offset(8),
        flip(),
        shift({ padding: 8 }),
        arrowMw
    ]
});

// Bind to DOM when the popover mounts.
effect(() => {
    const el = popover();
    if (el) bindTransform(el, floating.x, floating.y);
});

// Position the arrow.
effect(() => {
    const el = arrowEl();
    const side = floating.placement().split('-')[0];
    if (!el) return;
    el.style.left = arrowMw.x() + 'px';
    el.style.top = arrowMw.y() + 'px';
    // Visually anchor the arrow to the side opposite the floating element.
    el.dataset.side = side;
});

// Use any DOM API to set the refs.
trigger.set(document.querySelector('#trigger'));
popover.set(document.querySelector('#popover'));
arrowEl.set(document.querySelector('#arrow'));

Architecture

One reactive effect watches the refs. When both resolve, it schedules a recompute and wires autoUpdate. Every recompute coalesces into a single requestAnimationFrame, reads the current rects into a struct that was allocated once and is reused forever, runs the middleware pipeline in place, and writes the output signals. The engine never touches the DOM; consumers bind the signals.

flowchart TB
    subgraph refs["reactive refs (accessors returning Element / virtual / null)"]
        RR["referenceRef()"]
        FR["floatingRef()"]
    end

    RR --> EFF["effect: both non-null?<br/>schedule update + wire autoUpdate"]
    FR --> EFF
    EFF -->|"update()"| RAF["coalesce into one<br/>requestAnimationFrame"]
    RAF --> BASE

    subgraph compute["compute() -- per frame, zero new allocation"]
        BASE["read rects into REUSED struct<br/>then computeBasePosition<br/>(numeric side 0..3, align 0..2)"] --> MW1
        subgraph PIPE["middleware pipeline (sequential, mutate struct in place)"]
            MW1["offset"] --> MW2["flip"] --> MW3["shift"] --> MW4["arrow / hide / size"]
        end
        MW2 -.->|"side changed: rebase + restart from 0, max 5x"| BASE
    end

    MW4 --> SX
    subgraph OUT["output signals (Object.is gated)"]
        SX["x"]
        SY["y"]
        SP["placement"]
        SI["isPositioned"]
    end

    AU["autoUpdate listeners: ResizeObserver (lite-observe),<br/>capturing scroll, window resize, IO layout-shift"] -.->|"on movement"| RAF

    SX --> BIND["consumer binding<br/>bindTransform / your own effect"]
    SY --> BIND

The dotted edges are the two feedback paths: flip requesting a pipeline restart when it changes the side (so offset re-applies against the resolved side), and autoUpdate listeners re-triggering update() on scroll/resize/layout-shift. The Object.is gate on the output signals means a consumer reading only x doesn't wake when only y moved.

API reference

createFloating(referenceRef, floatingRef, options?) -> FloatingHandle

interface FloatingHandle {
    readonly x: Signal<number>;
    readonly y: Signal<number>;
    readonly placement: Signal<Placement>;
    readonly isPositioned: Signal<boolean>;
    update(): void;     // force a recompute; coalesced into the next RAF
    dispose(): void;    // tear down autoUpdate + pending RAF; idempotent
}

interface FloatingOptions {
    placement?: Placement;                          // default 'bottom'
    strategy?: 'absolute' | 'fixed';                // informational; default 'fixed'
    middleware?: ReadonlyArray<Middleware>;
    autoUpdate?: AutoUpdateOptions | false;         // pass false to disable
}

referenceRef and floatingRef are reactive accessors returning either a real Element, a virtual element { getBoundingClientRect, contextElement? }, or null. While either is null, positioning is suspended; once both resolve, an initial update is scheduled and auto-update is wired for that pair.

Coordinates are always viewport-relative. strategy: 'fixed' is the common case (pair with bindTransform); 'absolute' is the consumer's hint to themselves -- they bind the coordinates with offsetParent compensation.

Middleware

Each middleware factory returns an object with an apply(state) method. Middleware run in order; they mutate the shared state struct in place. Factories that emit reactive outputs (arrow's x/y/staticSide, hide's referenceHidden, size's availableWidth/availableHeight) expose those signals as fields on the returned object.

offset(value) -> Middleware

Push the floating element away from the reference along the primary axis. value is pixels, positive away from the reference.

flip(opts?) -> Middleware

If the float overflows the viewport on the preferred side, try the opposite side and switch if it has strictly less overflow. opts.padding is the minimum slack from the viewport edge that still counts as "fits".

flip uses the reset pattern: when it changes the resolved side, it requests that the pipeline restart from the beginning so middleware placed before it (notably offset) re-apply against the new side. This matches Floating-UI's semantics. Up to five resets per compute pass; further restarts are dropped.

shift(opts?) -> Middleware

Clamp the floating element along its alignment axis (perpendicular to the side) to keep it within the viewport. opts.padding is the minimum gap from each edge.

arrow({ element, padding? }) -> ArrowMiddleware

Compute an arrow element's offset along the alignment axis so it visually points at the reference's centre, clamped within the float's bounds (minus padding for border-radius headroom).

interface ArrowMiddleware extends Middleware {
    readonly x: Signal<number>;
    readonly y: Signal<number>;
    readonly staticSide: Signal<Side>;     // CSS side the arrow attaches to
}

For vertical placements (top/bottom), x carries the arrow's horizontal offset and y is zero. For horizontal placements (left/right), y carries the offset and x is zero. staticSide is the opposite of the resolved placement side -- when the float sits below the reference, the arrow attaches to the top of the float. Wire it as style[staticSide()] = -arrowHalfSize + 'px'.

element accepts an Element or an accessor function returning one (useful when the arrow is mounted reactively).

hide(opts?) -> HideMiddleware

Detect when the reference has fully escaped the viewport (scrolled out of view, or otherwise wholly offscreen). Surfaces a referenceHidden signal that consumers bind to dismiss logic.

interface HideMiddleware extends Middleware {
    readonly referenceHidden: Signal<boolean>;
}
const h = hide();
const fl = createFloating(ref, flt, { middleware: [offset(8), flip(), h] });
effect(() => { if (h.referenceHidden()) closePopover(); });

The check is a rect-vs-viewport intersection on already-cached state -- four comparisons plus an equality-gated write, no allocation. It tests against the layout viewport, not an arbitrary clipping ancestor: a <div style="overflow:hidden"> that crops the reference but extends to the viewport edges will not flip referenceHidden true. A boundary option is on the roadmap.

size(opts?) -> SizeMiddleware

Compute the space available to the floating element in the resolved placement direction, for combobox/autocomplete dropdowns capping max-height, popovers shrinking to small screens, etc. Surfaces availableWidth / availableHeight signals (clamped to zero on overconstrained viewports).

interface SizeMiddleware extends Middleware {
    readonly availableWidth: Signal<number>;
    readonly availableHeight: Signal<number>;
}
const sz = size({ padding: 8 });
const fl = createFloating(ref, flt, { middleware: [flip(), sz] });
effect(() => { dropdownEl.style.maxHeight = sz.availableHeight() + 'px'; });

Place size after flip in the pipeline so it measures against the resolved side, not the preferred one. No allocation: arithmetic plus two equality-gated writes.

autoUpdate(reference, floating, update, options?) -> () => void

Standalone listener wiring, for consumers using the engine via their own scheduler. Returns a cleanup. Options (each defaults to true):

interface AutoUpdateOptions {
    ancestorResize?: boolean;     // ResizeObserver via lite-observe, on both refs
    windowResize?: boolean;       // window 'resize' listener
    windowScroll?: boolean;       // capturing window 'scroll' -- one listener catches every nested scroller
    layoutShift?: boolean;        // IntersectionObserver trick for transformed-ancestor moves
}

The capturing scroll listener is the cheap-and-correct alternative to walking the scroll-ancestor chain. The IO layout-shift trick reconfigures its rootMargin to the negation of the reference's current rect insets, so any pixel of movement drops the intersection ratio and re-fires; the observer re-arms with new margins after every update.

bindTransform(el, x, y) -> () => void

Bind x/y signals to style.transform = translate3d(...). Seeds position: fixed; top: 0; left: 0; will-change: transform on the element. Returns an idempotent dispose.

bindTopLeft(el, x, y, strategy?) -> () => void

Bind x/y signals to style.left / style.top. Less performant than bindTransform (top/left changes trigger layout, not just composite), but useful when the float must participate in flow constraints.

virtualRef(initialX?, initialY?) -> VirtualRef

Create a zero-allocation virtual reference backed by a shared mutable rect. getBoundingClientRect() returns the same object on every call; mutate via setPoint(x, y) (zero-size point) or setRect(x, y, w, h) (full rectangle). Used for cursor-anchored menus, text-range tooltips, measurement overlays, and any other reference whose geometry is computed in JS rather than read off a real element. See Building reactive UIs for the rationale.

Internal helpers

parsePlacement(s), encodePlacement(side, alignment), computeBasePosition(state), isVerticalSide(side) are exported for consumers building custom middleware or alternative pipelines.

Middleware order

The conventional order is [offset, flip, shift, arrow]. The arrow middleware should come last because it reads the final resolved state.x / state.y to position the arrow.

offset placed before flip works correctly because flip triggers a pipeline restart when it changes the side, so offset re-applies against the resolved side. This matches Floating-UI's behaviour and avoids a class of subtle bugs where a flipped popover ends up flush against its reference.

Building reactive UIs

The engine is zero-allocation in its hot path. To keep that property at the UI boundary -- where you bind signals to DOM -- a handful of patterns are worth internalising. None of them are tricky; the first time you reach for them is when you discover them, so here they are up front.

1. Wrap the floating handle in a signal when you rebuild it reactively

createFloating takes its options (placement, middleware list, autoUpdate config) at construction time. If you want to change the middleware composition or the preferred placement at runtime, you tear down and rebuild the handle. The pitfall: any effect that tracks the old handle's signals stays subscribed to those signals forever -- they're orphaned and never fire again, and your effect appears dead.

import { signal, effect, dispose } from '@zakkster/lite-signal';

const flSig = signal(null);
let activeEffects = [];

function rebuild() {
    // Dispose effects that closed over the previous handle.
    for (const e of activeEffects) dispose(e);
    activeEffects.length = 0;

    const fl = createFloating(refRef, floatRef, currentOptions());
    flSig.set(fl);

    activeEffects.push(effect(() => {
        floatEl.style.transform = `translate3d(${fl.x()}px,${fl.y()}px,0)`;
    }));
}

// Other code reads the current handle through the signal wrapper, so the
// effect retracks the new handle's signals on every rebuild:
effect(() => {
    const h = flSig();
    if (!h) return;
    console.log('placement is now', h.placement());
});

The two pieces together: a flSig wrapper signal so external observers reactively re-track when you swap the handle, plus an activeEffects array you dispose before rebuilding so the closures over the previous handle don't keep firing on stale state.

2. Use virtualRef for cursor / selection / virtual anchors

Returning a fresh { x, y, ... } object literal from getBoundingClientRect() allocates per engine pass. With autoUpdate on, the engine reads the ref rect once per compute and once per middleware that needs it (flip, shift). At 60Hz that's hundreds of literals per second.

The virtualRef factory wraps a shared mutable rect and exposes mutators:

import { createFloating, virtualRef } from '@zakkster/lite-floating';

const ref = virtualRef();
ref.setPoint(event.clientX, event.clientY);

const fl = createFloating(() => ref, () => menuEl, { placement: 'bottom-start' });

document.addEventListener('mousemove', e => {
    ref.setPoint(e.clientX, e.clientY);
    fl.update();
});

setPoint(x, y) collapses to a zero-size point. setRect(x, y, w, h) for selection-range tooltips or measurement overlays. The returned object satisfies the VirtualElement contract; the engine treats it identically to a real Element.

3. Pre-allocate any DOM you mutate per frame

If you're rendering decoration -- SVG dimension lines, debug overlays, custom arrows -- the naive pattern is to clear the container and rebuild on every signal change. That's a createElement storm during continuous interaction (scroll, drag) and the GC pressure is real.

Allocate once at mount; mutate setAttribute (or textNode.data, which mutates an existing text node rather than creating a new one as textContent does) on every signal change:

// At mount: create persistent nodes.
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
svg.appendChild(line);
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
const textNode = document.createTextNode('');
text.appendChild(textNode);
svg.appendChild(text);

// In the per-frame effect: mutate, don't recreate.
effect(() => {
    line.setAttribute('x1', fl.x());
    line.setAttribute('y1', fl.y());
    textNode.data = fl.placement();
});

setAttribute(name, value) still coerces value to a string -- there's a small unavoidable boundary allocation. But no DOM nodes are added or removed after mount, and the browser's reflow logic is much happier with attribute mutations than with childList changes.

4. The transform string is an accepted boundary cost

floatEl.style.transform = `translate3d(${fl.x()}px,${fl.y()}px,0)`;

This allocates a string per write. There's no way around it without attributeStyleMap (CSS Typed OM), which has uneven browser support. Browser JS engines are highly tuned for short-string concatenation garbage; in practice the cost is below noise. Keep these allocations isolated to the binding edge and don't worry about them; the engine has done its job by writing equality-gated signals you read inside a single effect that does one string write per frame.

5. Don't reach for innerHTML in an effect

innerHTML = invokes the HTML parser and recreates every child node it parses. In an effect that fires on continuous interaction it's worse than appendChild and far worse than textContent. Reach for textContent instead, and for the persistent-text-node pattern from #3 when even that's too noisy.


The demo (npm run demo) is wired with all five patterns intact. It's a working blueprint for how a downstream consumer -- lite-headless, a component library, your app's bespoke popover -- should consume lite-floating while preserving the zero-allocation property.

Design notes

Per-instance state, mutated in place. The state struct (side, alignment, x, y, refRect, floatRect, viewport) is allocated once at createFloating time and reused on every compute pass. No frame-time allocation by the library. Browser-allocated rect objects from getBoundingClientRect we cannot avoid; we add nothing on top.

Numeric placement encoding. Side is stored as 0..3 (top, right, bottom, left) and alignment as 0..2 (centre, start, end). flip swaps the side with one XOR: state.side ^ 2. The public placement signal carries the canonical string, computed once per pass.

Sequential pipeline, not a reactive graph. Middleware are evaluated in order over a shared mutable state. A reactive graph (each middleware a computed) sounds elegant but the data flow is inherently sequential: flip resolves the side, then shift reads the resolved side, then arrow reads the final x/y. A graph would express that as fan-out (every node reading every prior output), which is just a sequential pipeline with extra book-keeping.

Reset pattern for flip. When flip changes the side, it returns true from apply to request a pipeline restart. The engine resets the iteration index and rebases x/y; flip itself remembers it has already flipped (via a closure flag cleared by the reset hook) so the second pass doesn't re-flip. Iteration cap of five.

Signals are equality-gated. x.set(v) is a no-op when v matches the current value under Object.is. A consumer effect reading only x does not wake when only y moves. This is the lite-signal contract; lite-floating leans on it heavily.

Single capturing scroll listener. Walking scroll-ancestor chains is gnarly and wrong: every mount/dismount of the popover is a tree walk; shadow DOM is a special case; CSS containment can hide scrollers. A single window.addEventListener('scroll', fn, { capture: true, passive: true }) catches every nested scroll event in one listener with linear-in-scrollers cost only when an event actually fires.

Layout-shift trick. When a transformed ancestor moves (a CSS animation, a virtualised list reordering), no scroll or resize event fires. The IntersectionObserver trick handles it: configure an IO whose rootMargin is the negation of the reference's current viewport rect insets, so the effective root is exactly the reference's current position. Intersection ratio reads 1 while still; drops the instant the reference moves. Re-arm after each update.

Two subtleties that took some iteration to get right:

  1. Sub-pixel rounding. Hi-DPI and zoomed displays return fractional rect coordinates (e.g. y = 200.5). Math.floor on the insets builds an IO root that's up to 1px larger than the actual rect, which causes the initial observation to report ratio < 1 even for stationary elements. We use Math.round to halve the worst-case error.
  2. Adaptive threshold. Even with Math.round, the steady-state intersection ratio can land anywhere in roughly [0.95, 1.0] on noisy compositors rather than exactly 1.0. A fixed threshold of 1 then either never fires (baseline below threshold) or fires constantly on sub-pixel noise (baseline straddling it). The shipped algorithm arms with threshold: 1; the first (calibration) fire reports the steady-state ratio R; if R < 1 the observer is rebuilt once with threshold: R. Subsequent fires of the rebuilt observer are then genuine crossings of the calibrated baseline -- real movement -- and trigger update() + a re-arm. A latch ensures the rebuild happens at most once per arm cycle, so the rebuilt observer's own calibration fire doesn't recurse. This matches Floating-UI's robust approach and supersedes the earlier round-plus-first-fire-suppression design.

Benchmarks

The bench drives the engine's hot path synthetically (no browser in Node) at three levels: the base placement math alone, the middleware pipeline over a pre-built state (with and without a flip restart), and the full update() + RAF-flush path with live subscribers on the output signals. The gate fails the build on a single scavenge or more than 1 byte/call on any path.

Run: node --expose-gc bench/lite-floating.bench.js (Node 22.22, V8 12.4):

| path | ns/call | B/call | scavenges | | --- | --- | --- | --- | | computeBasePosition (cycling all sides) | ~18 | 0.00 | 0 | | middleware pipeline (no flip) | ~88 | 0.00 | 0 | | middleware pipeline (flip restart fires) | ~104 | 0.00 | 0 | | createFloating.update() + RAF flush | ~349 | 0.00 | 0 |

The headline figure is B/call and scavenges, not nanoseconds. Zero bytes and zero scavenges across every path is the verifiable claim. ns/call is machine-dependent and indicative only. Note the placement signal's string encoding (encodePlacement) was checked specifically -- it allocates nothing measurable even for aligned placements (bottom-start etc.) across millions of calls, because V8 elides the short concatenation. The unavoidable per-frame allocations in a real browser are the getBoundingClientRect() DOMRect (one per element; the library copies out and discards it) and the consumer's style string at the binding edge -- neither is the library allocating its own state.

Edge cases & guarantees

  • Null refs suspend positioning. When either referenceRef or floatingRef returns null, the effect short-circuits; signals retain their last values, isPositioned stays at its current state. autoUpdate is torn down. When the refs resolve to non-null again, a fresh autoUpdate is installed and positioning resumes.
  • Update coalescing. Calling update() multiple times within one frame schedules one RAF callback. Inputs are read once; signals are written once.
  • Virtual elements. A virtual reference ({ getBoundingClientRect, contextElement? }) without a contextElement skips ResizeObserver wiring (no element to observe) but the scroll + resize + layout-shift paths still cover the common cases. Provide contextElement to enable ResizeObserver on a wrapping real element.
  • First emission is synchronous-ish. The first compute lands on the next animation frame after both refs resolve. isPositioned flips true once x/y have been written; consumers using isPositioned to gate visibility avoid the FOUC at (0,0).
  • Dispose semantics. Call fl.dispose() to tear down the instance: it disposes the internal effect, which runs every registered cleanup -- autoUpdate listener removal (scroll / resize / ResizeObserver / IntersectionObserver) and cancellation of any pending RAF. The x / y / placement / isPositioned signals retain their last values; consumer effects on them stay subscribed but receive no further updates. Idempotent. Disposing the owning effect (the scope that called createFloating) achieves the same teardown via lite-signal's ownership tree. A dropped-but-undisposed handle is eventually cleaned up by the FinalizationRegistry safety net (non-deterministic timing; explicit dispose is the primary path).

Roadmap

Items deferred from 1.0.0, grouped by tier. Marks intent, not promises; honest scoping happens at each version cut.

1.1.x -- additive surface

  • Custom-boundary support for hide and size. Both middleware currently test against the layout viewport. A boundary option matching the (planned) shift boundary contract would let consumers check against an arbitrary clipping ancestor (an overflow:hidden scroll container that crops the reference without aligning to the viewport edge). This is the case where the v1.0 viewport-only hide returns false even though the user perceives the reference as hidden -- worth fixing once a real consumer hits it.
  • autoPlacement middleware. Alternative to flip -- instead of preferring one placement and flipping on overflow, evaluate all candidate placements and pick the one with the best fit. Marginal value when flip covers the common case; valuable for consumers needing "best fit given current overflow."
  • animationFrame fallback in autoUpdate. For adversarial cases (an animated transform on a non-observable ancestor), a polled requestAnimationFrame loop is the bulletproof fallback. The IntersectionObserver layout-shift trick covers nearly all real-world cases; this is an opt-in escape hatch for the remainder.
  • Real-browser smoke for FinalizationRegistry. Current tests use --expose-gc to force collection in Node. A browser-level smoke that creates handles, encourages GC via allocation pressure, and asserts on listener removal would catch Safari / Firefox timing divergence. The cross-browser smoke architecture also lives in lite-observe's roadmap; sharing tooling would amortise the cost.

1.2.x -- ergonomics and developer experience

  • createFloatingPair(refs) convenience factory. Returns { tooltip, popover } or similar named slots pre-configured with sensible defaults -- offset 8, flip, shift padding 8, hide, size. Wrapper over createFloating; primarily for downstream consumers (lite-headless will probably build something like this internally).
  • Bench harness regression gates in CI. Threshold checks that fail if B/call drifts above zero on any dispatch path or if ns/call regresses by more than 25% across versions. The bench numbers we publish should be auditable, not pinky-promise.
  • Real-browser smoke for fl.dispose(). Current dispose tests use the mock harness. A browser-level smoke that disposes a handle and asserts no listeners remain via getEventListeners (Chrome DevTools API) or equivalent would catch any platform divergence.

2.0.x -- breaking-change tier

  • absolute strategy with offsetParent walking. Currently coordinates are viewport-relative regardless of strategy; consumers using 'absolute' are responsible for offsetParent compensation in their binding. bindTransform works correctly for both because transforms are layout-relative. Doing the walking automatically would be a behaviour change for current 'absolute' consumers, hence 2.x.
  • Inline reference elements. Ranges across line wraps; the current rect-driven model handles a single bounding rect only. Supporting inline ranges requires the engine to handle multiple rects per reference and pick the appropriate one based on placement direction. Substantial change to the state struct, hence 2.x.

Not planned

  • Polyfilling ResizeObserver / IntersectionObserver. Both have universal support in evergreen browsers; we depend on them via @zakkster/lite-observe. Consumers needing IE11 are not in our audience.
  • A platform-agnostic core. Floating-UI splits its core from a DOM-specific layer to support React Native and other non-DOM environments. Our positioning is DOM-only by design -- the reference rect contract maps directly to getBoundingClientRect(), and the autoUpdate listeners are inherently browser-side. A non-DOM consumer would build a sibling library, not a port of this one.
  • platform injection. Floating-UI allows consumers to override the platform layer (rect-reading, document scrolling, etc.). The composability looks attractive on paper but in practice every plausible consumer wants the default behaviour; the indirection cost is paid by 100% of consumers for the benefit of zero.

Browser & runtime support

Positioning is DOM-only by design. getBoundingClientRect, ResizeObserver, IntersectionObserver, and capturing scroll/resize listeners are baseline in every evergreen browser; auto-update relies on them via @zakkster/lite-observe. The FinalizationRegistry orphan-cleanup net is used where available (all evergreen browsers, Node 14+) and is a no-op pass-through elsewhere -- explicit dispose() works everywhere. SSR-safe in the sense that importing the package never throws under Node and createFloating is inert without DOM refs; the viewport falls back to a 1024x768 box when window is absent. Requires Node >= 18 for the test/bench tooling.

Testing

  • Unit (Node, fast). npm test runs the node:test suites against deterministic fakes (a manual RAF queue, fake window with listener tracking, fake ResizeObserver / IntersectionObserver, fake Element). Covers placement math for all 12 sides, the middleware pipeline including flip's restart, hide / size signals, virtualRef, the bindings, autoUpdate listener wiring + teardown, dispose, and the SSR no-DOM path.
  • Memory (--expose-gc). npm run test:gc additionally exercises the FinalizationRegistry orphan-cleanup tests, which skip gracefully without the flag.
  • Allocation gate. npm run bench fails on any scavenge or > 1 byte/call (see Benchmarks).
  • Real-browser smoke. npm run test:browser serves test/browser/smoke.html, which runs assertions against real browser positioning, observers, and scroll.

npm scripts

| script | what it does | | --- | --- | | test | node --test test/*.test.js -- unit suite against fakes | | test:gc | as above, with --expose-gc for the finalization tests | | test:browser | serve the browser smoke page | | demo | serve the interactive demo (demo/index.html) | | bench | node --expose-gc bench/lite-floating.bench.js -- allocation gate | | verify | npm test && npm run bench |

Ecosystem

lite-floating is part of the @zakkster/lite-* family of zero-GC, zero-dependency ESM micro-libraries built on @zakkster/lite-signal:

  • @zakkster/lite-signal -- the reactive core (peer dependency).
  • @zakkster/lite-observe -- the DOM-observer-to-signal bridge that powers auto-update's ResizeObserver wiring (peer dependency).
  • @zakkster/lite-headless and other UI primitives consume lite-floating for anchored positioning.

License

MIT (c) Zahary Shinikchiev.