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

liquidforge

v0.2.1

Published

Liquid 3D hero sections for React. One component turns text, an SVG, an image, a shape or a .glb into a living chrome, glass or molten surface that reacts to the cursor.

Downloads

22

Readme

Liquidforge

Liquid 3D hero sections for React — and a browser-based forge, so you don't need a .glb to start.

import { LiquidHero } from "liquidforge"

<LiquidHero object={{ type: "text", value: "SHIP IT" }} preset="mercury-3" />

The idea

Liquid-chrome heroes are all over Awwwards and almost nobody ships one, for two reasons.

  1. They're repos you fork, not packages you install. Every one of them says "now replace model.glb with your own". That step is where it dies: most people don't have a .glb, and getting one means Blender, a marketplace, or a licence they didn't read.
  2. The shader is the hard part and it's always buried. Getting a displaced surface to actually read as liquid needs one specific thing — rebuilding the normals from the displaced surface — that almost no tutorial mentions. So people copy the code, see a flat gradient blob, and give up.

Liquidforge removes both. It's an installable component, it forges the object in your browser from a word or a logo or a primitive, and the material system is the product rather than an implementation detail.


Install

npm install liquidforge three
npm install -D @types/three   # TypeScript projects

Two peer dependencies: react (>=18) and three (>=0.160). In a TypeScript project add @types/three as well — three ships no types of its own, and without them a strict build reports errors from inside this library rather than from your code. No react-three-fiber, no postprocessing stack, and no HDRIs — the environment the metal reflects is computed analytically in the fragment shader.

Or scaffold it:

npx liquidforge init

Objects

Any of these; they all arrive as one mesh, fitted into the same box, so the material never has to know which it was.

| type | input | notes | | --- | --- | --- | | text | a string | extruded from live browser text. Any font the page can render, emoji included | | svg | src or markup | contours extruded directly | | image | src | contour-traced to a silhouette, then extruded. Logos and icons — a photograph has no outline to find | | shape | sphere torus torusknot capsule icosahedron rounded-box | parametric, no assets | | model | a .glb URL | every mesh baked into one surface. Animation is kept; materials are dropped unless the family is Original |

<LiquidCanvas object={{ type: "text", value: "SHIP IT", depth: 0.5 }} />
<LiquidCanvas object={{ type: "shape", shape: "torusknot", detail: 128 }} />
<LiquidCanvas object={{ type: "model", src: "/models/logo.glb" }} />

Don't have a model?

liquidforge/catalog searches five open catalogues — about 46,900 models — from the browser, with no API key and no account, because all five serve CORS-open metadata and files. The Studio's /assets page is this with a grid around it.

import { searchAssets, randomAsset, resolveAssetUrl } from "liquidforge/catalog"

const { results } = await searchAssets({ query: "helmet", providers: ["khronos"] })
const src = await resolveAssetUrl("khronos", results[0].id)

// Or one at random out of every importable model in all four catalogues.
const surprise = await randomAsset()
const surpriseSrc = await surprise.resolveModelUrl()

randomAsset reaches into the packed Objaverse index rather than shuffling a search page, so all 46,207 entries are actually reachable — a search returns a handful per category, and shuffling that would mean "random among the first slice" for ever. The draw is 25% from the curated sets and 75% weighted by catalogue size, because the hand-picked models are the ones with the silhouettes this material wants and pure size weighting would never show them.

Objaverse (46,207), Poly Haven (521, all CC0), Khronos (119), three.js (22) and Sketchfab (search only — downloading needs an account). Licences come back exactly as each catalogue states them; never infer one.

What makes a good object here is not what makes a good render. This material reflects an environment off a displaced surface and carries almost no interior detail, so a shape you can recognise from its outline survives and a cluttered scene turns to soup. A photogrammetry scan of grass is 1.6 million triangles of specks — it imports, but there is nothing to read.

