Scroll distortion
The pictures of a page bend with the scroll.
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 put in the marked box and draws its pictures: anything laid over a picture, a badge or a title, needsposition: relativeand az-indexover 1 to stay in front. A bent picture reaches past its own box for the time of the scroll, so the marked box needs some room around its pictures. 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-scroll-distortion Marks the box that holds the pictures: a gallery, a section, the wrapper of a whole page. It takes no value: leave the value field empty. One canvas is put in that box and every picture in it bends with the scroll, so mark the box around all the pictures rather than each picture, and one that leaves them some room above and below. 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-scroll-distortion-image Marks one picture of the box, an Image. It takes no value. Without it on any picture, every picture of the box bends; with it on some, only those do. [ Optional ] data-scroll-distortion-state Written by the engine on the box: live while a picture is bent, 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-scroll-distortion-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 ] data-scroll-distortion-layer Written by the engine on the canvas it adds to the box. Never written by hand, and removed by destroy(). [ 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-scroll-distortion-tint #ffab1a Colour of the fringe an edge takes while the picture moves: that colour on one side of the edge, its opposite on the other. Options: any CSS colour, a hex, an rgb(), a name. data-scroll-distortion-tint-amount 1 How much the fringe follows tint, 0 to 1. data-scroll-distortion-bend 0.1 Depth of the bow at full speed, in shares of the picture's width: how far the middle of the picture lags behind its two sides. data-scroll-distortion-stretch 0.08 Length the picture gains at full speed, in shares of its height: its trailing edge drags while its leading edge keeps its place. data-scroll-distortion-split 0.02 Gap between the colour layers at full speed, in shares of the picture's height. data-scroll-distortion-inertia 0.9 Share of the difference left after a frame, at 60 frames a second, between the speed a picture shows and the speed of the page; it sets how long the pictures take to pick the effect up and to lie flat again. data-scroll-distortion-speed 2400 Scroll speed that gives the full effect, in px a second.
Two files and their pictures (6), 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
ScrollDistortion.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-scroll-distortion-tint #ffab1a Colour of the fringe an edge takes while the picture moves: that colour on one side of the edge, its opposite on the other. Options: any CSS colour, a hex, an rgb(), a name. data-scroll-distortion-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-scroll-distortion-bend 0.1 Depth of the bow at full speed, in shares of the picture's width: how far the middle of the picture lags behind its two sides. At 0 the picture stays a rectangle; under 0 the bow turns over and the middle leads Past 0.25 the picture reads as a sheet of rubber, and tall pictures reach into their neighbours data-scroll-distortion-stretch 0.08 Length the picture gains at full speed, in shares of its height: its trailing edge drags while its leading edge keeps its place. At 0 the picture keeps its length Past 0.2 the tail covers whatever follows the picture, a caption for one data-scroll-distortion-split 0.02 Gap between the colour layers at full speed, in shares of the picture's height. At 0 no fringe at all, and tint does nothing Past 0.05 the layers come apart into three ghost pictures data-scroll-distortion-inertia 0.9 Share of the difference left after a frame, at 60 frames a second, between the speed a picture shows and the speed of the page; it sets how long the pictures take to pick the effect up and to lie flat again. At 0.8 the effect follows the scroll closely and is gone a third of a second after it stops At 0.96 the pictures are slow to bend and settle for close to two seconds data-scroll-distortion-speed 2400 Scroll speed that gives the full effect, in px a second. At 1200 a slow wheel already bends the pictures all the way At 5000 only a flick does, and ordinary reading leaves them almost flat -
Drive it from JavaScript
When the pictures of the box change after load, when something other than the scroll should bend them, a page transition for one, or when another element should follow the speed of the scroll.const element = document.querySelector('[data-scroll-distortion]'); element.addEventListener('scrolldistortion:ready', (e) => { console.log(e.detail); }); const bend = ScrollDistortion.mount(element); // the box auto() mounted, or a new one bend.push(0, -1); // every picture bent as by a full speed scroll down, then settling bend.speed(); // { x, y }, what the pictures show right now bend.refresh(); // after pictures were added to the box or removed element.addEventListener('scrolldistortion:ready', (e) => { console.log(e.detail.live, 'of', e.detail.pictures, 'pictures are on the canvas'); }); // When the box leaves the page: bend.destroy(); // removes the canvas, frees its WebGL context, shows the pictures again
-
Fit it to your page
- Structure: one box marked
data-scroll-distortion, with pictures in it, at any depth: a gallery, a section, the wrapper of the whole page. The script puts one canvas in the box,data-scroll-distortion-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 all the pictures, not each picture. 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-scroll-distortion-imageand only those bend. - No smooth scroll library is needed. The canvas is a child of the box and scrolls with the page like the pictures do, so the browser's own scroll never shows a picture and its copy apart. The easing of the effect is the component's own,
inertia. - With Lenis, or another smooth scroll, there is nothing to connect. The speed is read on the pictures, from where they are in the window each frame, whatever moved them. A library that moves the page with a CSS transform and sends no
scrollevent is picked up from the wheel or the finger that started it. - A scroller inside the page and a horizontal scroll work the same way: a picture that travels sideways bows sideways.
- One setting for every box of the page, without opening the file: declare
window.ScrollDistortionSettings = { bend: 0.2 }in a script placed beforescroll-distortion.js. The three levels, least specific first, areDEFAULTS, then that object, then adata-scroll-distortion-*attribute on the box itself (data-scroll-distortion-bend="0.2"), 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-scroll-distortion-stateon the box,livewhile a picture is bent,idleat rest,stillunder reduced motion,plainwhen there is no WebGL or no picture could be read; anddata-scroll-distortion-liveon each picture the canvas draws, which the stylesheet turns clear. On its own canvas, a size and a transform. Nothing else: no inline size on the box or the pictures, 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(),speed(), the speed the pictures show, across and down, in shares of the full effect,push(x, y), which hands every picture a speed as a scroll would,frames()andisRunning(), for a test that wants to see the loop sleep at rest, andfreeze()andunfreeze(), which hold the bend as a still picture, for a thumbnail. The box emitsscrolldistortion: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: the same effect, from the finger's scroll and from the glide that follows it. Nothing is added to the gesture and the page scrolls as it always did.
- **
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 title, a play button, takesposition: relativeand a z-index over 1, or the picture covers it. - A bent picture leaves its box. Its tail reaches past its trailing edge, by
stretchandbendtogether, and covers what follows it for the time of the scroll, a caption under it for one. Give the pictures some air, or lower the two settings. - The canvas ends where the box ends. A picture that sits against the edge of the marked box is cut there when it stretches. Mark a box with a margin around its pictures, a section rather than the row itself.
- 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 parallax that moves the pictures is read as speed like any other movement, which is usually what is wanted. A picture pinned in place,
position: stickyorfixed, does not move in the window and stays flat. - Pictures that change after load (a filter, a CMS list that loads more) are not seen until
refresh()is called on the instance. - Every picture of the box is loaded at once, lazy ones included, and kept as a texture at the size it shows: the canvas has to be able to draw a picture before it scrolls in. Meant for the tens of pictures of a page, not for an endless feed.
- **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.ScrollDistortionSettings, whichauto()reads itself. - 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
Scroll distortion is a scroll animation for Webflow and vanilla JavaScript, with no library. Also called webgl scroll distortion, image distortion on scroll or scroll velocity image effect.
The pictures of a page bend with the scroll. Each one is an ordinary <img>, kept where it is for the layout, the alt text and the search engines, and drawn a second time on one WebGL canvas: its middle lags behind its edges along a bow, its trailing edge drags, and its colour layers part, the more the faster the page goes. When the scroll stops the speed eases out and the pictures lie flat again, on the pictures of the page: same place, same size, one device pixel per pixel. The speed is read on the pictures themselves, frame by frame, so the window's own scroll, a scroller inside the page and a smooth scroll library all drive it the same way, and none is needed. The canvas is a child of the marked box, so it scrolls with the page instead of chasing it, and it is only as tall as the window and a margin, however long the page is. A short WebGL shader, no library, no build. 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-scroll-distortion on the box that holds the pictures, a section or the wrapper of the page, and every <img> inside it bends with the scroll. Mark the box and not each picture: the script puts one canvas in it, because a browser only gives a page a few WebGL contexts. Settings go on that box, for instance data-scroll-distortion-bend="0.2".
No. It is raw WebGL1 with the shader written in the engine, no library and no build. It needs no smooth scroll either: the canvas is a child of the marked box and scrolls with the page, so a picture and its copy never come apart. With Lenis already on the page there is nothing to connect, the speed is read on the pictures themselves.
Yes. Each picture stays in the page with its box, its alt text and its clicks, and is only made transparent while the canvas draws it at the same place and the same size. Without WebGL, under prefers-reduced-motion, or when a picture's server does not allow its pixels to be read, no copy is made and the plain picture shows.
data-scroll-distortion-bend is the depth of the bow, 0.1 of the picture's width by default, data-scroll-distortion-stretch the length the picture gains and data-scroll-distortion-split the gap between its colour layers. data-scroll-distortion-inertia, 0.9 by default, sets how long the pictures take to lie flat: at 0.8 a third of a second, at 0.96 close to two. data-scroll-distortion-speed is the scroll speed, in px a second, that gives the full effect.
Yes, from the finger's scroll and from the glide that follows it; nothing is added to the gesture. The canvas is only as tall as the screen and a margin, however long the page is.