X-ray lens
A rectangular window follows the pointer and, inside it, the page shows what is under it.
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 -
Tune it
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 section 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-xray-lens Marks the section the effect plays on. It takes no value: leave the value field empty. data-xray-lens-top Marks the layer on top, the page as it reads normally. The engine uses it to find the layer underneath, the block that follows it. [ Optional ] data-xray-lens-under Marks the layer the window reveals. Without it the engine takes the block right after the one marked data-xray-lens-top, and with neither mark it warns in the console and does not mount. [ Optional ] SettingsEvery setting is a custom attribute too, added the same way and on the same section. An attribute always wins over the defaults written in the code.Attribute Default What it is data-width clamp(260px, 34vw, 470px) Width of the window the lens opens, any CSS unit. data-height clamp(150px, 20vw, 280px) Height of that window, any CSS unit. data-corner 0 Corner radius of the window and of the frame, any CSS unit. data-lag 0.16 How far the window trails behind the pointer, in seconds. data-readout empty One line under the frame, naming what the window opens on. Options: any short label, or empty for no line at all. data-touch follow What a finger does on a touch screen. Options: follow, the window opens under the finger and closes when it lifts; off, a touch screen gets the page alone.
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
XrayLens.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-width clamp(260px, 34vw, 470px) Width of the window the lens opens, any CSS unit. A peephole that cuts every line of text it crosses The layer underneath stops being a window and takes over the page data-height clamp(150px, 20vw, 280px) Height of that window, any CSS unit. One or two lines at a time, the shape reads as a scanner A squarer window, which cuts twice as many lines in half for the same area data-corner 0 Corner radius of the window and of the frame, any CSS unit. Square corners, flush with the brackets Rounded corners, and the brackets stand off the edge data-lag 0.16 How far the window trails behind the pointer, in seconds. Glued to the pointer, no trail at all It drifts in well after the pointer has stopped data-readout empty One line under the frame, naming what the window opens on. Options: any short label, or empty for no line at all. data-touch follow What a finger does on a touch screen. Options: follow, the window opens under the finger and closes when it lifts; off, a touch screen gets the page alone. -
Drive it from JavaScript
When code rather than the pointer has to open the window, a guided tour for instance, or when the page reacts to it.const element = document.querySelector('[data-xray-lens]'); element.addEventListener('xraylens:enter', (e) => { console.log(e.detail); }); const lens = XrayLens.mount(element); // the lens auto() mounted, or a new one lens.moveTo(innerWidth / 2, innerHeight / 2); // opens the window on a point of the viewport lens.close(); // When the section leaves the page: lens.destroy(); // removes the frame and the clip, gives the layer back its attributes; the pointer listeners go with the last lens
-
Fit it to your page
- One setting for every lens of the page, without opening the file: declare
window.XrayLensSettings = { lag: 0.1 }in a script placed beforexray-lens.js. The three levels, least specific first, areDEFAULTS, then that object, then adata-*attribute on the block itself, so one section can still differ from the page-wide setting. - The look is CSS, not settings. The ground of the layer underneath, its ruling, and the colours of the frame are custom properties set on the page, listed at the bottom of
xray-lens.css, and every one of them is proposed from a:where()rule that carries no specificity: a Webflow class on the layer wins over it with no!importantanywhere. The ground of the sheet underneath is--xray-lens-under-bg, the ground of the blocks sitting on it--xray-lens-under-panel, the ruling pitch and weight--xray-lens-gridand--xray-lens-grid-line, the frame, brackets, crosshair and readout--xray-lens-frame,--xray-lens-tick,--xray-lens-crossand--xray-lens-label, the length of a corner bracket--xray-lens-bracket. The frame of the lens takes the page's own--accent, amber#ffab1awithout one. - In the Designer the two layers are two sibling blocks. Build the first one as you would build any section, duplicate it, then rewrite the words. Duplicating is the point: the two layers land on the same boxes because they are the same boxes, with the same classes.
data-xray-lensgoes on the block that holds both,data-xray-lens-topanddata-xray-lens-underon the two inside it. Without the second attribute the engine takes the block that follows the top one, so a layer that lost its mark still works. - The layer underneath is yours to write. Anything that can be stacked works: a second version of the copy, a photograph, a wireframe, a before state, a translation.
- Write the second layer to the length of the first. This is what makes the effect read as one page with two skins instead of two documents sitting on top of each other, and it is the single rule that decides whether the component looks finished. Two halves, both cheap: give both layers the exact same typography, never a different face or a smaller size for the one underneath, and write each block to roughly the character count of the block it hides, so the lines break in the same places. In the reference version the heading underneath is 32 characters against 34 above and breaks on the same word, the lede is 183 against 181 and fills the same three lines, and the panel on the right reuses the same block with a different skin rather than a new one.
- Give the ground itself a tell. Two layers that share the page's exact background read as one page whose words keep changing. The proposed ground warms the page by two percent of accent and rules it with a 26 px grid: enough to say "another surface", not enough to compete with the text. The grid is anchored to the layer, never to the lens, so the window slides over a surface that stays put; a texture pinned to the cursor looks like a spotlight instead.
- The engine marks the layer underneath
aria-hidden="true"and the CSS makes itpointer-events: none: it is a second copy of the content, so a screen reader does not read the page twice, and nothing in it can be clicked.destroy()gives the layer back the attributes it had before. - The window only opens over its own block. It opens when the pointer crosses into the box of the block carrying
data-xray-lensand closes when it leaves it, scroll included, so a lens pasted between other sections never floats over them. With several lenses on a page, only the one under the pointer is open; nested blocks give the pointer to the innermost one. - On a touch screen the window opens under the finger, follows it while it slides, and closes when it lifts.
touch: 'off'is worth considering on a phone: a full second layer is a real weight for an effect nobody can hover. - The page keeps its own cursor. The look sets
crosshairon the block, which is the only affordance the effect needs: no label telling people to move the mouse. - The preview ships whole. The end of
xray-lens.css, after the linelook, left out of the Webflow Embed, is the look of the preview on thexray-lens-*classes ofsnippet.html, and the Webflow paste carries the same values as classes of the same names. The paper of the card, its barcode and the two hatchings of the cross section are drawings, not grounds, so they travel as the SVG files ofimages/, the same in the demo, the zip and the paste. The type is the same everywhere too, Geist and Geist Mono, loaded by the font tag of the Code tab. The narrow layout, one column at 991 px and under (Webflow's tablet), closes the look, and the Webflow classes carry it as a breakpoint variant, so the Designer shows it too (AB-97). - 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.
- **
prefers-reduced-motion**: the lens stops trailing and sticks to the pointer. - **From JavaScript, beyond
mount,moveTo,closeanddestroy**: the api also carrieselementandsettings,XrayLens.destroy(el)does the same asdestroy()from the element, andmount()returnsnull, with a warning in the console, when it finds no second layer. The block emitsxraylens:enterwhen the pointer crosses into it andxraylens:leavewhen it crosses out, both bubbling, with{ element, x, y }asevent.detail.destroy()closes an open window first, so aleavealways follows anenter, removes the frame and the clip, and the page-wide listeners go with the last lens: mount, destroy and mount again, as React does in strict mode, leaves one frame and one set of listeners. - 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 in both layers whenever they want, in the page's language.
- One setting for every lens of the page, without opening the file: declare
-
Avoid the pitfalls
- **Settings handed to
mount()after the page loaded are dropped.**auto()mounts on DOM ready, andmount()on a block that already carries a lens returns the existing one untouched. A page-wide block therefore goes inwindow.XrayLensSettings, whichauto()reads itself; handed to a latermount()call it is silently dropped, and the settings block ends up looking decorative. - The layer underneath must be opaque, or the page above will show through and the two texts will fight. That is what
--xray-lens-under-bgis for, and why a translucent colour there breaks the effect. - Anything outlined must also be filled. The blocks of a second layer are usually drawn as frames with no background, which was fine over a flat ground and falls apart over a ruled one: the ruling runs straight through them and they stop reading as objects. Every such block takes a ground of its own,
--xray-lens-under-bgfor what belongs to the sheet,--xray-lens-under-panelone step up for what sits on it. That is also what keeps a card's own texture, the fibre hatching here, from being read as a continuation of the sheet's ruling. - **Padding on the block that carries
data-xray-lensis only safe where the browser supports CSS anchor positioning.** There the layer underneath is laid on the top layer's own box, so a padding on the block, or a margin on the layers, moves both together. Without it, the layer underneath covers the whole block, padding included, while the top layer sits inside the padding, and the two drift apart by exactly that much. Putting the padding on the two layers works everywhere. The anchoring also needs thedata-xray-lens-toplayer to come before the layer underneath, as siblings, which is how the Designer lays them out. - Do not fix a misaligned second layer with absolute positioning or with a smaller type size. Both break the illusion in a different way: one drifts as soon as the viewport changes, the other makes the layer underneath look like a tooltip. Fix it in the copy instead, by matching lengths.
- **Settings handed to
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
X-ray lens is a cursor animation for Webflow and vanilla JavaScript, with no library. Also called x-ray cursor effect, cursor reveal effect or cursor mask reveal.
A rectangular window follows the pointer and, inside it, the page shows what is under it. Two layers sit on the same boxes, both of them ordinary HTML, and the top one is cut open wherever the lens goes. In the reference demo a product page hides the mill's own record for the paper it is selling: lot number, basis weight, moisture at wrap, a cross section of the sheet. The point is that the layer underneath is real content, not decoration, so the effect says something instead of just moving. A thin accent frame with corner brackets, a crosshair and a small readout mark the opening. The window is a CSS clip-path driven by two custom properties, so following the pointer costs nothing but writing two values, and the lens trails slightly behind with time-based smoothing.
Updated
Questions
The two layers are two sibling blocks in the Designer: build the first as any section, duplicate it, then rewrite the words. Put data-xray-lens on the block that holds both, data-xray-lens-top on the page as it reads normally and data-xray-lens-under on the layer the window reveals.
No library. The window is a CSS clip-path driven by two custom properties, and the script writes those two values to follow the pointer. Both layers are ordinary HTML, with no image trick.
Set data-width and data-height on the block. They default to clamp(260px, 34vw, 470px) and clamp(150px, 20vw, 280px), and accept any CSS unit, clamp() and calc() included. Smaller, the window is a peephole that cuts every line of text it crosses, and larger, the layer underneath takes over the page.
Fix it in the copy, by matching lengths. Give both layers the same typography and write each block underneath to roughly the character count of the block it hides, so the lines break in the same places. Padding belongs on the two layers, not on the block that carries data-xray-lens, where it is only safe in browsers that support CSS anchor positioning.
Yes. By default the window opens under the finger, follows it while it slides and closes when it lifts. Set data-touch="off" to give touch screens the page alone, which is worth considering on a phone since the second layer is a real weight for an effect nobody can hover.