Pixel swap
The cursor opens a trail of pixels onto a second image, and each square closes again on its own a moment later.
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 tutorial -
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 attributeSelect 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-pixel-swap Marks the element the effect plays on. It takes no value: leave the value field empty. 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-accent #ffab1a Flash colour on a square that has just turned. Options: any CSS colour; taken out of this block, --pixel-swap-accent on the box then the page's --accent take over. data-cell 15 Side of one square the trail turns over, kept in px so the grain is the same on every screen. data-radius 74 Half width of the brush that opens the second image, any CSS unit. data-edge 0.42 How ragged the rim of that brush is, 0 to 1. data-hold 0.9 How long a square keeps showing the second image once the cursor has left it, in seconds. data-hold-jitter 0.55 Spread of that hold from one square to the next, 0 to 1. data-front 0.3 How long a square that has just turned keeps its accent flash, in seconds. data-front-intensity 0 Strength of that flash, 0 to 1. data-flat 1 How solid the lit squares are, 0 to 1.
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
PixelSwap.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-accent #ffab1a Flash colour on a square that has just turned. Options: any CSS colour; taken out of this block, --pixel-swap-accent on the box then the page's --accent take over. data-cell 15 Side of one square the trail turns over, kept in px so the grain is the same on every screen. A fine grain, many more squares to redraw Chunky blocks, cheaper to draw data-radius 74 Half width of the brush that opens the second image, any CSS unit. A thin wake right under the cursor A wide patch that opens most of the box at once data-edge 0.42 How ragged the rim of that brush is, 0 to 1. At 0 a clean disc, which reads as a mask rather than as pixels A broken rim, squares scattered ahead of the stroke data-hold 0.9 How long a square keeps showing the second image once the cursor has left it, in seconds. The trail closes right behind the cursor The second image stays open across the whole box data-hold-jitter 0.55 Spread of that hold from one square to the next, 0 to 1. At 0 the trail shuts in one block, which reads as a glitch A ragged close, squares dropping out one by one data-front 0.3 How long a square that has just turned keeps its accent flash, in seconds. Barely a spark on the rim The whole opened patch stays tinted data-front-intensity 0 Strength of that flash, 0 to 1. At 0, the default, no flash: the second image shows at once. Just above, the accent is only a hint Past 0.5 a whole disc lights at once and the accent repaints the picture data-flat 1 How solid the lit squares are, 0 to 1. A light that fades behind the front At 1 real squares of flat colour, which then turn into the picture -
Drive it from JavaScript
When the two sources are not <img> elements, a canvas for instance, or when you want to react to what the component does.const element = document.querySelector('[data-pixel-swap]'); element.addEventListener('pixelswap:open', (e) => { console.log(e.detail); }); const swap = PixelSwap.mount(element); // the box auto() mounted, or a new one // Two sources that are not <img> elements: here, two canvases painted in code. function paint(color) { const canvas = document.createElement('canvas'); canvas.width = canvas.height = 600; const context = canvas.getContext('2d'); context.fillStyle = color; context.fillRect(0, 0, 600, 600); return canvas; } swap.setSources([paint('#f4ece0'), paint('#15100c')]); // When the box leaves the page: swap.destroy(); // removes the canvas and the listeners
-
Fit it to your page
- One setting for every box of the page, without opening the file: declare
window.PixelSwapSettings = { hold: 0.8 }in a script placed beforepixel-swap.js. The three levels, least specific first, areDEFAULTS, then that object, then adata-*attribute on the box itself, so one box can still differ from the page-wide setting. - The size and the ratio of the box are not a setting. The box is a real element of the page, so it is styled like any other element: a class in the Webflow Designer, a rule in a stylesheet. The engine measures it, fills it, and follows it when the window changes.
- Minimal structure: one box, two
<img>inside it. The first is shown, the second is taken out of the layout by the stylesheet and revealed square by square. In Webflow they are two Image elements the user swaps in the Navigator. The script adds a<canvas>of its own (classpixel-swap-canvas,position: absolute; inset: 0; pointer-events: none, written inline as well as in the stylesheet, so the canvas is out of the flow before the stylesheet arrives), with noz-index, so anything the page lays over the box still paints on top. - The effect works on one image box, and nothing else. The canvas fills that box edge to edge, and the squares are that box. There is nothing to frame and nothing to wrap the picture in. If you see squares turning in a margin around your subject, that margin is part of your image, not part of the component.
- The squares never look at what they are showing. A square only knows which of the two sources it is drawing, so a photograph, a poster, a product shot and a screenshot all behave identically. Different box ratios in the same page cost nothing.
- What appears in one source and not the other is free. A window lit in one picture and dark in the other, a poster whose ground changes: nothing has to line up except the framing.
- Both sources must share their framing. The effect swaps squares at the same coordinates, so a different crop between the two reads as a glitch rather than as a swap. Shoot or export them on the same ground. Sizes do not have to match: every source is redrawn once into a canvas the size of the box, centred and cropped the way
object-fit: coverwould, which is also what the visible<img>is told to do. - Sources that are not images:
PixelSwap.mount(el, { sources: [a, b] })takes canvases, which is how a page hands over pictures it paints itself. On a box already mounted, the same call, orsetSources([a, b]), hands them over. Sources handed in win over anything found in the markup. - **From JavaScript, beyond
mount,setSourcesanddestroy**: the api also carrieselement,settings,resize(),render()to draw one frame by hand, andisOpen(). The box emitspixelswap:closewhen the last square has turned back, as well aspixelswap:openwhen the first one turns, with{ cells, count }asevent.detail. freeze()holds the box as a still picture: the squares that turned keep the second image instead of closing on their own clock, the loop stops as soon as the last flash is out, and nothing redraws until something asks.unfreeze()hands the squares back to their clock and they close at once.PixelSwap.freeze(el)andPixelSwap.unfreeze(el)do the same from the element. It exists so a still image of the effect can be taken, a thumbnail that has to show the swap standing still: a page never calls it.- Touch: there is no hover to follow, so a tap turns the whole box over and a second tap brings it back. A trail that needed a pointer path would show nothing at all on a phone.
- **
prefers-reduced-motion**: the picture still swaps, it just stops crawling. Hovering the box turns the whole of it over, leaving brings it back. - The words of the markup are the preview's, and they ship as they are: pasted, the page reads the way the preview did. Put the user's own content in their place whenever they want, in the page's language.
- The staging script at the end of the markup ships with the words: it blinks the caret, lights the frame of an open box, names the stock underneath in its caption and writes the status line. It reads
data-underon a box,data-nameanddata-codein its caption,data-readouton the status line anddata-careton the caret, and listens topixelswap:openandpixelswap:closeonly. The swap needs none of it. The frame and the lit code are drawn by the Embed ondata-pixel-swap-boxanddata-pixel-swap-code, never on a class, which Webflow renames when it is pasted twice on one site (AB-116). - 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.
- One setting for every box of the page, without opening the file: declare
-
Avoid the pitfalls
- Without the stylesheet, the second picture shows. The engine keeps its own canvas out of the flow, but taking the second
<img>out of the layout and cropping the first are the job ofpixel-swap.css: load the script alone and both pictures stack in the box at their natural size. Link the stylesheet, or style the two images yourself. - The image addresses of the markup are relative,
images/…, so they point at theimages/folder next to the page holding the markup. Moved into another page or a framework route, they have to be pointed at wherever your pictures are served from. - **Settings handed to
mount()after the page loaded are dropped.**auto()mounts on DOM ready, andmount()on a box that already carries a swap returns the existing one, taking only its sources. A page-wide block therefore goes inwindow.PixelSwapSettings, whichauto()reads itself; handed to a latermount()call it is silently dropped, and the settings block ends up looking decorative. - An image that never loads leaves the box with one source, and then nothing happens: no error, no warning, nothing to reveal. The engine gives up on any single image after 8 s, so a dead URL shows as a box that never swaps. Check both addresses first.
- **The accent goes to a canvas, which cannot parse
color-mix().** A colour written that way, indata-accent, in--pixel-swap-accentor in the page's--accent, paints black without a word. Give it a plain colour, a hex or anrgb(). - There is no flash by default.
frontIntensityis 0, soaccent,frontandflatdo nothing until it is raised. At 1 withflat1, the squares that open are plain accent and ease into the picture overfront. For a light that only hints at the accent, lowerflatto 0 and setfrontIntensityto about 0.4. No still image will tell you whether you got it right: this one has to be judged with a mouse in hand. - Never read a pixel back from the canvas. A picture served from the Webflow CDN, or from any other origin without CORS, taints the canvas and makes
getImageDatathrow. A test that wants to know whether a trail opened countspixelswap:openinstead. - A caption cannot follow the coverage. With a trail, the share of the picture showing through never settles, so a label driven by it flickers between two names. The demo's caption follows
pixelswap:openandpixelswap:closeinstead.
- Without the stylesheet, the second picture shows. The engine keeps its own canvas out of the flow, but taking the second
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
Pixel swap is a cursor animation for Webflow and vanilla JavaScript, with no library. Also called pixel image swap on hover, image change on hover or pixelate image on hover.
The cursor opens a trail of pixels onto a second image, and each square closes again on its own a moment later. Nothing has to be uncovered: you see what is underneath where you pass, and the picture comes back behind you. Squares open straight onto the second image and close one by one, so the trail dissolves in pixels; an accent flash on the squares that open is a setting, off by default. The effect works on one image box and fills it edge to edge, so a product shot shows its second colourway, a landscape shows the same view after dark, a poster shows its other side. In the reference demo the three boxes hold two photographs each, a small vase and a single stem and then its other colourway, and the squares never look at what they are showing, so any pair of pictures goes through the same code. Each box keeps two off-screen sources and one visible canvas, one bit per square, and only the squares that changed are ever redrawn.
Updated
Questions
Paste the component, then replace the two Image elements inside each box in the Navigator: the first is the picture shown, the second is the one the cursor opens onto. The settings are custom attributes on the box that carries data-pixel-swap, for instance data-hold="0.8". The size and ratio of the box come from its class, like any other element.
No. It uses no library at all. Each box keeps two off-screen sources and one visible canvas, and only the squares that changed are redrawn.
Set data-hold on the box, in seconds. The default is 0.9. A lower value closes the trail right behind the cursor, a higher one keeps the second image open across the whole box.
No, the sizes do not have to match: every source is redrawn once to the size of the box, centred and cropped like object-fit: cover. The framing does have to match, because squares are swapped at the same coordinates and a different crop reads as a glitch.
A tap turns the whole box over to the second image, and a second tap brings the first one back. The same full swap replaces the trail under prefers-reduced-motion: hovering turns the whole box over and leaving brings it back.