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

@denizeffects/dropnav

v0.1.0

Published

A navigation that behaves like a droplet. One circle in a corner that leans toward your cursor, then splits into the nav items through liquid bridges. Analytic metaball SVG, no blur filters.

Readme

dropnav

a navigation that behaves like a droplet. one circle rests in a corner, leans toward your cursor before it arrives, and on click stretches, necks and breaks into the nav items through liquid bridges that thin until they snap.

the body is a single svg path, fused two ways: the classic blur-and-threshold goo filter by default, or the joins solved analytically from real tangent points and curve handles. same geometry, same motion, different material. no glassmorphism, no webgl.

install

npm install @denizeffects/dropnav

quick start

import { DropNav } from "@denizeffects/dropnav";

<DropNav
  items={[
    { label: "home", href: "/" },
    { label: "work", href: "/work" },
    { label: "contact", href: "/contact" },
  ]}
/>

that is the whole thing. it fixes itself to the top right corner, leans toward the cursor, and manages its own open state.

icons

each droplet holds one icon. anything renders; hairline strokes around 18px read best against the ink.

<DropNav
  items={[
    { label: "home", href: "/", icon: <HomeIcon /> },
    { label: "lab", href: "/lab", icon: <FlaskIcon /> },
  ]}
/>

without an icon a droplet falls back to the first letter of its label. nothing is drawn beside the droplets — no tooltips, no captions. the label is still the button's accessible name, so screen readers and voice control get it; the surface stays circles.

position and layout

<DropNav position="bottom-left" />          // any of the four corners
<DropNav layout="arc" />                    // fans out of the corner (default)
<DropNav layout="column" />                 // straight stack
<DropNav layout="row" />                    // along the edge

the arc solves its own radius from the chord, so neighbouring droplets keep exactly gap of clear space however many items there are and however wide you set spread.

the lean

the cursor does exactly one thing: a field past each droplet edge tilts it toward the pointer. no tendril, no droplet chasing the mouse. the division is the event here, and anything competing with it is noise.

<DropNav
  attraction={{
    radius: 31,  // how far past the edge the field reaches, px
    pull: 0.26,  // lean, as a fraction of the droplet radius
  }}
/>

radius defaults to size * 0.55 — roughly one droplet-radius, tight enough that each droplet answers for itself instead of the whole cluster sliding at once. the field does not fade evenly across that depth: it holds near full strength for the first two thirds and then falls off a cliff, so the lean reads as the droplet noticing you rather than reaching for you from across the page. shrinking the radius alone gives a field that is weak everywhere instead of one that is tight.

the droplet follows how far off its centre the pointer sits, capped at pull × its radius — so the lean is zero dead centre, strongest around the rim, and monotonic between. push pull past about 0.4 and it stops reading as liquid and starts reading as rubber. pull: 0 turns the cursor off entirely.

the two surfaces

<DropNav surface="goo" />   // blur + alpha threshold (default)
<DropNav surface="path" />  // joins solved analytically

path solves each join from the triangle between two centres: exact tangent points, cubic handles along the surface normals, so the join is tangent-continuous with both edges. vector-crisp at any size and the cheaper of the two. every pair is solved, not just neighbours along the chain, so it merges n-body the same way the filter does — droplets still stacked on each other early in the split read as one mass, and two of them find each other again if the cursor drags between them.

goo blurs the shapes, thresholds the alpha back to a hard edge, then composites the untouched circles on top, so every droplet keeps a true vector edge and only the necks are raster. merging is by proximity, which makes it n-body for free: anything near anything else fuses, in any arrangement, no chain required. costs one raster pass over the cluster per frame, and the neck reaches about as far as the blur, so it fuses over shorter distances than path.

<DropNav
  surface="goo"
  goo={{
    blur: 7,        // px; defaults to gap * 0.7
    threshold: 0.5, // alpha cutoff; 0.5 keeps circles their authored size
  }}
/>

the blur default is gap * 0.7 for a reason: at that radius each blurred edge contributes just under half the cutoff at the midpoint of the gap, so resting droplets stay separate no matter how you size them. push it higher and the open nav sits there permanently webbed.

speed

<DropNav speed="calm" />    // 1600ms, unhurried
<DropNav speed="normal" />  // 1000ms (default)
<DropNav speed="quick" />   // 620ms

one control for the whole thing, either surface. it sets duration and stagger together; pass either of those explicitly to override it.

motion

the split is not released at its destination — each droplet is walked along its path by an authored curve, with a spring chasing that target underneath for softness. a spring alone always leaves immediately; this way the departure can be shaped.

it is paced to be watched. with the default geometry the droplets sit close enough that a neck survives about 98% of the travel — there is no stretch where the split is finished and the motion is still running.

<DropNav
  duration={1000}                           // overrides speed; travel time for one droplet
  ease={[0.65, 0, 0.35, 1]}                 // the curve it travels on
  stagger={80}                              // ms between droplets leaving the body
  spring={{ stiffness: 190, damping: 25 }}  // the softness under the curve
  tension={0.62}                            // how fat the bridges run, 0..1
/>

ease takes css cubic-bezier control points. the default holds while the neck takes the strain, crosses the middle without lurching, and glides to a stop. [0.5, 0, 0, 1] holds longer and then snaps away, if you want the break to feel more violent than smooth.

lower damping toward 18 for a visible settle; raise it past 30 for a dead stop. tension at 0 gives clean separate circles with no bridging at all.

