Focus grid
A grid of pictures, written as plain markup: a box, one child per cell, a picture in each, a caption if you want one, any number of cells and pictures of any proportions.
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 box a grid of its own:display: grid, its columns and its gaps, at every breakpoint you want, and a height, or cells that give it one. The Embed takes the tracks over from there and reads four variables,--focus-grid-accent(the keyboard ring),--focus-grid-dim(how far the pictures out of focus fall back),--focus-grid-scrim(the shade under a caption) and--focus-grid-fade: set them on the box in a rule of your own. Leave the transform of a picture to the engine. -
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-focus-grid Marks the box whose direct children are the cells. It takes no value: leave the value field empty. That box is the grid: it must be a grid in the Designer, with its columns and gaps, and it keeps the height the page gave it. The settings below go on the same element. data-focus-grid-caption On the caption of a cell. It takes no value. A marked caption is shown only on the cell in focus, rising from its foot; it stays in the page for screen readers. A figcaption needs no mark, and a cell with no caption is fine. [ Optional ] data-focus-grid-picture On the picture of a cell, only when it is not the first image or video in it. It takes no value. Without it the engine takes the first img or video of each cell. [ Optional ] data-focus-grid-state Written by the engine on the box: ready once mounted, measure for the instant it reads the page again. The cell in focus carries data-focus-grid-active, the box data-focus-grid-lit while a cell is in focus, and every cell data-focus-grid-cell. Never written by hand; the stylesheet reads them, and so can a rule of yours. [ 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-focus-grid-start 0 The cell in focus when nobody is on the grid, counted from 1: the grid opens on it and comes back to it when the pointer leaves. data-focus-grid-grow 2.2 Size of the column and of the row of the cell in focus, against a track at rest. data-focus-grid-give 0.3 How much the tracks right next to the focus shrink, 0 to 1; the next ones give half of that, then a third. data-focus-grid-reveal 0.6 How still a picture stays while its cell changes size, 0 to 1. data-focus-grid-settle 1.2 Time the grid takes to come to rest after a change of focus, in seconds. data-focus-grid-bounce 0.3 Overshoot of the springs, 0 to 1: how far the tracks go past their place before coming back.
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
FocusGrid.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-focus-grid-start 0 The cell in focus when nobody is on the grid, counted from 1: the grid opens on it and comes back to it when the pointer leaves. At 0 no cell is in focus at rest and the grid comes back to even tracks Options: the number of any cell; past the last one it counts as 0 data-focus-grid-grow 2.2 Size of the column and of the row of the cell in focus, against a track at rest. At 1 nothing grows and only the neighbours step back; under 1.6 the move is discreet Past 3 the cell in focus takes most of the grid and the others become thin strips data-focus-grid-give 0.3 How much the tracks right next to the focus shrink, 0 to 1; the next ones give half of that, then a third. At 0 every other track gives the same, the grid simply shares what is left The neighbours are squeezed hard and the far side of the grid hardly moves; past 0.6 they become slivers data-focus-grid-reveal 0.6 How still a picture stays while its cell changes size, 0 to 1. At 0 the picture grows and shrinks with its cell, as object-fit: cover would At 1 the picture never changes size: the cell opens on it like a window, and a cell at rest shows only the middle of its picture data-focus-grid-settle 1.2 Time the grid takes to come to rest after a change of focus, in seconds. Snappier; under 0.6 the move stops reading as matter; at 0 the grid changes at once Slower and heavier; past 2 the grid feels late on the pointer data-focus-grid-bounce 0.3 Overshoot of the springs, 0 to 1: how far the tracks go past their place before coming back. At 0 the tracks arrive without going past Past 0.5 the grid wobbles several times before it rests -
Drive it from JavaScript
When a script brings a cell into focus, a list beside the grid for one, or follows which cell is in focus.const element = document.querySelector('[data-focus-grid]'); element.addEventListener('focusgrid:rest', (e) => { console.log(e.detail); }); const grid = FocusGrid.mount(element); // the box auto() mounted, or a new one grid.select(4); // brings the fifth cell into focus grid.release(); // back to the resting cell element.addEventListener('focusgrid:focus', (event) => { console.log(event.detail.index, event.detail.by); }); // When the grid leaves the page, or before its cells change: FocusGrid.destroy(element); // gives the markup back as it was
-
Fit it to your page
- Structure: a box marked
data-focus-grid, and its direct children, one per cell. A cell holds one picture, animgor avideo, and whatever else you want: a caption, markeddata-focus-grid-captionor written as afigcaption, is shown only on the cell in focus. In the reference markup a cell is a link, so the whole cell opens a page. - Any number of cells, from two up; six to twenty-four read best. A last row that is short is closed by its last cell, which runs to the edge of the grid, so the grid is a full rectangle at any count.
- Pictures of any proportions. Each one is centred in its cell and covers it at every moment; none is ever stretched. Give every
imgitswidthandheightattributes, so the engine knows its proportions before the file has loaded. - The columns and the gaps are the page's own CSS. Give the box
display: grid, itsgrid-template-columnsand its gaps, with any breakpoint you like: the engine reads how many columns the page made and takes the tracks over from there, and reads them again when the box changes size. In Webflow, set the grid in the Designer as usual. - The height is the page's own too. The engine keeps the height the box had before it mounted: a height you gave it, or the one its cells gave it through an
aspect-ratio. The grid never changes height when a cell takes the focus, so nothing under it moves. - A cell at rest.
data-focus-grid-start="6"keeps the sixth cell in focus when nobody is on the grid: the page opens on a composition, with one large cell and its caption. Without it the grid rests on even tracks and every caption is hidden until a cell is pointed at.
The keyboard
Tab goes from cell to cell in the order of the markup, and the cell that has the focus takes the room, with a ring in the accent. The four arrows move across the grid, Home and End go to the first and the last cell, Enter opens the link, and Escape lets the grid go back to rest without moving the focus.
On a touch screen
Nothing hovers under a finger, so the finger has its own two gestures. A tap brings a cell into focus, and its caption says it opens; a tap on the cell already in focus opens its link. A finger that slides sideways across the grid carries the focus with it, cell after cell, and the last one stays in focus when the finger lifts. A slide up or down scrolls the page as usual, and a tap outside the grid sends it back to rest.
The colours and the times
Four custom properties, set at zero specificity on the box. Give them your own values on the box or on anything above it:
--focus-grid-accent: the ring of the keyboard focus.--focus-grid-dim: the opacity of the pictures out of focus, 0.46 in the preview. They fall back towards the background colour of their cell;1leaves them as they are.--focus-grid-scrim: the colour of the shade a caption sits on, which rises from the foot of the cell.transparentfor none.--focus-grid-fade: the time of the dimming and of the caption that rises, 0.9s in the preview.
The gap, the radius of a cell, its background and the type of the caption are the page's own classes.
From JavaScript
FocusGrid.mount(element, overrides)returns the instance of a box, mounting it if it was not:{ element, settings, cells(), index(), columns(), select(index, options), release(options), refresh(), destroy() }.selecttakes a number counted from 0, or a cell, and{ instant: true }to change with no motion;-1puts no cell in focus.FocusGrid.auto(root)mounts every marked box insideroot, and runs once on its own;FocusGrid.select(element, index)andFocusGrid.destroy(element)are the instance methods from the element.- Events, all bubbling from the box:
focusgrid:ready;focusgrid:focusas soon as the focus changes, with{ index, previous, cell, by }indetail,bybeingpointer,keyboard,touch,startorapi;focusgrid:restonce the springs have come to rest. - The state is in the markup:
data-focus-grid-state="ready"on the box,data-focus-grid-liton it while a cell is in focus, anddata-focus-grid-activeon that cell. A rule of yours can read them. - **Under
prefers-reduced-motion: reduce** the grid changes at once, with no spring, and the caption appears without rising. - Without JavaScript the box is the grid the page drew, every cell a link and every caption in sight.
- The pictures and the words of the demo are the demo's: on a real page every picture, name and link is the user's own, or asked for.
- Structure: a box marked
-
Avoid the pitfalls
- The attribute goes on the box that directly holds the cells, never on a section around it. With a Webflow Collection List, that is the Collection List itself, whose items are the cells.
- The box needs a height before the engine mounts: one of its own, or cells with an
aspect-ratioor a height. A box whose cells have no size at all has no height to keep. - The box must be a grid. On a box that is not
display: gridthe engine counts the columns itself and says so in the console. - Leave the size of a cell to its tracks. Once mounted, a cell takes the size of its column and row; a
width, aheightor anaspect-ratioon the cell is set aside by the stylesheet. - **Do not set a transform or an
object-positionon a picture**: the engine holds it by its middle and writes its scale at every frame. - **Without
focus-grid.cssnothing gives way**, and the engine says so in the console. - A cell that holds several links opens none of them on the second tap by itself: each link is tapped as usual once the cell is in focus, the first tap only brings the cell forward.
- Cells added or removed after the mount are not part of the grid: call
destroy()thenmount(). In React or any framework that owns the DOM, mount in an effect and callFocusGrid.destroy(element)in its cleanup. - Large pictures cost memory, not speed: each one is drawn once at the size of its cell in focus. A file 1400 pixels wide is enough for a cell that takes half a wide screen.
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
Focus grid is a cursor animation for Webflow and vanilla JavaScript, with no library. Also called expanding image grid on hover, grid hover expand effect or image grid hover effect.
A grid of pictures, written as plain markup: a box, one child per cell, a picture in each, a caption if you want one, any number of cells and pictures of any proportions. The cell under the pointer takes room, and it takes it from the whole grid: its column widens from top to bottom, its row grows taller from side to side, the nearest tracks give way the most and the far ones hardly move, so the grid stays one full rectangle with the same gaps and nothing ever covers anything. Every track runs on a damped spring, the far ones a little late, which reads as one slow move of matter with a soft overshoot at the end. A picture is never stretched: it is held by its middle at the size of its cell in focus, so a cell that opens shows more of the picture and a cell that closes crops it. The pictures out of focus fall back and the caption of the cell in focus rises from its foot. Each cell stays a real link. The keyboard does what the pointer does: Tab from cell to cell, the arrows across the grid, Enter to open, Escape to let go. On a touch screen, where nothing hovers, a tap brings a cell into focus and a second tap opens it, and a finger that slides sideways carries the focus from cell to cell while the page still scrolls up and down. The number of columns is the page's own CSS, breakpoints included; a short last row is closed by its last cell. Without JavaScript the grid is a plain grid with every caption in sight. Reduced motion respected. No library. Pattern type expanding image grid, hover gallery, focus gallery.
Updated
Questions
Put the custom attribute data-focus-grid on the grid whose direct children are the cells, a Collection List for one, and paste the Embed. The grid keeps the columns and gaps you set in the Designer; the cell under the pointer makes its whole column and row grow, and the others give way.
No, it loads nothing. One damped spring per column and per row writes the tracks of a CSS grid as fr shares at every frame, so the grid stays a full rectangle with the same gaps and no cell ever covers another.
Never. Each picture is drawn once at the size of its cell in focus and held by its middle, so a cell that opens shows more of the picture and a cell that closes crops it. data-focus-grid-reveal goes from 0, the picture follows its cell as a cover would, to 1, the picture does not move at all.
A tap brings a cell into focus and shows its caption, and a tap on the cell already in focus opens its link. A finger that slides sideways carries the focus from cell to cell, while a slide up or down still scrolls the page.
data-focus-grid-start="6" keeps the sixth cell in focus whenever nobody is on the grid, so the page opens on a composition. data-focus-grid-grow is the size of the focused column and row against the others, 2.2 by default, and data-focus-grid-settle the time the grid takes to come to rest, 1.2 seconds.