Practical limits, all handled rather than hit:

  • Over ~200k triangles the mesh is clustered down on import. buildNormals buckets every vertex, and a 1.6M-triangle scan is not slow, it is a frozen tab.
  • Over ~90k triangles the cursor probe falls back to the bounding sphere instead of raycasting the mesh, because a per-frame raycast at that size costs more than the frame has.
  • Textures are only downloaded for the Original family, which is the one that shows them. Every other family keeps positions alone, so image requests are redirected to a blank pixel — which saves megabytes on a Poly Haven asset and removes the 404s from models whose textures sit beside the file.
  • Draco and Meshopt geometry, and KTX2 textures, need decoders this package does not bundle. Re-export uncompressed.

Animated models

Rigged and morph-target .glb files animate. Three.js's own GPU skinning cannot be used here — it lives in the material, and this material is a custom shader doing its own displacement, over a mesh that is every mesh in the file welded into one. So the skinning happens on the CPU: the scene graph, the skeleton and the mixer are kept alive off-screen, and each frame their result is read back into the flat buffer the shader draws.

That is affordable because the welding is done once — which vertices started life in the same place is a fact about topology, and a rig moving them does not change it. Per frame it is one transform per vertex and a cross product per face, with no hashing.

Past 60,000 vertices a per-frame re-bake costs more than the frame has, so the model is posed rather than animated. The models that actually animate are an order of magnitude under that; a photogrammetry scan is not, and does not animate.

<LiquidHero
  object={{ type: "model", src: "/soldier.glb" }}
  motion={{ animation: "Run", animationSpeed: 1.2 }}
  onReady={({ animations }) => console.log(animations)}
/>

Presets

A hundred and eight colourways across twelve families. A preset is data — a palette and about twenty numbers — so you can edit one, serialise it, or inline it.

Each family is a different technique, not a different palette. If a look can be reached by recolouring an existing family, it is a colourway rather than a family.

| family | look | reach for it when | | --- | --- | --- | | Mercury | liquid chrome, tinted | the default. Anything that should read as restrained | | Aurora | iridescent oil slick | music, launches, anything that wants colour | | Prism | glass with dispersion | product and hardware pages; reads as precision | | Magma | molten, glowing in the troughs | loud. Games, events, energy | | Pearl | soft matte iridescence | light pages, and anything that should feel calm | | Obsidian | lacquer over a deep body | luxury, automotive, audio — expensive and restrained | | Velvet | cloth, lit along the silhouette | fashion, film, interiors. The only family with no highlight | | Halo | holographic foil | streetwear, drops, merch. Loud and prismatic | | Jade | translucent stone | the second light-page family; wellness, craft, ceramics | | Plasma | filaments in a dark body | AI, data, networks. Electric and technical | | Original | the object's own textures and colours, made liquid | a model, logo or illustration that already looks right — you want it to move, not to become chrome | | Ferrofluid | black magnetic liquid; spikes stand up toward the cursor | hardware, audio, science, anything that should feel like it has a field around it |

Each colourway declares the ground it expects: dark, mid, light or transparent. Pearl and Jade are lit for a light page. Obsidian, Velvet and Plasma ask for mid, a studio grey — a black lacquer, a deep velvet or a body lit only by its own filaments against a near-black page is not subtle, it is invisible, which is the same reason nobody photographs a black car against black.

None of that is fixed. background takes any colour, and transparent composites over whatever your page already has:

<LiquidHero object={{ type: "text", value: "SHIP IT" }} preset="obsidian-1" transparent />

The Studio's export has a switch for exactly this: with a background it is a finished section for an empty route, without it is a surface to drop into a layout you have already designed.

Ids run mercury-1 … mercury-9, aurora-1 …, and so on. npx liquidforge presets lists them all.

Overrides layer on top of a colourway, merged one level deep, so you can move one number without restating the other nineteen:

<LiquidHero
  object={{ type: "text", value: "SHIP IT" }}
  preset="mercury-3"
  palette={["#ff5f1f", "#1a1a1a", "#ffd6a5", "#7a2e0e"]}
  surface={{ advection: 1.1, trailSpacing: 0.14 }}
/>

Where it goes