both the easing solver and the default curve are exported, if you want the same timing elsewhere:

import { cubicBezier, SPLIT_EASE } from "@denizeffects/dropnav";

const ease = cubicBezier(...SPLIT_EASE);

how many items

three is what the geometry likes. the arc solves its radius from the chord, so with three droplets it sits at its floor — each one exactly gap clear of the body and of its neighbours, which is as tight as they go without merging at rest. past four, the radius has to grow to fit them and the droplets start travelling further than the neck can survive, so the split reads as items flying out rather than a body dividing. widening spread pulls them back in.

the press

hold a droplet down and it gives. because the whole surface is driven by radius, the squeeze travels through the joins as well — press one while it is still bridged to another and the neck thins with it, instead of the feedback stopping at the edge of one circle. the glyph inside gives too.

<DropNav press={0.11} />  // fraction of the radius; 0 turns it off

it runs on a stiffer, critically damped spring than the rest of the motion: a press wants to arrive at once and come back without ringing. releasing the trigger hands straight into the split, and because the split curve holds for its first moment, the rebound lands inside that hold rather than fighting it.

controlled

const [open, setOpen] = useState(false);

<DropNav open={open} onOpenChange={setOpen} items={items} />

uncontrolled otherwise, with defaultOpen if you want it open on mount.

inside a panel

by default the nav anchors to the viewport. fixed={false} anchors it to the nearest positioned ancestor instead, which is what you want for a docked panel, a card, or a demo stage.

<div style={{ position: "relative", overflow: "hidden" }}>
  <DropNav fixed={false} margin={20} items={items} />
</div>

props

| prop | type | default | notes | | --- | --- | --- | --- | | items | DropNavItem[] | | { label, href?, icon?, onSelect?, target?, rel?, id? } | | position | "top-right" \| "top-left" \| "bottom-right" \| "bottom-left" | "top-right" | | | layout | "arc" \| "column" \| "row" | "arc" | | | size | number | 56 | trigger diameter | | itemSize | number | size * 0.86 | item diameter | | gap | number | 10 | clear space between droplets at rest | | spread | number | 112 | arc width in degrees | | margin | number | 24 | distance from the corner | | surface | "goo" \| "path" | "goo" | the blur filter, or solved joins | | speed | "calm" \| "normal" \| "quick" | "normal" | sets duration and stagger together | | tension | number | 0.62 | bridge fatness, 0..1 (path) | | press | number | 0.11 | squeeze while held, as a fraction of the radius | | goo | { blur, threshold } | { gap*0.7, 0.5 } | (goo) | | duration | number | from speed | travel time for one droplet, ms | | ease | [number,number,number,number] | [0.65,0,0.35,1] | cubic-bezier control points | | attraction | { radius, pull } | { size*0.55, 0.26 } | the lean toward the cursor | | spring | { stiffness, damping } | { 190, 25 } | | | stagger | number | from speed | ms between droplets | | open / defaultOpen / onOpenChange | | | controlled or not | | fixed | boolean | true | viewport or nearest positioned parent | | closeOnSelect | boolean | true | | | closeOnOutsideClick | boolean | true | | | triggerLabel / navLabel | string | "menu" / "site" | accessible names | | zIndex | number | 50 | | | className, style | | | applied to the root |

theming

three variables, set them anywhere above the nav:

:root {
  --dn-ink: #1b1a17;    /* the liquid */
  --dn-icon: #f7f6f3;   /* icons and the resting dots */
  --dn-focus: #1b1a17;  /* focus ring, falls back to --dn-ink */
}

--dn-ink falls back to currentColor, so a color on any ancestor works too.

the metaball, on its own

the bridge math is exported. it takes two circles and returns an svg path for the join — useful for anything that needs two shapes to read as one body.

import { metaballPath, circlePath } from "@denizeffects/dropnav";

const d =
  circlePath(40, 40, 20) +
  circlePath(110, 40, 16) +
  metaballPath({ x: 40, y: 40, r: 20 }, { x: 110, y: 40, r: 16 }, {
    tension: 0.6,   // neck fatness while merged
    span: 24,       // px past touching that the bridge survives
  });

<path d={d} fill="currentColor" />;

every subpath comes out counter-clockwise, so fill-rule: nonzero unions them rather than punching holes.

how it works

each droplet is two springs for its centre and one for its radius, chasing a target that the split curve walks along the path. it all runs in a single rAF loop at a fixed sub-step, writing d and transform straight to the dom — react never re-renders during motion — and parking itself the moment the schedule finishes and every spring settles. move the cursor outside the field and it does not even wake.

the cursor is modelled as its own droplet. its radius grows with how deep into the field the pointer is, and the same bridge math that splits the nav open is what makes the body reach for it. one piece of geometry drives both behaviours, which is why the hover and the expansion feel like the same material.

perceived volume is held two ways: a droplet gives up radius in proportion to the tendril it is feeding, and the body contracts slightly while it holds the arc open.

accessibility

the trigger is a real button with aria-expanded and aria-controls; the items are real anchors or buttons inside a labelled nav. opening moves focus to the first droplet, arrows and home/end move between them, escape closes and returns focus to the trigger. while closed the group is inert, so it is out of the tab order and out of the accessibility tree.

under prefers-reduced-motion the springs are bypassed entirely: droplets sit at their targets, no bridges are drawn, no cursor field runs. the nav still opens, closes and navigates.

touch pointers never trigger the attraction field — there is no cursor to be attracted to.

license

mit