Liquid hover
A picture, or every picture of a grid, turns liquid under the pointer.
Documentation
-
Copy to Webflow
Add Geist and Geist Mono to your site's fonts (Site settings, Fonts, Google Fonts) before pasting: Webflow removes any font your site has not installed. -
Add custom CSS
Custom CSS in WebflowThis CSS already ships inside the Webflow embed, so a pasted section needs nothing added. Paste it yourself, in the page or the site custom code, only if the effect runs on several pages. The tutorial explains where.
See the tutorialThe canvas is laid over the marked box and draws its pictures: anything laid over a picture, a badge or a caption, needsposition: relativeand az-indexover 1 to stay in front. The radius and theobject-fitof a picture are read on the picture itself. -
Add custom JS
Custom JavaScript in WebflowThis script already ships inside the Webflow embed, settings block included. You paste it yourself, before the closing body tag of the page or the site custom code, only when the effect has to run on several pages, and the tutorial says how to choose.
See the tutorial -
Use it on your own elements
How to add a custom attribute in WebflowA custom attribute is a pair, a name and a value, typed in the Settings panel of the element you selected, under Custom attributes. The name is what the script looks for, the value is what it reads. The tutorial shows the panel.
See the tutorialCustom attributesSelect the element on the canvas and open the Settings panel. Under Custom attributes, add a pair: the name on the left, the value on the right.Custom attribute What it does data-liquid-hover Marks the box that holds the pictures: a figure, a grid of figures, a whole section. It takes no value: leave the value field empty. One canvas is laid over that box and every picture in it turns liquid, so mark the box around a grid rather than each picture. Put on a picture itself, it is read as the box around that picture. Without it on any element of the page, the embed marks the section it was pasted into. data-liquid-hover-image Marks one picture of the box, an Image. It takes no value. Without it on any picture, every picture of the box turns liquid; with it on some, only those do. [ Optional ] data-liquid-hover-state Written by the engine on the box: live while a surface moves, idle at rest, still under reduced motion, plain when there is no WebGL or no picture could be read. Never written by hand. [ Optional ] data-liquid-hover-live Written by the engine on each picture the canvas draws, which the stylesheet then makes clear. A picture without it is shown by the page itself. Never written by hand. [ Optional ] SettingsEvery setting is a custom attribute too, added the same way and on the same element. An attribute always wins over the defaults written in the code.Attribute Default What it is data-liquid-hover-tint #ffab1a Colour of the fringe an edge takes on the wave: that colour on one side of the edge, its opposite on the other. Options: any CSS colour, a hex, an rgb(), a name. data-liquid-hover-tint-amount 1 How much the fringe follows tint, 0 to 1. data-liquid-hover-strength 0.35 How far the picture is pushed at the heart of a full speed pass, in shares of the brush radius. data-liquid-hover-radius 0.22 Radius of the brush, in shares of the picture's width. data-liquid-hover-relax 0.92 Share of the wave kept from one frame to the next, at 60 frames a second; it sets how long the surface takes to lie flat. data-liquid-hover-split 0.14 Gap between the colour layers, in shares of the push. data-liquid-hover-velocity 0.6 Share of the gesture's speed in the push, 0 to 1.
Two files and their pictures (4), no build step.
It runs in the browser only. Load it with a script tag in plain HTML, in a
client-side script in Astro, never in the frontmatter. In React, call
LiquidHover.mount(ref.current) in useEffect and
destroy() in its cleanup.
-
Tune it, either way
Per element with an attribute, or once for the whole site by editingDEFAULTSat the top of the file.Attribute Default What it is Lower Higher data-liquid-hover-tint #ffab1a Colour of the fringe an edge takes on the wave: that colour on one side of the edge, its opposite on the other. Options: any CSS colour, a hex, an rgb(), a name. data-liquid-hover-tint-amount 1 How much the fringe follows tint, 0 to 1. At 0 the fringe is the plain red and blue of a lens, and tint does nothing At 1 the fringe is tint and its opposite, nothing else data-liquid-hover-strength 0.35 How far the picture is pushed at the heart of a full speed pass, in shares of the brush radius. A shiver, the picture barely moves Past 0.6 the picture folds over itself where the push is hardest data-liquid-hover-radius 0.22 Radius of the brush, in shares of the picture's width. A fine line drawn in the picture A push that moves the whole picture at once data-liquid-hover-relax 0.92 Share of the wave kept from one frame to the next, at 60 frames a second; it sets how long the surface takes to lie flat. At 0.85 the surface is flat again in half a second, stiff At 0.96 it sloshes for over two seconds data-liquid-hover-split 0.14 Gap between the colour layers, in shares of the push. At 0 no fringe at all, and tint does nothing Past 0.3 the layers come apart into three ghost pictures data-liquid-hover-velocity 0.6 Share of the gesture's speed in the push, 0 to 1. At 0 every pass pushes as hard, slow or fast At 1 a slow pass leaves almost nothing and only a flick moves the picture -
Drive it from JavaScript
When the pictures of the box change after load, when something other than the pointer should make a wave, or when the page waits for the pictures to be ready.const element = document.querySelector('[data-liquid-hover]'); element.addEventListener('liquidhover:ready', (e) => { console.log(e.detail); }); const liquid = LiquidHover.mount(element); // the box auto() mounted, or a new one liquid.pulse(200, 120); // a single wave at 200, 120 in the box liquid.refresh(); // after pictures were added to the box or removed element.addEventListener('liquidhover:ready', (e) => { console.log(e.detail.live, 'of', e.detail.pictures, 'pictures are liquid'); }); // When the box leaves the page: liquid.destroy(); // removes the canvas, frees its WebGL context, shows the pictures again
-
Fit it to your page
- Structure: one box marked
data-liquid-hover, with pictures in it, at any depth: a figure with one picture, a grid of figures, a whole section. The script lays one canvas over the box,data-liquid-hover-layer, draws every picture of the box on it and never takes a click, so links and pictures underneath keep their clicks and their menu. - One canvas per box, on purpose. A browser gives a page a few WebGL contexts and takes the oldest away past that. Mark the box that holds the grid, not each picture of it. The attribute on a picture itself is read as its parent box, so pictures marked side by side still share one canvas.
- Choosing the pictures: by default every
<img>of the box. Mark some withdata-liquid-hover-imageand only those turn liquid. - One setting for every box of the page, without opening the file: declare
window.LiquidHoverSettings = { strength: 0.5 }in a script placed beforeliquid-hover.js. The three levels, least specific first, areDEFAULTS, then that object, then adata-liquid-hover-*attribute on the box itself (data-liquid-hover-strength="0.5"), so one box can still differ from the page-wide setting. - Pictures from another site have to allow their pixels to be read: the server sends
Access-Control-Allow-Origin, and the script asks for the picture a second time withcrossoriginwhen the first reading is refused. The Webflow CDN allows it (checked oncdn.prod.website-files.comon 04/10/2026:access-control-allow-origin: *). A picture that is refused stays the plain HTML picture, without a word. - What the script writes:
data-liquid-hover-stateon the box,livewhile a surface moves,idleat rest,stillunder reduced motion,plainwhen there is no WebGL or no picture could be read; anddata-liquid-hover-liveon each picture the canvas draws, which the stylesheet turns clear. Nothing else: no inline size, no class. - **From JavaScript, beyond
mount,autoanddestroy**: the api carrieselement,settings,pictures(), the number of pictures the canvas draws,refresh(), which looks for the pictures of the box again after they changed,resize(),pulse(x, y), which drops a single wave at a point of the box,frames()andisRunning(), for a test that wants to see the loop sleep at rest, andfreeze()andunfreeze(), which hold the wave as a still picture, for a thumbnail. The box emitsliquidhover:readyonce its pictures have been read. - **
destroy()leaves the box as it was**: the canvas removed and its WebGL context given back at once, the listeners gone, the pictures visible again, the state attribute taken off, and the inlineposition: relativeremoved if the script set it. - Touch: a finger drag pushes the surface, through touch events, so the page still scrolls under the finger. A short touch that barely moves (under 10px and 350ms) is a tap, and drops a single wave. A mouse click does the same.
- **
prefers-reduced-motion**: no canvas at all, the pictures stay as the page set them. The state readsstill, and turning the preference off brings the effect back without a reload. - Without WebGL the pictures stay as the page set them, and the state reads
plain. - The words and the pictures of the markup are the preview's, and they ship as they are. Put the user's own in their place whenever they want.
- On Webflow, add the fonts first. Add Geist and Geist Mono to your site's fonts (Site settings, Fonts, Google Fonts) before pasting: the paste names them, and the Designer drops a font the site does not have.
- Structure: one box marked
-
Avoid the pitfalls
- The canvas draws over the box. It sits at
z-index: 1inside the box and is clear everywhere but on the pictures; anything laid over a picture, a badge, a caption, a play button, takesposition: relativeand a z-index over 1, or the liquid picture covers it. - A picture is drawn as a plain rectangle with rounded corners. The script reads the size, the
object-fit, theobject-positionand theborder-radiusof the<img>itself. A picture cropped by its parent (overflow: hiddenon a wrapper smaller than the picture), aclip-path, afilteror a CSStransformon the picture are not followed: put the radius and the fit on the picture. - A picture that scales on hover (
transform: scale(1.05)) keeps scaling under the canvas, unseen. Take the hover scale off the pictures of a liquid box. - Pictures that change after load (a slider, a filter, a CMS list that loads more) are not seen until
refresh()is called on the instance. - **Settings handed to
mount()after the page loaded are dropped.**auto()mounts on DOM ready, andmount()on a box that already carries the effect returns the existing one. A page-wide block goes inwindow.LiquidHoverSettings, whichauto()reads itself. - A box taller than a few screens costs its whole area. The canvas is one bitmap the size of the box, at up to twice the pixel density, brought down past nine million pixels. Mark the gallery, not the page.
- An animated GIF is drawn as its first frame, and a
<video>is not a picture: the effect is for still pictures.
- The canvas draws over the box. It sits at
One click on Copy the AI prompt copies everything an assistant needs to build this effect: the complete code, the markup, the dependency, every setting and every pitfall.
What it does
Liquid hover is a cursor animation for Webflow and vanilla JavaScript, with no library. Also called liquid image hover effect, liquid distortion hover effect or image ripple effect on hover.
A picture, or every picture of a grid, turns liquid under the pointer. The gesture pushes the surface of the picture along its own direction, the harder the faster it goes, and the push runs out as a ripple: the picture bends where the pointer passes, a little further for one colour layer than for the other, so every edge on the wave takes a fringe of colour, and the front of the wave catches a touch of light. Then the surface swings back once, softly, and lies flat again in about a second. A click or a tap drops a single wave that opens out from the point. Each picture has its own sheet of points on springs, moved on the processor and drawn by a short WebGL shader, on one canvas for the whole box: no library, no build. At rest the loop stops and the canvas shows the picture the page showed, at the same place and the same size. Without WebGL, under reduced motion, or when a picture comes from another site that does not allow its pixels to be read, the plain HTML picture stays, untouched.
Updated
Questions
Put the custom attribute data-liquid-hover on the box that holds the pictures, a grid or a whole section, and every <img> inside it turns liquid. Mark the box and not each picture: the script lays one canvas over it, because a browser only gives a page a few WebGL contexts. Settings go on that box, for instance data-liquid-hover-strength="0.5".
No. It is raw WebGL1 with the shader written in the engine, no library and no build. Each picture gets a grid of points on springs, 64 on its long side, moved on the processor and drawn in one draw call.
data-liquid-hover-strength sets how far the picture is pushed, 0.35 by default: lower is a shiver, and past 0.6 the picture folds over itself. data-liquid-hover-relax, 0.92 by default, sets how long the surface takes to lie flat: at 0.85 it is flat in half a second, at 0.96 it sloshes for over two seconds.
The server of that picture has to send Access-Control-Allow-Origin, or its pixels cannot be read and the plain HTML picture stays, with no warning. The Webflow CDN sends it. An animated GIF is drawn as its first frame and a <video> is not taken at all.
Yes. A finger drag pushes the surface through touch events, so the page still scrolls under the finger, and a short touch under 10px and 350ms drops a single wave. Under prefers-reduced-motion no canvas is made and the pictures stay as the page set them.