A hero is the obvious placement and usually not the one you need. codegen emits eight, each a self-contained component with inline styles and no dependency beyond this package, so it pastes into any project regardless of how that project does CSS.

| layout | | | --- | --- | | hero | full-bleed section with your headline over the surface | | split | copy on one side, surface on the other | | card | a product or feature card with the surface as its media | | grid | three feature cards in a row | | banner | a wide strip — a call to action, or a divider with weight | | badge | a small round mark, for a logo lockup or an avatar | | backdrop | fixed behind a whole page, with content scrolling over it | | canvas | just the surface; you do the layout |

import { generateCode, configFromPreset } from "liquidforge/codegen"

generateCode(configFromPreset("mercury-3"), { showcase: "card" })

The layout has an opinion about two props and applies them itself rather than leaving them to you: a badge is always transparent, and a badge or a card in a grid drops to quality="low" — a 56px mark does not need the adaptive tier.

The Studio has all eight in its export, and /showcase renders four of them as pretend websites so you can see a colourway somewhere other than full-bleed on black. That is the placement most people never use, and a palette that looks expensive at full size often turns to mud at 36px.


On a site you already have

A hero owns a band of the layout. LiquidSpot owns nothing: it floats over the page you already built, somewhere you put it with the in-place editor, and can travel a route as the page scrolls.

import { LiquidSpot } from "liquidforge"
import { LiquidEditor } from "liquidforge/editor"
import placements from "./liquidforge.placements.json"

<LiquidSpot id="hero" placement={placements.hero} />
{process.env.NODE_ENV === "development" && <LiquidEditor placements={placements} />}

Press ⌘⇧E on your own page and:

  • Place — drag the object where it goes and pick what it is.
  • Path — draw the route it takes as the page scrolls, or press Route around content to have one drawn through the page's empty space.
  • Size — set how big it is at each point.
  • Rotate — pick a point and drag: left and right turn the object, up and down tip it. Points carry their rotation forward, so scrolling between two that differ turns the object in 3D as it travels.
  • Checkpoints — select a point and give it a different element or look. The object melts into it as the page scrolls past, and back on the way up.
  • Pin — tie a point to a heading or section, so it is reached when that element is at that height. Edit the copy above it and the timing follows.
  • Breakpoints — tablet and phone can each have their own placement; they inherit the desktop one until you change something.

Save writes liquidforge.placements.json into your project through a development-only route (createPlacementsRoute from liquidforge/dev for Next.js, the liquidforgePlacements() plugin for Vite). The editor holds no state of its own, so deleting it keeps everything; putting it back later opens on exactly what is on screen. npx liquidforge and the Studio's Place it tab print the whole setup, and the MCP server can leave a proposed placement for you to accept or discard in the editor.


Breeding

Two looks can have children. breed crosses them gene by gene — each number from one parent, the other, or between — nudges each by an amount scaled to that gene's range, crosses the palette in OKLab, and takes the silhouette and the family whole from one parent each. The same seed always gives the same child.

import { breed, litter, blendPresets } from "liquidforge/breed"
import { PRESETS } from "liquidforge"

const a = { preset: PRESETS["mercury-3"], object: { type: "text", value: "OIL" } }
const b = { preset: PRESETS["velvet-1"], object: { type: "shape", shape: "torus" } }

const six = litter(a, b, 6, 42, { mutation: 0.15 })   // Child[]
const halfway = blendPresets(a.preset, b.preset, 0.5) // what a checkpoint melts through

The Studio's gallery breeds any two posts, and a post made from a child credits both parents.


Posters, loops and phones

Poster. The shader compiles and the mesh builds before the first frame, which is the slow part of a hero. Give it a still and the page paints that at once, then crossfades to the live surface when the browser is idle:

<LiquidHero object={{ type: "text", value: "SHIP IT" }} preset="mercury-3" poster="/hero-poster.webp" />

engine.posterBlob(width, height) renders that still, and the Studio's export downloads one to match the component it gives you.

