A restaurant's booking page often ends with a short list of switches: text me when my table is ready, remind me the day before, send me the specials. People flick them quickly and do not always notice which way each one went. This proactive toggle shows its answer before the click. As your pointer comes near, the knob starts sliding towards its other side and the track tints to match, so you can see what will happen. Nothing changes until you actually click.
Your booking alerts
- Text me when my table is readyOne message, no marketing
- Remind me the day beforeSent at 10 am
- Email the weekly specialsFridays, easy to stop
How it works
- Real switches. Each control is a
buttonwithrole="switch"andaria-checked. The words beside it are its label, and clicking them works too. - Measure the distance. While the pointer moves inside the card, the script works out how far it is from the centre of every switch. Inside 120 pixels, that distance becomes a number between 0 and 1.
- Lean, do not flip. The number is squared, so the lean stays small until you are close, then capped at 40 percent of the full travel. It goes into a CSS custom property,
--lean, which moves and slightly stretches the knob and fades the green fill in or out. - Click to commit. A click changes
aria-checked, the knob springs home with a small overshoot, and a live region says what changed. That switch then stays still until the pointer moves away, so it does not instantly lean back towards the old setting. - Keyboard and touch. Tabbing onto a switch shows the same preview while it has focus. On touch screens there is no hovering pointer, so a tap simply flips the switch.
The code
Three short blocks with no libraries; the switches are ordinary buttons you can wire to your own form or settings code.
<div class="pt" data-lab="proactive-toggle">
<div class="pt-card">
<p class="pt-title">Your booking alerts</p>
<ul class="pt-list">
<li class="pt-row">
<span class="pt-text" id="pt-l1"><b>Text me when my table is ready</b><small>One message, no marketing</small></span>
<button class="pt-sw" type="button" role="switch" aria-checked="true" aria-labelledby="pt-l1"><span class="pt-knob" aria-hidden="true"></span></button>
</li>
<li class="pt-row">
<span class="pt-text" id="pt-l2"><b>Remind me the day before</b><small>Sent at 10 am</small></span>
<button class="pt-sw" type="button" role="switch" aria-checked="false" aria-labelledby="pt-l2"><span class="pt-knob" aria-hidden="true"></span></button>
</li>
<li class="pt-row">
<span class="pt-text" id="pt-l3"><b>Email the weekly specials</b><small>Fridays, easy to stop</small></span>
<button class="pt-sw" type="button" role="switch" aria-checked="false" aria-labelledby="pt-l3"><span class="pt-knob" aria-hidden="true"></span></button>
</li>
</ul>
<p class="pt-status" aria-live="polite"></p>
</div>
</div>.pt {
display: grid;
justify-items: center;
padding: 8px 0;
font-family: system-ui, sans-serif;
color: #101a14;
}
.pt-card {
width: 100%;
max-width: 420px;
box-sizing: border-box;
padding: 22px 22px 14px;
border: 1px solid #c9cec4;
border-radius: 16px;
background: #fff;
}
.pt-title { margin: 0 0 6px; font: 700 18px/1.3 system-ui, sans-serif; color: #133621; }
.pt .pt-card ul.pt-list { margin: 0; padding: 0; list-style: none; }
.pt .pt-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin: 0;
padding: 14px 0;
border-top: 1px solid #c9cec4;
}
.pt-row:first-child { border-top: 0; }
.pt-text { display: grid; gap: 3px; cursor: pointer; font-size: 15px; line-height: 1.35; }
.pt-text b { font-weight: 600; }
.pt-text small { font-size: 13px; color: #46534a; }
/* The switch. --lean (0 to 1) is set by the script as the pointer comes near. */
.pt-sw {
--lean: 0;
--pos: var(--lean);
position: relative;
flex: none;
width: 56px;
height: 32px;
margin: 0;
padding: 0;
border: 0;
border-radius: 999px;
background: #c9cec4;
cursor: pointer;
overflow: hidden;
}
.pt-sw[aria-checked="true"] { --pos: calc(1 - var(--lean)); }
/* Green fill fades in as the knob leans towards "on", out as it leans to "off". */
.pt-sw::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
background: #133621;
opacity: var(--pos);
transition: opacity .18s ease-out;
}
.pt-knob {
position: absolute;
top: 4px;
left: 4px;
width: 24px;
height: 24px;
border-radius: 50%;
background: #fff;
box-shadow: 0 1px 3px rgba(16, 26, 20, .3);
transform: translateX(calc(var(--pos) * 24px)) scaleX(calc(1 + var(--lean) * .5));
transform-origin: left center;
transition: transform .18s ease-out, background-color .2s;
}
.pt-sw[aria-checked="true"] .pt-knob { background: #f4d96c; transform-origin: right center; }
/* Right after a click the knob snaps home with a little overshoot. */
.pt-sw.is-snap .pt-knob { transition: transform .32s cubic-bezier(.3, 1.6, .5, 1), background-color .2s; }
.pt-sw:focus-visible { outline: 3px solid #f4d96c; outline-offset: 3px; }
.pt-status { min-height: 1.4em; margin: 6px 0 0; font-size: 14px; font-weight: 600; color: #133621; }
@media (prefers-reduced-motion: reduce) {
.pt-sw::before, .pt-knob, .pt-sw.is-snap .pt-knob { transition: none; }
}(function () {
var root = document.querySelector('[data-lab="proactive-toggle"]');
if (!root) return;
var switches = root.querySelectorAll('.pt-sw');
var status = root.querySelector('.pt-status');
var RANGE = 120; // pixels from a switch where it starts to lean
var MAX_LEAN = 0.4; // how far the knob travels before the click (0 to 1)
var calm = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var resting = []; // a switch just clicked waits until the pointer leaves
function setLean(sw, v) { sw.style.setProperty('--lean', v.toFixed(3)); }
// The closer the pointer, the further the knob leans towards its other side.
function onMove(e) {
if (calm || e.pointerType === 'touch') return;
for (var i = 0; i < switches.length; i++) {
var r = switches[i].getBoundingClientRect();
var dx = e.clientX - (r.left + r.width / 2);
var dy = e.clientY - (r.top + r.height / 2);
var near = Math.max(0, 1 - Math.sqrt(dx * dx + dy * dy) / RANGE);
if (resting[i]) {
if (near > 0) { setLean(switches[i], 0); continue; }
resting[i] = false;
}
setLean(switches[i], near * near * MAX_LEAN);
}
}
function relaxAll() {
for (var i = 0; i < switches.length; i++) {
resting[i] = false;
if (document.activeElement !== switches[i]) setLean(switches[i], 0);
}
}
function flip(i) {
var sw = switches[i];
var on = sw.getAttribute('aria-checked') !== 'true';
sw.setAttribute('aria-checked', on ? 'true' : 'false');
resting[i] = true;
sw.classList.add('is-snap');
setLean(sw, 0);
setTimeout(function () { sw.classList.remove('is-snap'); }, 340);
var name = root.querySelector('#' + sw.getAttribute('aria-labelledby') + ' b').textContent;
status.textContent = name + (on ? ': on.' : ': off.');
}
function bind(i) {
var sw = switches[i];
sw.addEventListener('click', function () { flip(i); });
// A keyboard user gets the same preview while the switch has focus.
sw.addEventListener('keyup', function (e) {
if (e.key === 'Tab' && !calm && !resting[i]) setLean(sw, MAX_LEAN);
});
sw.addEventListener('blur', function () { resting[i] = false; setLean(sw, 0); });
// Clicking the words beside a switch flips it too.
root.querySelector('#' + sw.getAttribute('aria-labelledby')).addEventListener('click', function () { sw.click(); });
}
for (var i = 0; i < switches.length; i++) { resting[i] = false; bind(i); }
root.addEventListener('pointermove', onMove);
root.addEventListener('pointerleave', relaxAll);
})();When to use it, and when not to
It suits short lists of yes or no choices where getting it wrong is mildly annoying: notification settings, booking extras, "keep me signed in", a delivery note option at checkout. The preview answers the old question of whether a grey switch means on or off, because you see it move before you commit. It also adds a little life to a part of the page that is usually dull, without getting in the way.
It is a poor fit for long settings pages with dozens of switches stacked closely, where several would lean at once as you move down the list and the effect turns into noise. Shrink RANGE or skip it there. It is also not a replacement for clear wording: a vague label is still vague however nicely the knob moves. People who ask their system for reduced motion get plain switches with no lean and no animation, and screen reader users hear the normal switch state plus the live message, so the preview is a visual extra rather than something anyone depends on.
Make it yours
- How early it reacts: change
RANGE. Around 80 pixels feels subtle; 160 makes the switch notice you from across the card. - How far it leans: change
MAX_LEAN. Keep it under 0.5 so a leaning switch never looks as if it has already changed. - Colours and size: the off track is the
.pt-swbackground, the on fill is.pt-sw::before, the gold knob is set on.pt-sw[aria-checked="true"] .pt-knob. If you resize the switch, update the 24px travel in the.pt-knobtransform.
Frequently asked questions
Will the switch ever change without a click?
No. The lean is only a preview drawn with CSS. The real state, aria-checked, changes only on a click, a tap, or Space and Enter on the keyboard.
How do I send the setting to my server or form?
Add your own code at the end of flip(), where the new value is known, for example a fetch call or updating a hidden input that your form submits.
Does it slow the page down?
Hardly. The script only does work while a pointer moves over the card, reads the position of three small buttons, and sets one CSS property each. When the pointer leaves, it stops.