Dot repel
A screen of fine dots lies behind the hero, pale, like the halftone of a printed page, and the cursor parts it: the dots near the pointer are pushed out of a round hole, the nearest the farthest, so they pile up on its rim, which takes a little ink.
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-dot-repel Marks the section the field lies behind. It takes no value: leave the value field empty. That section is what the canvas is laid under, what catches the pointer and what carries the settings, so it needs a height of its own. Without it on any section of the page, the embed marks the section it was pasted into. data-dot-repel-state Written by the engine on the section: live while dots move, idle at rest, still under reduced motion, held for a frozen field. Never written by hand. [ 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-dot-repel-color #15100c Colour of the dots. Options: any CSS colour; taken out of this block, --dot-repel-color on the section takes over, then the ink #15100c. data-dot-repel-rest 0.12 Opacity of a dot at rest, 0 to 1. data-dot-repel-peak 0.34 Opacity of a dot pushed the farthest, the ones piled on the rim of the hole, 0 to 1; never under rest. data-dot-repel-spacing 16 Distance between two dots of the grid, in px. data-dot-repel-dot 1.2 Radius of a dot at rest, in px; kept under half the spacing. data-dot-repel-reach clamp(120px, 12vw, 180px) Radius of the area the pointer parts, any CSS unit. data-dot-repel-grow 1.35 Radius of a dot pushed the farthest, in times dot. data-dot-repel-push 0.28 How far the nearest dots are pushed, in times reach, 0 to 0.5. data-dot-repel-life 0.9 Time a dot takes to spring back once the pointer moves on, in seconds.
Two files, 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
DotRepel.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-dot-repel-color #15100c Colour of the dots. Options: any CSS colour; taken out of this block, --dot-repel-color on the section takes over, then the ink #15100c. data-dot-repel-rest 0.12 Opacity of a dot at rest, 0 to 1. A screen you barely see until the pointer parts it A plain dot grid that competes with the words data-dot-repel-peak 0.34 Opacity of a dot pushed the farthest, the ones piled on the rim of the hole, 0 to 1; never under rest. The rim barely darker than the screen A dark ring around the pointer data-dot-repel-spacing 16 Distance between two dots of the grid, in px. A fine screen, more dots to redraw around the pointer A coarse grid, the dots read one by one data-dot-repel-dot 1.2 Radius of a dot at rest, in px; kept under half the spacing. Pin pricks Heavy dots, a darker screen data-dot-repel-reach clamp(120px, 12vw, 180px) Radius of the area the pointer parts, any CSS unit. A small hole, only the dots right under the pointer move A wide parting, the whole middle of the hero shifts data-dot-repel-grow 1.35 Radius of a dot pushed the farthest, in times dot. The rim keeps the size of the screen Swollen dots on the rim data-dot-repel-push 0.28 How far the nearest dots are pushed, in times reach, 0 to 0.5. A slight shift of the screen A wide empty hole, the dots crowded on its edge data-dot-repel-life 0.9 Time a dot takes to spring back once the pointer moves on, in seconds. A quick, tight return A slow return that sways longer -
Drive it from JavaScript
When the section arrives after load, when a tap has to be played from code, or when the section changes size without the window doing so.const element = document.querySelector('[data-dot-repel]'); const field = DotRepel.mount(element); // the section auto() mounted, or a new one field.ripple(240, 180); // a ripple from that point of the section field.resize(); // after the section changed size on its own // When the section leaves the page: field.destroy(); // removes the canvas and the listeners
-
Fit it to your page
- One section, one attribute. Put
data-dot-repelon the section the field lies behind. The script adds a<canvas>of its own as the first child (classdot-repel-layer,position: absolute; inset: 0; z-index: -1; pointer-events: none, written inline as well as in the stylesheet, so the canvas is out of the flow before the stylesheet arrives). It switches a static section toposition: relative, and gives the section a stacking context of its own withisolation: isolate, so the canvas sits over the section's background and under every word and link. It never takes a click. - The section needs a background of its own, or none. The dots are drawn over the section's background colour; the page shows through where the section has none.
- The sensitive area is the section, not the window: coordinates are relative to its
getBoundingClientRect(). An empty section is 0px tall and shows no dot; give it content or a height. - One setting for every field of the page, without opening the file: declare
window.DotRepelSettings = { spacing: 12 }in a script placed beforedot-repel.js. The three levels, least specific first, areDEFAULTS, then that object, then adata-dot-repel-*attribute on the section itself (data-dot-repel-reach="200px"), so one section can still differ from the page-wide setting. The colour has one more level: without acoloranywhere, the section's--dot-repel-coloris read. - Touch: a finger drag parts the field too, through touch events, once the finger has travelled 10px, and the page still scrolls under it. A short tap that barely moves leaves a ripple: a ring that runs out from the point and pushes the dots it crosses, then lets them spring back.
- **
prefers-reduced-motion**: the screen of dots stays, still, and answers neither the pointer nor a tap. The engine listens to the setting and stops or starts with it. - Cost at rest is nothing: the loop only runs while a dot is moving. It sleeps once the field has settled, the pointer resting on it included, and stops in a hidden tab. The section carries
data-dot-repel-state:livewhile the loop runs,idleat rest,stillunder reduced motion,heldfor a frozen field. - A resize rebuilds the grid: the window, or the section alone through a ResizeObserver. The field goes back to rest and
reachis read again, so avwor aclamp()follows the width. - From JavaScript:
DotRepel.mount(el, overrides)returns the instance, which carrieselement,settings(the values it resolved,reachin px),resize(),ripple(x, y)(the ripple of a tap at a point of the section),clear(),frames()andisRunning()(rendered frames since mount, and whether the loop runs, for a test that the loop sleeps), anddestroy().destroy()takes away the canvas, every listener, the size watch and the loop, and gives the section'sstyleattribute back as it was, so the markup is as it was and mounting again starts clean.DotRepel.destroy(el)does the same from the element. In React,mountinuseEffectanddestroyin its cleanup; the double run of StrictMode then leaves one field, not two. freeze()holds the field as a still picture: the pointer leaving no longer closes the hole, the dots settle where the last move pushed them and the loop stops, so a held field costs nothing.unfreeze()lets the hole close again.DotRepel.freeze(el)andDotRepel.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 field parted at rest: a page that only wants the field never calls it.- 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.
- 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 section, one attribute. Put
-
Avoid the pitfalls
- **Settings handed to
mount()after the page loaded are dropped.**auto()mounts on DOM ready, andmount()on a section that already carries a field returns the existing one untouched. A page-wide block therefore goes inwindow.DotRepelSettings, whichauto()reads itself. - A child with a background of its own hides the dots under it. The canvas is under the content: a card, a band or an image inside the section covers the screen where it stands, which is usually what is wanted.
- **A section with a
z-indexalready has its stacking context**, so the script leavesisolationalone there. The canvas still sits under the content and over the background, sincez-index: -1is read inside the section's own context. - **Mouse and pen only for
pointermove.** A touch pointer is ignored there on purpose, touch going through touch events; a page that fakes pointer events for a test has to give thempointerType: 'mouse'.
- **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
Dot repel is a cursor animation for Webflow and vanilla JavaScript, with no library. Also called halftone dot grid cursor effect, interactive dot grid background or mouse repel dots.
A screen of fine dots lies behind the hero, pale, like the halftone of a printed page, and the cursor parts it: the dots near the pointer are pushed out of a round hole, the nearest the farthest, so they pile up on its rim, which takes a little ink. When the pointer moves on, the dots spring back to their place with a slight overshoot and the screen closes again. Nothing but dots, no picture to load: one canvas under the section's content, clicks pass through, only the dots around the pointer are worked on, and the loop sleeps as soon as the field has settled, even with the pointer resting on it. A finger drag parts it too and a tap leaves a small ripple running out through the dots; under reduced motion the screen stays still and answers nothing.
Updated
Questions
Paste the component, or put data-dot-repel with an empty value on a section of your own. If no section carries it, the embed marks the section it was pasted into. Settings are custom attributes on that section, for instance data-dot-repel-spacing="12", and the section needs content or a height, since at 0px tall it shows no dot.
No. The engine has no dependency and no build step, and there is no image to load. It draws the dots on one canvas that the script adds under the section's content.
Set data-dot-repel-reach, the radius the pointer parts, in any CSS unit, for instance data-dot-repel-reach="200px". The default is clamp(120px, 12vw, 180px). Lower and only the dots right under the pointer move, higher and the whole middle of the hero shifts.
No. The canvas sits under every word and link of the section with pointer-events: none, so it never takes a click. A child with a background of its own, a card or an image, hides the dots where it stands.
Yes. A finger drag parts the dots once it has travelled 10px, and the page still scrolls under it, while a short tap leaves a ripple running out through the dots. Under prefers-reduced-motion the dots stay, still, and answer neither the pointer nor a tap.