Loops. engine.renderFrames({ seconds, fps, width, height }, onFrame) renders a closed loop one exact frame at a time and hands each canvas to you — encode them with WebCodecs and a slow machine takes longer instead of dropping frames. The Studio does exactly that to download a 4K H.264 MP4. engine.recordLoop() is the real-time MediaRecorder version, for when an MP4 encoder is not available.

Phones. A touch screen has no cursor, so by default (deviceMotion: "auto") tilting the phone steers the light and the well instead. iOS asks for permission on the first tap.


Bound to a number

Give the surface a number and it follows it. As value climbs from min to max the look moves toward to — or, without one, the same look gets louder — and passing a milestone on the way up throws a splash across the whole object.

import { LiquidHero, useLiveNumber } from "liquidforge"

const stars = useLiveNumber("https://api.github.com/repos/vercel/next.js", { path: "stargazers_count" })

<LiquidHero
  object={{ type: "text", value: "STARS" }}
  preset="mercury-3"
  data={stars === undefined ? undefined : { value: stars, min: 100_000, max: 150_000, to: "magma-4", milestones: [150_000] }}
/>

useLiveNumber polls any JSON endpoint that allows cross-origin requests, by dot path, and pauses while the tab is hidden. Changes ease in over ease milliseconds (900 by default) rather than jumping. engine.splash() throws a splash by hand. On <liquid-forge> the same thing is value, value-min, value-max, value-to and milestones, or value-src and value-path to fetch the number.


The blend-mode trap — read this before using blend

The signature effect is a headline that inverts to the complement of the liquid behind it: teal over orange, olive over pink. It's one CSS line, and <LiquidHero blend> applies it for you.

