Infinite grid
A wall of tiles that never ends, seen through a bulged lens, as if it were the inside of a sphere: the tiles in the middle are flat and large, those near the edges narrow and lean away into a shadow, and no edge of the wall is ever visible, in any direction, at any size of the box.
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 tutorialGive the wall a height: once mounted the tiles are placed by the script and no longer push the box open. The size of a tile is not a class here but thewidthandheightsettings, which the script hands to the stylesheet. And leave the transform of a tile to the engine, which writes it every frame. -
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-infinite-grid Marks the box that holds the tiles. It takes no value: leave the value field empty. That box is the wall: what the hand drags, what the lens is measured on, what the effect is mounted on and what carries the settings. It takes its height from the page. Without it on any box of the page, the embed marks the parent of every tile it finds (data-infinite-grid-tile). data-infinite-grid-tile Marks one tile, a direct child of the wall. It takes no value. A tile can be a link: a plain click still opens it, a drag never does. Without it on any tile, the wall takes its own children as the tiles. [ Optional ] data-infinite-grid-state Written by the engine on the wall once mounted: ready. Never written by hand; the stylesheet reads it to clip the wall, keep the gesture, take the tiles out of the flow and lay the shadow of the lens. [ Optional ] data-infinite-grid-dragging Written by the engine on the wall while a drag runs. Never written by hand; the stylesheet reads it for the closed hand. [ Optional ] data-infinite-grid-clone Written by the engine on the copies of the tiles it adds to fill the wall, each one hidden from screen readers and out of the Tab order. 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-infinite-grid-tint #0e0b09 Colour the wall falls into towards its corners, the ground of the page in the preview. Options: any CSS colour, best the colour behind the wall; taken out of this block, --infinite-grid-tint on the wall takes over. data-infinite-grid-shade 0.6 Strength of that shadow in the corners of the box, 0 to 1. data-infinite-grid-bulge 0.42 Curve of the lens, 0 to 0.8: how much the tiles shrink and lean away towards the edges. data-infinite-grid-swell 0.14 Bulge added while the wall runs fast, 0 to 0.3, gone again as it slows. data-infinite-grid-width clamp(170px, 21vw, 420px) Width of a tile, any CSS unit, as drawn in the middle of the box. data-infinite-grid-height clamp(124px, 15.3vw, 306px) Height of a tile, any CSS unit, as drawn in the middle of the box. data-infinite-grid-gap clamp(12px, 1.7vw, 32px) Space between two tiles, on both axes, any CSS unit. data-infinite-grid-stagger 0.5 How far every other column sits lower, as a share of one cell, 0 to 1. data-infinite-grid-friction 0.05 Share of its speed a thrown wall loses on each frame, 0 to 1. data-infinite-grid-drift 22 Speed of the wall when nobody touches it, in pixels per second. data-infinite-grid-angle 24 Direction of that drift, in degrees: at 0 the tiles run to the left, at 90 upwards. Options: any angle; 180 runs to the right, 270 downwards. data-infinite-grid-wheel 1 Whether the wheel and the trackpad move the wall, 0 or 1. data-infinite-grid-lift 1.04 Scale of the tile right under the pointer.
Two files and their pictures (12), 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
InfiniteGrid.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-infinite-grid-tint #0e0b09 Colour the wall falls into towards its corners, the ground of the page in the preview. Options: any CSS colour, best the colour behind the wall; taken out of this block, --infinite-grid-tint on the wall takes over. data-infinite-grid-shade 0.6 Strength of that shadow in the corners of the box, 0 to 1. At 0 no shadow, the tiles keep their light to the edge At 1 the corners are the tint itself and the wall reads as a deep bowl data-infinite-grid-bulge 0.42 Curve of the lens, 0 to 0.8: how much the tiles shrink and lean away towards the edges. At 0 a flat wall, a plain endless grid Past 0.6 a strong fisheye, many small tiles round the edges and more nodes on the page data-infinite-grid-swell 0.14 Bulge added while the wall runs fast, 0 to 0.3, gone again as it slows. At 0 the lens never moves A wall that visibly balloons on every throw data-infinite-grid-width clamp(170px, 21vw, 420px) Width of a tile, any CSS unit, as drawn in the middle of the box. Small tiles, many on screen, more nodes on the page A few large tiles, each one read on its own data-infinite-grid-height clamp(124px, 15.3vw, 306px) Height of a tile, any CSS unit, as drawn in the middle of the box. Wide landscape tiles Tall portrait tiles data-infinite-grid-gap clamp(12px, 1.7vw, 32px) Space between two tiles, on both axes, any CSS unit. At 0 the tiles touch and the wall is one surface A loose wall with a lot of ground between the tiles data-infinite-grid-stagger 0.5 How far every other column sits lower, as a share of one cell, 0 to 1. At 0 a plain grid, the rows straight At 0.5 a brick layout, each column half a tile lower than the last data-infinite-grid-friction 0.05 Share of its speed a thrown wall loses on each frame, 0 to 1. A wall that glides a long way before it settles A wall that settles almost where the hand left it data-infinite-grid-drift 22 Speed of the wall when nobody touches it, in pixels per second. At 0 the wall stands still at rest and its clock sleeps Past 60 the tiles are hard to read and to click as they pass data-infinite-grid-angle 24 Direction of that drift, in degrees: at 0 the tiles run to the left, at 90 upwards. Options: any angle; 180 runs to the right, 270 downwards. data-infinite-grid-wheel 1 Whether the wheel and the trackpad move the wall, 0 or 1. At 0 the wheel scrolls the page as usual, over the wall too At 1 the wall keeps the wheel, and the page does not scroll while the pointer is on it data-infinite-grid-lift 1.04 Scale of the tile right under the pointer. At 1 no lift; under 1 the tile sinks instead Past 1.1 the lifted tile covers its gap and touches its neighbours -
Drive it from JavaScript
When a button has to bring a tile to the middle, when the tiles change after load, or when the page reacts to a click on a tile that is not a link.const element = document.querySelector('[data-infinite-grid]'); element.addEventListener('infinitegrid:rest', (e) => { console.log(e.detail); }); const wall = InfiniteGrid.mount(element); // the box auto() mounted, or a new one wall.select(4); // throws the wall to rest on the fifth tile wall.moveBy(1, 0); // one tile to the right, as the arrow key does element.addEventListener('infinitegrid:click', (e) => { console.log(e.detail.index); // the tile of the markup that was clicked }); // When the wall leaves the page, or before its tiles change: InfiniteGrid.destroy(element); // removes the copies and gives back the markup as it was
-
Fit it to your page
- Structure: a box marked
data-infinite-grid, and inside it the tiles, its direct children, each markeddata-infinite-grid-tile. In the reference markup a tile is a link holding a picture and a caption line. Anything can go in a tile: a picture alone, a card with a title, a price, a tag. Twelve tiles is a good number; one is enough for the engine. - The wall takes its height from the page. Mounted, the tiles are placed by the script and no longer push the box open, so the box needs a height of its own: a full screen, a share of it, a fixed band. Whatever its shape, the wall fills it: the lens is measured on the diagonal of the box.
- Tiles without the mark: a box with no marked tile takes its own children as the tiles, scripts and styles left out.
- One setting for every wall of the page, without opening the file: declare
window.InfiniteGridSettings = { bulge: 0.6 }in a script placed beforeinfinite-grid.js. The three levels, least specific first, areDEFAULTS, then that object, then adata-infinite-grid-*attribute on the wall itself (data-infinite-grid-bulge="0.6"), so one wall can still differ from the page-wide setting. - The size of a tile is a setting here, not a class. The wall is laid out by the script, cell by cell, so the script has to know the cell:
width,heightandgapare read in pixels and handed to the stylesheet, which sizes every tile and every copy alike. That size is the one of a tile in the middle of the box; the lens draws the others smaller. What is inside a tile stays the page's own. - A click is still a click. A press that travels less than six pixels opens the tile's link, on a wall that drifts too. A drag never opens one, and a press on a wall thrown and still running stops it without opening the tile under it. For tiles that are not links, the wall emits
infinitegrid:clickwith the tile of the markup that was clicked, whether the click landed on it or on one of its copies. - What the script writes. On the wall:
data-infinite-grid-state="ready"once mounted,data-infinite-grid-draggingwhile a drag runs,tabindex="0"androle="group"when the markup gave none, and four custom properties,--infinite-grid-width,--infinite-grid-height,--infinite-grid-tintand--infinite-grid-shade. On each tile: atransform, amatrix3d. The copies carrydata-infinite-grid-clone. - **From JavaScript, beyond
mount,autoanddestroy**: the api carrieselement,settings,tiles(the tiles of the markup),position()andspeed()as{ x, y },phase()(rest, drift, hold, drag or glide),lens()(the bulge of the moment, swell included),nodes(),moveBy(columns, rows),select(index)which throws the wall so that tile comes to rest in the middle, andrefresh(). The wall emitsinfinitegrid:start{ element, x, y }when a drag begins,infinitegrid:rest{ element, x, y, column, row, index, tile }each time a throw, a wheel or an aimed move has run out, andinfinitegrid:click{ element, index, tile, node, original }on a real click of a tile. All three bubble. - **
destroy()leaves the wall as it was**: the copies removed, the listeners and the watches gone, the states, the custom properties and every transform taken off. - Keyboard: the wall takes the focus, and the arrow keys move it by one tile in their direction. Tab then reaches each tile of the markup in its order, and a tile that takes the focus is brought to the middle of the box, where the drift waits for as long as it keeps the focus. The copies never take the focus and are hidden from a screen reader, which reads each tile once. Give the wall an
aria-label. - Touch: a finger drags and throws the wall on both axes, and a tap opens a tile. There is no pointer to lift a tile, so nothing lifts.
- **
prefers-reduced-motion**: the wall keeps its bulge, which is a layout and not a motion, follows the hand and stops where it is let go. Nothing drifts, swells, glides or lifts; the arrows, Tab andselect()put the wall in place at once. - Out of the viewport the wall stops. Its clock sleeps while the box is off screen and starts again when it comes back, so a drifting wall far down a page costs nothing.
- The words of the markup are the preview's, and they ship as they are. Put the user's own tiles 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: a box marked
-
Avoid the pitfalls
- A wall with no height shows nothing. The script says so once in the console. A percentage height needs a parent that has one.
- Without the stylesheet the tiles are not placed. Taking them out of the flow, sizing them and anchoring the matrix on their top left corner is the job of
infinite-grid.css; the script only writes transforms and custom properties. - A finger on the wall never scrolls the page. The wall keeps both axes of a swipe (
touch-action: none), or a drag would fight the page scroll. On a phone, a wall as tall as the screen leaves nothing to scroll the page with: keep it shorter than the screen, or make it the whole page as the preview does. - **
wheelat 1 keeps the wheel too.** While the pointer is on the wall the page does not scroll. Setdata-infinite-grid-wheel="0"on a wall that sits in the middle of a long page. - The image addresses of snippet.html are relative,
images/…, so they point at theimages/folder next to the page holding the markup, which is where the zip puts them. 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 wall that already carries the effect returns the existing one. A page-wide block goes inwindow.InfiniteGridSettings, whichauto()reads itself. - The copies are clones of the markup. A listener added to a tile with
addEventListeneris not on its copies: listen on the wall forinfinitegrid:click, or forclickand readevent.target.closest('[data-infinite-grid-tile]'). Anidinside a tile is taken off its copies. - Do not put a transform, a transition or a transform-origin on a tile. The script writes the tile's matrix every frame, from its top left corner; a transition on it turns the drag into rubber. Style what is inside the tile.
- The edges of a tile stay straight. Each tile is a flat rectangle laid on four points of the lens: the curve is read across the tiles, not along the side of one. Tiles wider than a third of the box start to show it; keep them smaller, or lower
bulge. - Text in a tile is drawn through the matrix. Away from the middle it is as sharp as a rotated layer is, which is fine for a caption and wrong for a paragraph. Keep the words of a tile short.
- **
shadedarkens the gaps too.** It is one layer over the whole wall, in thetint: on a ground of another colour, settintto that colour, or the corners turn to mud. - **A strong
bulgecosts nodes.** The lens pulls more of the wall into the box, 1 / sqrt(1 - bulge) times further at the corners: about 1.3 times at the default, 2.2 times at 0.8, where the page holds near twice the nodes. - Tiles added or removed after mount are not seen. Destroy and mount again;
refresh()only reads the sizes again.
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
Infinite grid is a cursor animation for Webflow and vanilla JavaScript, with no library. Also called spherical infinite gallery, fisheye infinite grid or curved infinite gallery.
A wall of tiles that never ends, seen through a bulged lens, as if it were the inside of a sphere: the tiles in the middle are flat and large, those near the edges narrow and lean away into a shadow, and no edge of the wall is ever visible, in any direction, at any size of the box. The wall drifts slowly on its own. The hand grabs it anywhere, with the mouse, a pen or a finger, drags it and throws it; it runs on with the speed it was given, the lens swelling a little while it runs fast, then settles back into its drift. The wheel and the trackpad move it too. The tiles are the page's own elements, pictures or cards with a title, repeated as the wall needs and coming round again on both axes; each one stays a real element, so a click on a tile, never a drag, opens its link or fires an event. The arrow keys move the wall by one tile and Tab brings each tile to the middle. No library and no WebGL: one projective matrix per tile and per frame, on a few dozen nodes. Under reduced motion the wall keeps its bulge and follows the hand, and nothing drifts, swells or runs on. Pattern type infinite grid, spherical gallery, fisheye wall.
Updated
Questions
Put the custom attribute data-infinite-grid on a box and data-infinite-grid-tile on each of its direct children, pictures or cards; twelve tiles is a good number. Give the box a height of its own, a full screen or a fixed band, because the script places the tiles and they no longer push it open. Settings go on the box, for instance data-infinite-grid-bulge="0.6".
No, it loads no library and draws no canvas. Each tile is a real element stretched onto four points of the lens by one matrix3d per frame, on a few dozen nodes: about fifty at 1440 by 900 with the defaults, however far the wall is thrown.
Yes. A press that travels less than six pixels opens the tile's link, on a wall that drifts too, and a drag never opens one. For tiles that are not links, listen on the wall for infinitegrid:click, which names the tile of the markup whether the click landed on it or on one of its copies.
data-infinite-grid-bulge is the curve of the lens, 0.42 by default: at 0 the wall is a plain endless grid, past 0.6 it is a strong fisheye with more nodes on the page. data-infinite-grid-drift is the idle speed in pixels per second, 22 by default, and 0 keeps the wall still at rest. data-infinite-grid-swell is the bulge added while a throw runs fast.
The wall keeps the wheel and the trackpad while the pointer is on it. Set data-infinite-grid-wheel="0" on a wall that sits in the middle of a long page and the wheel scrolls the page as usual. A finger always drags the wall on both axes, so on a phone keep it shorter than the screen, or make it the whole page.