25 CSS Play / Pause Buttons21 / 25
Magnetic Hover Cursor Play Button
The award-site micro-interaction: the button leans toward your cursor as it approaches, the icon lags slightly behind for parallax, and everything springs home when you leave. Twenty lines of pointer maths write two custom properties; CSS does all the rendering. Includes the guardrails production versions forget — pointer-type detection, reduced-motion opt-out and a full keyboard path.
Published Updated
The code
<section class="pp-21" aria-label="Magnetic hover cursor play button demo">
<div class="pp-21__stage">
<div class="pp-21__zone">
<button class="pp-21__btn" type="button" aria-pressed="false">
<span class="pp-21__icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="28" height="28">
<path class="pp-21__p" d="M9 5.6v12.8L19 12z" fill="currentColor"/>
<path class="pp-21__q" d="M8.6 5.6h3.2v12.8H8.6zM12.8 5.6H16v12.8h-3.2z" fill="currentColor"/>
</svg>
</span>
<span class="pp-21__sr">Play the showreel</span>
</button>
<span class="pp-21__hint" aria-hidden="true">move closer</span>
</div>
<div class="pp-21__cap">
<p class="pp-21__title">Showreel 2027</p>
<p class="pp-21__sub">02:14 · sound on</p>
</div>
<p class="pp-21__chip" aria-hidden="true">pointermove → 2 custom props · 0.32 pull · icon parallax at 0.35</p>
</div>
</section><section class="pp-21" aria-label="Magnetic hover cursor play button demo">
<div class="pp-21__stage">
<div class="pp-21__zone">
<button class="pp-21__btn" type="button" aria-pressed="false">
<span class="pp-21__icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="28" height="28">
<path class="pp-21__p" d="M9 5.6v12.8L19 12z" fill="currentColor"/>
<path class="pp-21__q" d="M8.6 5.6h3.2v12.8H8.6zM12.8 5.6H16v12.8h-3.2z" fill="currentColor"/>
</svg>
</span>
<span class="pp-21__sr">Play the showreel</span>
</button>
<span class="pp-21__hint" aria-hidden="true">move closer</span>
</div>
<div class="pp-21__cap">
<p class="pp-21__title">Showreel 2027</p>
<p class="pp-21__sub">02:14 · sound on</p>
</div>
<p class="pp-21__chip" aria-hidden="true">pointermove → 2 custom props · 0.32 pull · icon parallax at 0.35</p>
</div>
</section>.pp-21,
.pp-21 *,
.pp-21 *::before,
.pp-21 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.pp-21 {
width: 100%;
min-height: 100vh;
min-height: 100svh;
display: block;
background: var(--pp-21-bg);
--pp-21-bg: oklch(0.15 0.01 100);
--pp-21-ink: oklch(0.97 0.01 95);
--pp-21-mut: oklch(0.66 0.02 95);
--pp-21-acc: oklch(0.85 0.16 92);
font-family: 'Segoe UI',system-ui,sans-serif;
color: var(--pp-21-ink);
}
.pp-21__stage {
min-height: 100vh;
min-height: 100svh;
display: grid;
place-items: center;
align-content: center;
gap: 18px;
padding: clamp(20px,5vw,56px);
background: radial-gradient(55% 45% at 50% 42%,oklch(0.24 0.05 95/.65),transparent 72%);
}
.pp-21__zone {
position: relative;
display: grid;
place-items: center;
width: min(90vw,320px);
height: 240px;
border-radius: 28px;
border: 1px dashed oklch(1 0 0/.14);
}
.pp-21__hint {
position: absolute;
bottom: 14px;
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 10.5px;
letter-spacing: .18em;
text-transform: uppercase;
color: var(--pp-21-mut);
opacity: .8;
transition: opacity .3s;
}
.pp-21__zone:hover .pp-21__hint {
opacity: 0;
}
.pp-21__btn {
display: grid;
place-items: center;
width: 116px;
height: 116px;
border: 0;
border-radius: 50%;
background: var(--pp-21-acc);
color: oklch(0.18 0.04 95);
cursor: pointer;
translate: var(--pp-21-x,0) var(--pp-21-y,0);
box-shadow: 0 18px 44px oklch(0.6 0.14 92/.28);
transition: translate .35s cubic-bezier(.22,1,.36,1),scale .3s cubic-bezier(.34,1.4,.5,1),background .3s;
}
.pp-21__btn:hover,
.pp-21__btn:focus-visible {
scale: 1.08;
}
.pp-21__btn:active {
scale: .96;
}
.pp-21__btn:focus-visible {
outline: 3px solid var(--pp-21-ink);
outline-offset: 6px;
}
.pp-21__icon {
display: grid;
place-items: center;
translate: calc(var(--pp-21-x,0px) * .35) calc(var(--pp-21-y,0px) * .35);
transition: translate .45s cubic-bezier(.22,1,.36,1);
}
.pp-21__q {
display: none;
}
.pp-21__btn[aria-pressed="true"] {
background: oklch(0.98 0.01 95);
}
.pp-21__btn[aria-pressed="true"] .pp-21__p {
display: none;
}
.pp-21__btn[aria-pressed="true"] .pp-21__q {
display: block;
}
.pp-21__sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.pp-21__cap {
text-align: center;
}
.pp-21__title {
font-size: 19px;
font-weight: 660;
letter-spacing: -.015em;
}
.pp-21__sub {
font-size: 13px;
color: var(--pp-21-mut);
margin-top: 5px;
}
.pp-21__chip {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11.5px;
color: var(--pp-21-mut);
padding: 7px 14px;
border: 1px solid oklch(1 0 0/.13);
border-radius: 99px;
text-align: center;
margin-top: 6px;
}
@media (prefers-reduced-motion: reduce) {
.pp-21__btn,
.pp-21__icon {
translate: 0 0 !important;
transition-duration: .01ms !important;
}
}.pp-21,
.pp-21 *,
.pp-21 *::before,
.pp-21 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.pp-21 {
width: 100%;
min-height: 100vh;
min-height: 100svh;
display: block;
background: var(--pp-21-bg);
--pp-21-bg: oklch(0.15 0.01 100);
--pp-21-ink: oklch(0.97 0.01 95);
--pp-21-mut: oklch(0.66 0.02 95);
--pp-21-acc: oklch(0.85 0.16 92);
font-family: 'Segoe UI',system-ui,sans-serif;
color: var(--pp-21-ink);
}
.pp-21__stage {
min-height: 100vh;
min-height: 100svh;
display: grid;
place-items: center;
align-content: center;
gap: 18px;
padding: clamp(20px,5vw,56px);
background: radial-gradient(55% 45% at 50% 42%,oklch(0.24 0.05 95/.65),transparent 72%);
}
.pp-21__zone {
position: relative;
display: grid;
place-items: center;
width: min(90vw,320px);
height: 240px;
border-radius: 28px;
border: 1px dashed oklch(1 0 0/.14);
}
.pp-21__hint {
position: absolute;
bottom: 14px;
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 10.5px;
letter-spacing: .18em;
text-transform: uppercase;
color: var(--pp-21-mut);
opacity: .8;
transition: opacity .3s;
}
.pp-21__zone:hover .pp-21__hint {
opacity: 0;
}
.pp-21__btn {
display: grid;
place-items: center;
width: 116px;
height: 116px;
border: 0;
border-radius: 50%;
background: var(--pp-21-acc);
color: oklch(0.18 0.04 95);
cursor: pointer;
translate: var(--pp-21-x,0) var(--pp-21-y,0);
box-shadow: 0 18px 44px oklch(0.6 0.14 92/.28);
transition: translate .35s cubic-bezier(.22,1,.36,1),scale .3s cubic-bezier(.34,1.4,.5,1),background .3s;
}
.pp-21__btn:hover,
.pp-21__btn:focus-visible {
scale: 1.08;
}
.pp-21__btn:active {
scale: .96;
}
.pp-21__btn:focus-visible {
outline: 3px solid var(--pp-21-ink);
outline-offset: 6px;
}
.pp-21__icon {
display: grid;
place-items: center;
translate: calc(var(--pp-21-x,0px) * .35) calc(var(--pp-21-y,0px) * .35);
transition: translate .45s cubic-bezier(.22,1,.36,1);
}
.pp-21__q {
display: none;
}
.pp-21__btn[aria-pressed="true"] {
background: oklch(0.98 0.01 95);
}
.pp-21__btn[aria-pressed="true"] .pp-21__p {
display: none;
}
.pp-21__btn[aria-pressed="true"] .pp-21__q {
display: block;
}
.pp-21__sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.pp-21__cap {
text-align: center;
}
.pp-21__title {
font-size: 19px;
font-weight: 660;
letter-spacing: -.015em;
}
.pp-21__sub {
font-size: 13px;
color: var(--pp-21-mut);
margin-top: 5px;
}
.pp-21__chip {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11.5px;
color: var(--pp-21-mut);
padding: 7px 14px;
border: 1px solid oklch(1 0 0/.13);
border-radius: 99px;
text-align: center;
margin-top: 6px;
}
@media (prefers-reduced-motion: reduce) {
.pp-21__btn,
.pp-21__icon {
translate: 0 0 !important;
transition-duration: .01ms !important;
}
}(() => {
const fine = window.matchMedia('(hover:hover) and (pointer:fine)').matches;
const calm = window.matchMedia('(prefers-reduced-motion:reduce)').matches;
document.querySelectorAll('.pp-21__zone').forEach((zone) => {
if (zone.dataset.wired) return;
zone.dataset.wired = '1';
const btn = zone.querySelector('.pp-21__btn');
const sr = zone.querySelector('.pp-21__sr');
btn.addEventListener('click', () => {
const on = btn.getAttribute('aria-pressed') !== 'true';
btn.setAttribute('aria-pressed', String(on));
sr.textContent = (on ? 'Pause' : 'Play') + ' the showreel';
});
if (!fine || calm) return;
let box = null;
const reset = () => { btn.style.setProperty('--pp-21-x', '0px'); btn.style.setProperty('--pp-21-y', '0px'); };
zone.addEventListener('pointerenter', () => { box = btn.getBoundingClientRect(); });
zone.addEventListener('pointermove', (e) => {
if (!box) box = btn.getBoundingClientRect();
btn.style.setProperty('--pp-21-x', (e.clientX - (box.left + box.width / 2)) * 0.32 + 'px');
btn.style.setProperty('--pp-21-y', (e.clientY - (box.top + box.height / 2)) * 0.32 + 'px');
});
zone.addEventListener('pointerleave', () => { box = null; reset(); });
btn.addEventListener('blur', reset);
});
})();(() => {
const fine = window.matchMedia('(hover:hover) and (pointer:fine)').matches;
const calm = window.matchMedia('(prefers-reduced-motion:reduce)').matches;
document.querySelectorAll('.pp-21__zone').forEach((zone) => {
if (zone.dataset.wired) return;
zone.dataset.wired = '1';
const btn = zone.querySelector('.pp-21__btn');
const sr = zone.querySelector('.pp-21__sr');
btn.addEventListener('click', () => {
const on = btn.getAttribute('aria-pressed') !== 'true';
btn.setAttribute('aria-pressed', String(on));
sr.textContent = (on ? 'Pause' : 'Play') + ' the showreel';
});
if (!fine || calm) return;
let box = null;
const reset = () => { btn.style.setProperty('--pp-21-x', '0px'); btn.style.setProperty('--pp-21-y', '0px'); };
zone.addEventListener('pointerenter', () => { box = btn.getBoundingClientRect(); });
zone.addEventListener('pointermove', (e) => {
if (!box) box = btn.getBoundingClientRect();
btn.style.setProperty('--pp-21-x', (e.clientX - (box.left + box.width / 2)) * 0.32 + 'px');
btn.style.setProperty('--pp-21-y', (e.clientY - (box.top + box.height / 2)) * 0.32 + 'px');
});
zone.addEventListener('pointerleave', () => { box = null; reset(); });
btn.addEventListener('blur', reset);
});
})();Here's a working CSS Play / Pause Button from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Magnetic Hover Cursor Play Button
Source: https://codefronts.com/components/css-play-pause-buttons/magnetic-hover/
The award-site micro-interaction: the button leans toward your cursor as it approaches, the icon lags slightly behind for parallax, and everything springs home when you leave. Twenty lines of pointer maths write two custom properties; CSS does all the rendering. Includes the guardrails production versions forget — pointer-type detection, reduced-motion opt-out and a full keyboard path.
## HTML
```html
<section class="pp-21" aria-label="Magnetic hover cursor play button demo">
<div class="pp-21__stage">
<div class="pp-21__zone">
<button class="pp-21__btn" type="button" aria-pressed="false">
<span class="pp-21__icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="28" height="28">
<path class="pp-21__p" d="M9 5.6v12.8L19 12z" fill="currentColor"/>
<path class="pp-21__q" d="M8.6 5.6h3.2v12.8H8.6zM12.8 5.6H16v12.8h-3.2z" fill="currentColor"/>
</svg>
</span>
<span class="pp-21__sr">Play the showreel</span>
</button>
<span class="pp-21__hint" aria-hidden="true">move closer</span>
</div>
<div class="pp-21__cap">
<p class="pp-21__title">Showreel 2027</p>
<p class="pp-21__sub">02:14 · sound on</p>
</div>
<p class="pp-21__chip" aria-hidden="true">pointermove → 2 custom props · 0.32 pull · icon parallax at 0.35</p>
</div>
</section>
```
## CSS
```css
.pp-21,
.pp-21 *,
.pp-21 *::before,
.pp-21 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.pp-21 {
width: 100%;
min-height: 100vh;
min-height: 100svh;
display: block;
background: var(--pp-21-bg);
--pp-21-bg: oklch(0.15 0.01 100);
--pp-21-ink: oklch(0.97 0.01 95);
--pp-21-mut: oklch(0.66 0.02 95);
--pp-21-acc: oklch(0.85 0.16 92);
font-family: 'Segoe UI',system-ui,sans-serif;
color: var(--pp-21-ink);
}
.pp-21__stage {
min-height: 100vh;
min-height: 100svh;
display: grid;
place-items: center;
align-content: center;
gap: 18px;
padding: clamp(20px,5vw,56px);
background: radial-gradient(55% 45% at 50% 42%,oklch(0.24 0.05 95/.65),transparent 72%);
}
.pp-21__zone {
position: relative;
display: grid;
place-items: center;
width: min(90vw,320px);
height: 240px;
border-radius: 28px;
border: 1px dashed oklch(1 0 0/.14);
}
.pp-21__hint {
position: absolute;
bottom: 14px;
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 10.5px;
letter-spacing: .18em;
text-transform: uppercase;
color: var(--pp-21-mut);
opacity: .8;
transition: opacity .3s;
}
.pp-21__zone:hover .pp-21__hint {
opacity: 0;
}
.pp-21__btn {
display: grid;
place-items: center;
width: 116px;
height: 116px;
border: 0;
border-radius: 50%;
background: var(--pp-21-acc);
color: oklch(0.18 0.04 95);
cursor: pointer;
translate: var(--pp-21-x,0) var(--pp-21-y,0);
box-shadow: 0 18px 44px oklch(0.6 0.14 92/.28);
transition: translate .35s cubic-bezier(.22,1,.36,1),scale .3s cubic-bezier(.34,1.4,.5,1),background .3s;
}
.pp-21__btn:hover,
.pp-21__btn:focus-visible {
scale: 1.08;
}
.pp-21__btn:active {
scale: .96;
}
.pp-21__btn:focus-visible {
outline: 3px solid var(--pp-21-ink);
outline-offset: 6px;
}
.pp-21__icon {
display: grid;
place-items: center;
translate: calc(var(--pp-21-x,0px) * .35) calc(var(--pp-21-y,0px) * .35);
transition: translate .45s cubic-bezier(.22,1,.36,1);
}
.pp-21__q {
display: none;
}
.pp-21__btn[aria-pressed="true"] {
background: oklch(0.98 0.01 95);
}
.pp-21__btn[aria-pressed="true"] .pp-21__p {
display: none;
}
.pp-21__btn[aria-pressed="true"] .pp-21__q {
display: block;
}
.pp-21__sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.pp-21__cap {
text-align: center;
}
.pp-21__title {
font-size: 19px;
font-weight: 660;
letter-spacing: -.015em;
}
.pp-21__sub {
font-size: 13px;
color: var(--pp-21-mut);
margin-top: 5px;
}
.pp-21__chip {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11.5px;
color: var(--pp-21-mut);
padding: 7px 14px;
border: 1px solid oklch(1 0 0/.13);
border-radius: 99px;
text-align: center;
margin-top: 6px;
}
@media (prefers-reduced-motion: reduce) {
.pp-21__btn,
.pp-21__icon {
translate: 0 0 !important;
transition-duration: .01ms !important;
}
}
```
## JavaScript
```js
(() => {
const fine = window.matchMedia('(hover:hover) and (pointer:fine)').matches;
const calm = window.matchMedia('(prefers-reduced-motion:reduce)').matches;
document.querySelectorAll('.pp-21__zone').forEach((zone) => {
if (zone.dataset.wired) return;
zone.dataset.wired = '1';
const btn = zone.querySelector('.pp-21__btn');
const sr = zone.querySelector('.pp-21__sr');
btn.addEventListener('click', () => {
const on = btn.getAttribute('aria-pressed') !== 'true';
btn.setAttribute('aria-pressed', String(on));
sr.textContent = (on ? 'Pause' : 'Play') + ' the showreel';
});
if (!fine || calm) return;
let box = null;
const reset = () => { btn.style.setProperty('--pp-21-x', '0px'); btn.style.setProperty('--pp-21-y', '0px'); };
zone.addEventListener('pointerenter', () => { box = btn.getBoundingClientRect(); });
zone.addEventListener('pointermove', (e) => {
if (!box) box = btn.getBoundingClientRect();
btn.style.setProperty('--pp-21-x', (e.clientX - (box.left + box.width / 2)) * 0.32 + 'px');
btn.style.setProperty('--pp-21-y', (e.clientY - (box.top + box.height / 2)) * 0.32 + 'px');
});
zone.addEventListener('pointerleave', () => { box = null; reset(); });
btn.addEventListener('blur', reset);
});
})();
```Here's a working CSS Play / Pause Button from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Magnetic Hover Cursor Play Button
Source: https://codefronts.com/components/css-play-pause-buttons/magnetic-hover/
The award-site micro-interaction: the button leans toward your cursor as it approaches, the icon lags slightly behind for parallax, and everything springs home when you leave. Twenty lines of pointer maths write two custom properties; CSS does all the rendering. Includes the guardrails production versions forget — pointer-type detection, reduced-motion opt-out and a full keyboard path.
## HTML
```html
<section class="pp-21" aria-label="Magnetic hover cursor play button demo">
<div class="pp-21__stage">
<div class="pp-21__zone">
<button class="pp-21__btn" type="button" aria-pressed="false">
<span class="pp-21__icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="28" height="28">
<path class="pp-21__p" d="M9 5.6v12.8L19 12z" fill="currentColor"/>
<path class="pp-21__q" d="M8.6 5.6h3.2v12.8H8.6zM12.8 5.6H16v12.8h-3.2z" fill="currentColor"/>
</svg>
</span>
<span class="pp-21__sr">Play the showreel</span>
</button>
<span class="pp-21__hint" aria-hidden="true">move closer</span>
</div>
<div class="pp-21__cap">
<p class="pp-21__title">Showreel 2027</p>
<p class="pp-21__sub">02:14 · sound on</p>
</div>
<p class="pp-21__chip" aria-hidden="true">pointermove → 2 custom props · 0.32 pull · icon parallax at 0.35</p>
</div>
</section>
```
## CSS
```css
.pp-21,
.pp-21 *,
.pp-21 *::before,
.pp-21 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.pp-21 {
width: 100%;
min-height: 100vh;
min-height: 100svh;
display: block;
background: var(--pp-21-bg);
--pp-21-bg: oklch(0.15 0.01 100);
--pp-21-ink: oklch(0.97 0.01 95);
--pp-21-mut: oklch(0.66 0.02 95);
--pp-21-acc: oklch(0.85 0.16 92);
font-family: 'Segoe UI',system-ui,sans-serif;
color: var(--pp-21-ink);
}
.pp-21__stage {
min-height: 100vh;
min-height: 100svh;
display: grid;
place-items: center;
align-content: center;
gap: 18px;
padding: clamp(20px,5vw,56px);
background: radial-gradient(55% 45% at 50% 42%,oklch(0.24 0.05 95/.65),transparent 72%);
}
.pp-21__zone {
position: relative;
display: grid;
place-items: center;
width: min(90vw,320px);
height: 240px;
border-radius: 28px;
border: 1px dashed oklch(1 0 0/.14);
}
.pp-21__hint {
position: absolute;
bottom: 14px;
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 10.5px;
letter-spacing: .18em;
text-transform: uppercase;
color: var(--pp-21-mut);
opacity: .8;
transition: opacity .3s;
}
.pp-21__zone:hover .pp-21__hint {
opacity: 0;
}
.pp-21__btn {
display: grid;
place-items: center;
width: 116px;
height: 116px;
border: 0;
border-radius: 50%;
background: var(--pp-21-acc);
color: oklch(0.18 0.04 95);
cursor: pointer;
translate: var(--pp-21-x,0) var(--pp-21-y,0);
box-shadow: 0 18px 44px oklch(0.6 0.14 92/.28);
transition: translate .35s cubic-bezier(.22,1,.36,1),scale .3s cubic-bezier(.34,1.4,.5,1),background .3s;
}
.pp-21__btn:hover,
.pp-21__btn:focus-visible {
scale: 1.08;
}
.pp-21__btn:active {
scale: .96;
}
.pp-21__btn:focus-visible {
outline: 3px solid var(--pp-21-ink);
outline-offset: 6px;
}
.pp-21__icon {
display: grid;
place-items: center;
translate: calc(var(--pp-21-x,0px) * .35) calc(var(--pp-21-y,0px) * .35);
transition: translate .45s cubic-bezier(.22,1,.36,1);
}
.pp-21__q {
display: none;
}
.pp-21__btn[aria-pressed="true"] {
background: oklch(0.98 0.01 95);
}
.pp-21__btn[aria-pressed="true"] .pp-21__p {
display: none;
}
.pp-21__btn[aria-pressed="true"] .pp-21__q {
display: block;
}
.pp-21__sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.pp-21__cap {
text-align: center;
}
.pp-21__title {
font-size: 19px;
font-weight: 660;
letter-spacing: -.015em;
}
.pp-21__sub {
font-size: 13px;
color: var(--pp-21-mut);
margin-top: 5px;
}
.pp-21__chip {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11.5px;
color: var(--pp-21-mut);
padding: 7px 14px;
border: 1px solid oklch(1 0 0/.13);
border-radius: 99px;
text-align: center;
margin-top: 6px;
}
@media (prefers-reduced-motion: reduce) {
.pp-21__btn,
.pp-21__icon {
translate: 0 0 !important;
transition-duration: .01ms !important;
}
}
```
## JavaScript
```js
(() => {
const fine = window.matchMedia('(hover:hover) and (pointer:fine)').matches;
const calm = window.matchMedia('(prefers-reduced-motion:reduce)').matches;
document.querySelectorAll('.pp-21__zone').forEach((zone) => {
if (zone.dataset.wired) return;
zone.dataset.wired = '1';
const btn = zone.querySelector('.pp-21__btn');
const sr = zone.querySelector('.pp-21__sr');
btn.addEventListener('click', () => {
const on = btn.getAttribute('aria-pressed') !== 'true';
btn.setAttribute('aria-pressed', String(on));
sr.textContent = (on ? 'Pause' : 'Play') + ' the showreel';
});
if (!fine || calm) return;
let box = null;
const reset = () => { btn.style.setProperty('--pp-21-x', '0px'); btn.style.setProperty('--pp-21-y', '0px'); };
zone.addEventListener('pointerenter', () => { box = btn.getBoundingClientRect(); });
zone.addEventListener('pointermove', (e) => {
if (!box) box = btn.getBoundingClientRect();
btn.style.setProperty('--pp-21-x', (e.clientX - (box.left + box.width / 2)) * 0.32 + 'px');
btn.style.setProperty('--pp-21-y', (e.clientY - (box.top + box.height / 2)) * 0.32 + 'px');
});
zone.addEventListener('pointerleave', () => { box = null; reset(); });
btn.addEventListener('blur', reset);
});
})();
```How this works
JS measures, CSS renders. Never the other way around:
zone.addEventListener('pointermove', (e) => {
const r = btn.getBoundingClientRect();
const dx = e.clientX - (r.left + r.width / 2);
const dy = e.clientY - (r.top + r.height / 2);
btn.style.setProperty('--pp-21-x', dx * 0.32 + 'px'); /* pull strength */
btn.style.setProperty('--pp-21-y', dy * 0.32 + 'px');
});
zone.addEventListener('pointerleave', () => { /* reset both to 0 */ });.btn{ translate:var(--pp-21-x,0) var(--pp-21-y,0);
transition:translate .35s cubic-bezier(.22,1,.36,1); }
.btn .icon{ translate:calc(var(--pp-21-x) * .35) calc(var(--pp-21-y) * .35); }Two decisions make it feel expensive. The pull factor (0.32) is under a third of the cursor distance — more and the button feels unglued. The return easing is a long ease-out spring, while the follow is barely eased at all, so it tracks crisply but relaxes slowly.
The icon multiplies the same variables by 0.35, so it drifts inside the button — a parallax cue that reads as depth. And the magnetic zone is larger than the button (a padded wrapper), which is what gives the effect its 'reaching out' quality; without it the attraction only starts once you are already on the target.
Make it yours
- Pull strength: 0.2 subtle, 0.32 balanced, 0.5 playful. Scale it down as the button gets bigger or it will look unmoored.
- Rotation tilt: feed the same deltas into
rotate:calc(var(--pp-21-x) * .06deg)for a 3D lean. - Cursor swap: hide the native cursor inside the zone and render a custom dot that scales up over the button.
- Stagger a row: give each button its own zone; nearby buttons leaning toward a shared cursor reads beautifully on a nav bar.
Gotchas — read before shipping
- Guard with
matchMedia('(hover:hover) and (pointer:fine)')— on touch, pointermove fires during scroll and the button drifts randomly. - Honour prefers-reduced-motion by skipping the listener entirely; a transition-duration override is not enough, because the position still changes.
- Read layout ONCE per pointerenter, not per move: getBoundingClientRect in a pointermove handler forces a reflow on every event.
- Magnetism is decorative — the button must remain fully usable by keyboard. Focus here also triggers the visual state so keyboard users aren't shown a dead control.
- Always reset the properties on pointerleave AND on blur, or a button can stay stuck offset after the pointer leaves the window.
Browser support
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| 111+ | 16.4+ | 113+ | 111+ |
Pointer Events: universal. Independent translate: Chrome 104 / Safari 14.1 / Firefox 72 (use transform:translate() as a fallback). matchMedia hover/pointer queries: all modern engines.