Magnetic Elements
Five 60 px tiles pulled toward the pointer within a 120 px radius: up to 25 px of translation, a scale up to ×1.15 and an indigo glow, recomputed on every mousemove and smoothed by a 300 ms transform transition. No custom cursor: the elements move, the system arrow stays.
You'll get access to the interactive demo with a free account.
This effect is part of Effect.Labs — 811 vanilla effects, some free, some premium. Cursors has 26 effects, including 1 free. Explore the category →
3 usage examples



How it works
The [data-magnetic-zone] container (.magnetic-zone, centered flex, gap: 20px, padding: 20px, flex-wrap) holds five [data-magnetic-el] (.magnetic-item): 60 × 60 px, border-radius: 16px, glyph at 1.5rem, transition: transform .3s cubic-bezier(.23, 1, .32, 1), will-change: transform, cursor: pointer, and a different gradient per :nth-child (indigo, pink, cyan, amber, green). The JS iterates document.querySelectorAll('[data-magnetic-zone]'): several zones on one page work with no change.
On every mousemove over the zone, the script computes the pointer's local position then, for each tile, the distance d between that point and the tile's center at rest — measured once, then stored (see below). If d < 120: force = 25 × (1 − d / 120), atan2 angle, then transform: translate(cos θ × force, sin θ × force) scale(1 + .15 × (1 − d / 120)) and box-shadow: 0 0 (20 + force)px rgba(99,102,241, .3 + .4 × (1 − d / 120)) — all as inline styles. Otherwise: translate(0, 0) scale(1) and box-shadow: none. mouseleave resets all five. Measured: at 119 px, 0.2 px translation, scale 1.001, 20 px glow at 30%; at 60 px, 12.5 px, ×1.075, 32.5 px glow at 50%.
The glow is not in the transition list (only transform is): when crossing the 120 px boundary, box-shadow jumps from none to a 20 px blur at 30% in one step — a visible “pop”, while the translation eases in. And it is indigo for all five tiles, including the pink, amber and green ones: the glow color is hard-coded, independent of the tile's gradient.
Each tile's center is read once, at rest: on the first mousemove, the script neutralizes the tile's transform, reads its box (getBoundingClientRect), stores in t.__repos its offsets relative to the zone (dx, dy) and its size, then restores the transform. On later events, the center is derived from the zone's current box and those offsets: the already translated box is never re-read, the vector doesn't flip when the tile passes the pointer, and the tile moves by an amount that depends only on its resting distance, with no oscillation. Since the offsets are relative to the zone, page scrolling doesn't make the center drift. What the cache doesn't cover: a layout that changes after the first measurement (window resized, tile added) — t.__repos keeps the old offsets. Another point: .magnetic-zone { height: 100% } inside a parent with no defined height resolves to auto — the measured active zone is 100 px tall (60 + 2 × 20) in a 300 px preview, and a pointer 30 px above the band attracts nothing.
Accessibility
- Glyph contrast: white at 24 px regular (large text, 3:1 threshold) on cyan
#06b6d4= 2.4:1, amber#f59e0b= 2.2:1, emerald#10b981= 2.5:1, teal#14b8a6= 2.5:1 — four of the ten gradient stops fail; pink 3.5/3.7 and indigo 4.5/4.2 pass. Use the 600/700 shades:#0e7490 → #1d4ed8(5.4 and 6.7:1),#b45309 → #b91c1c(5.0 and 6.5:1),#047857 → #0f766e(5.5:1). Decorative glyphs are exempt: mark themaria-hidden. cursor: pointeris set ondivs that react to no click and have neither role nor name: the hand promises an action that doesn't exist. Either the tiles becomebutton/aelements (the effect works identically,[data-magnetic-el]is just a selector), or removecursor: pointer.- Keyboard:
divs don't receive focus. If you turn them into buttons, the attraction will never trigger on focus (the code only listens tomousemove): give them an explicit:focus-visible(outline: 2px solid #c7d2fe; outline-offset: 4px), independent of the effect. The clickable area follows the translation: a tile moved 25 px is clicked at its visible position. - prefers-reduced-motion not handled: tiles glide and grow under the pointer. Under
@media (prefers-reduced-motion: reduce),.magnetic-item { transition: none }is not enough — gate the listener instead:if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;before attachingmousemove. - Screen readers: the symbols ♦ ★ ● ▲ ❖ are announced (“black diamond”, “black star”…) with no meaning at all. Add
aria-hidden="true"on each decorative glyph, or replace them with named icons (aria-label) if the tiles become controls. Touch: a tap emulates amousemove— nearby tiles jump toward the tapped point and stay there until the nextmouseleave.
Browser compatibility
ES5 JavaScript (var, classic functions, forEach on NodeList), getBoundingClientRect, Math.atan2. CSS: transform, transition with cubic-bezier, will-change, flex-wrap. Zero dependencies — the most backward-compatible cursor effect in the catalog.
Without JavaScript, five static, properly laid-out tiles: nothing is visually missing. Without CSS transitions (very old browsers), tiles jump from one position to the next on every event. NodeList.forEach is missing before Chrome 51 / Firefox 50 / Safari 10: the script stops on an error, tiles stay static.
The code
HTML structure to paste into your page (CSS + JS available with a premium account):
<div class="magnetic-zone" data-magnetic-zone>
<div class="magnetic-item" data-magnetic-el>♦</div>
<div class="magnetic-item" data-magnetic-el>★</div>
<!-- … 5 in total, one per tile … -->
</div>
Full HTML + CSS + JS, copy-paste ready — with hundreds of premium effects.
Customize
Options passed to the API or data-* attributes:
| Option / property | Default | Effect |
|---|---|---|
Attraction radius (JS — f = 120) |
120 px | Pointer-to-center distance below which a tile reacts. 80 = only the hovered tile moves; 200 = the whole row ripples. |
Maximum force (JS — 25 * (1 - i / f)) |
25 px | Translation at contact. 12 = a quiver; 40 = tiles overlap if the gap is smaller. |
Scale (JS — 1 + .15 * (1 - i / f)) |
×1.15 | Scale at contact. .3 for a dock that swells; 0 for pure translation. |
Glow (JS — rgba(99,102,241, .3 + .4 × …), blur 20 + force) |
indigo, 20 to 45 px | Color and size of the drop shadow. Read one CSS variable per tile (getComputedStyle(t).getPropertyValue('--glow')) for a glow matching the gradient. |
Transition (CSS — transform .3s cubic-bezier(.23, 1, .32, 1)) |
300 ms, elastic ease-out | Motion smoothing. Add box-shadow .3s to remove the glow's jump at the 120 px edge; .15s for a snappier follow. |
Measurement point (JS — t.__repos) |
resting position, measured once | Center read with the transform neutralized, stored as an offset relative to the zone: the tile settles under the pointer with no oscillation and scrolling shifts nothing. If the layout changes afterwards (resize, tile added), set t.__repos = null on each tile — the next measurement starts over. |
Tile colors (CSS — .magnetic-item:nth-child(n)) |
5 gradients, 500 shades | One gradient per position. The 600/700 shades (#4f46e5, #db2777, #0e7490, #b45309, #047857) give the white glyph ≥ 4.6:1. |
FAQ
mousemove over the zone, for each tile: the inline transform is set to none, the box read with getBoundingClientRect(), its offsets relative to the zone and its size stored in t.__repos, then the transform restored. Later events never re-read the translated box — that is what avoids the oscillation a measurement on the already displaced tile would cause (the vector would flip as soon as the tile passes the pointer). Since the offsets are relative to the zone, page scrolling changes nothing. However, if the tiles reflow after that first measurement (window resized, flex-wrap wrapping, tile added), the stored offsets are stale and the attraction aims at the old spot: set t.__repos = null on each tile in a resize listener, or after any DOM change.transform. At the 120 px boundary, box-shadow goes with no intermediate step from none to 0 0 20px rgba(99,102,241,.3) (measured at 119 px), and drops back to none on the way out. Add box-shadow .3s to .magnetic-item's transition: blur and opacity interpolate, and the glow fades in smoothly. The cost of an animated box-shadow on five tiles is negligible.[data-magnetic-el], any element will do; keep display: flex and the centering for the glyph, and add a :focus-visible. As for the height: .magnetic-zone { height: 100% } resolves to auto when the parent has no defined height (a min-height doesn't count) — measured 100 px tall in a 300 px preview, and the attraction only triggers within that band. Give the parent a height, or the zone a min-height or generous vertical padding.