.blend { mix-blend-mode: difference; color: #fff; }

But no ancestor may create a stacking context. A z-index, a transform, a filter, an opacity below 1, a will-change, a backdrop-filter, a contain, or isolation: isolate on any parent isolates the blend group. The text then composites against that parent's transparent backdrop instead of against the canvas, and renders flat white over the object — with no error and no clue as to why.

It's usually the host layout that does it, not this component. The usual suspects:

  • a page wrapper with transform: translateZ(0) for "GPU acceleration"
  • an animation library's wrapper mid-transition
  • a fixed header given z-index: 50, if it wraps the hero rather than sitting beside it
  • opacity on a fade-in wrapper

Fixes, best first:

  1. Handle painting order with DOM order, not z-index. <LiquidHero> does this internally — the canvas and the content are both positioned with z-index: auto, and positioned siblings paint in DOM order.
  2. Move the offending property onto an element that isn't an ancestor of the hero.
  3. If you need a fade-in, animate a child of the blended element rather than a parent.

In development the component walks the ancestor chain and logs the exact element and property responsible. You can run that check yourself:

import { findBlendIsolator, warnIfBlendIsolated } from "liquidforge"

Performance

Fragment cost dominates, and it scales with the square of pixel ratio, because the advection loop runs per pixel.

quality="auto" (the default) measures real frame times and walks setPixelRatio between 0.75 and 2.0 every three quarters of a second. A fixed value either wastes a fast GPU or drops frames on a slow one, and which of those a visitor has is not knowable up front.

| tier | pixel ratio | vertex budget | trail | | --- | --- | --- | --- | | auto | 0.75–2.0, measured | 160k | 12 | | high | 1.5–2.0 | 400k | 16 | | balanced | 1.0–1.5 | 160k | 12 | | low | 0.75–1.0 | 60k | 8 |

Geometry is deliberately not adaptive: re-tessellating mid-scene rebuilds a quarter of a million vertices on the main thread, which is a visible stall where a pixel-ratio change is invisible.

Rendering pauses when the section scrolls out of view.

If you're building a gallery, remember each canvas holds a WebGL context and browsers cap those at roughly 16 before silently dropping the oldest. Mount only what's near the viewport.


Reduced motion

prefers-reduced-motion is honoured by default. When the visitor has asked for reduced motion the drift, the ripples and the advection all freeze and the object renders as a still material — it doesn't disappear. motion.respectReducedMotion={false} exists; leave it alone.

The canvas carries no text, so your headline and copy have to live in real DOM elements. That's what the children slot is for.


API

<LiquidHero />

Everything <LiquidCanvas /> takes, plus:

| prop | default | | | --- | --- | --- | | layout | "overlay" | "overlay" or "split" | | canvasSide | "right" | which side the surface sits on in split | | height | "100vh" | section height | | blend | false | invert the content against the liquid | | children | | your headline and copy |

<LiquidCanvas />

| prop | default | | | --- | --- | --- | | object | a sphere | what to render | | preset | "mercury-1" | a colourway id, or a whole LiquidPreset | | family palette surface shading | | overrides layered on the preset | | quality | "auto" | auto high balanced low | | motion | | { autoRotate, tilt, draggable, respectReducedMotion } | | transparent | false | composite over the page | | background | | override the preset's background colour | | pauseOffscreen | true | stop rendering when scrolled past | | fallback errorFallback onReady onError | | |

Without React

On Webflow, Framer, WordPress or a plain HTML page, use the custom element. One script tag, three.js included:

<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/element.global.js" defer></script>
<liquid-forge text="SHIP IT" preset="mercury-3" style="display:block;height:480px"></liquid-forge>

The object is one of text, shape, model, svg or image (or an object attribute holding the JSON). The look is preset, plus family, palette (comma-separated), and surface / shading as JSON of just the numbers you changed. Then transparent, background, quality, auto-rotate, draggable="false" and poster. Change an attribute and the surface updates in place; element.engine is the engine underneath. With a bundler, import "liquidforge/element" registers the same element against your copy of three. The Studio's export writes the Webflow embed, a Framer code component, or plain HTML for any look, and so does generateEmbed in liquidforge/codegen.

Under the element, LiquidEngine owns the whole render loop and has no React in it:

import { LiquidEngine, forgeGeometry, PRESETS } from "liquidforge"

const engine = new LiquidEngine({ container, preset: PRESETS["mercury-1"] })
engine.setGeometry(await forgeGeometry({ type: "text", value: "HI" }))
engine.resize(width, height)
engine.start()

Owning the source

npx liquidforge add hero        # components + engine + material + forge
npx liquidforge add material    # just the shader and the render loop
npx liquidforge add forge       # just the object generators

Copies the real source into your project. The shader is in material/glsl/; fragment.ts is where a new family goes, as a #define branch alongside the twelve that ship.


How it works, briefly

Four things, each of which fails silently if you get it wrong.

Normals are rebuilt from the displaced surface. Without this the lighting believes the surface is undisturbed, so dents and ripples are completely invisible however hard you push the geometry. The frame is built from the mesh's own normal — no UVs and no tangent attribute needed — and the normal is tilted by the gradient of the height field, which is algebraically the same as crossing two displaced tangent offsets and costs two fewer cross products.

Displacement runs along a welded normal, shading along a crease-aware one. At a hard edge one position carries two different normals; displacing each along its own tears the seam open. Two attributes, one job each.

The cursor is found by ray cast, in object space. A flat screen-space projection is only right at dead centre — measured 154px off-centre it put the dent 22px from the cursor, worse toward the rim, which visitors read instantly as "it isn't tracking me". Resolving in object space (rather than forbidding the mesh to rotate) means motion.autoRotate doesn't quietly drift the interaction out of step.

Ripples come from a trail, not a single source. A decaying "stir" scalar freezes every ripple the instant the mouse stops. Instead there's a ring buffer of points the cursor has passed through, each living out its own life — and rings are emitted on distance travelled, not per frame, so the same gesture leaves the same wake on a 60Hz and a 120Hz display.

The spiral, incidentally, is not concentric rings. It comes from rotating the colour field around each trail point — hard at the centre, weakly further out — per pixel. More triangles will not produce it, because the detail lives between the vertices.


Licence

MIT © Meet Chauhan

The shader generalises the author's own liquid-sphere study, form-flux-studio, from one hard-coded sphere to arbitrary geometry with a library of looks.