Musée de la Batellerie · reception kioskChoose your tour
Mouse demo of the future kiosk's pointer
Three glass buttons (Select, Grab, Point) and a 24 px ring that follows the pointer across the whole frame, jumps onto the button that gets the keyboard focus and squeezes to 80% when you press: a simulation of a virtual hand pointer, driven by the mouse, a finger or the keyboard — no camera and no hand detection, despite its name.
Updated
This effect is free — the 3D category contains 50 effects total, including 8 free. Effect.Labs has 840 vanilla effects. Explore the category →
Musée de la Batellerie · reception kioskChoose your tour
Mouse demo of the future kiosk's pointer
A thick ring follows your mouse and squeezes when you click: the pointer stays easy to spot on every page.
Enlarged pointer: on
Slide 4 / 12Quarterly results
The root .fx-0048-root (250 px tall, rgb(26, 26, 36) background) holds .xr-hand-tracking, an area spanning the whole frame that centers three real <button type="button"> .xr-hand-btn buttons: indigo fill at 15%, indigo border at 30%, #a1a1aa text at 0.8rem, 8 px corners. Above them sits the ring .xr-hand-cursor: 24 px, 2 px #6366f1 border, center tinted at 30%, pointer-events: none so it never gets in the way of a click. At first it waits below the row of buttons, centered, without covering one.
The script goes through every root on the page and wires one instance per root. On each pointer move or press inside the area, it centers the ring under the pointer (left and top positions); when the pointer leaves, the ring stays at its last position. When a button gets the keyboard focus (:focus-visible), the ring jumps onto its center. A click, Enter or Space marks that button as active (active class and aria-pressed="true", the other two go back to false) and squeezes the ring to 80% for 200 ms. Measured in Chromium 151 and Firefox 153: two Tab presses put the ring at the center of "Grab", Enter marks it active; two copies on one page each have their own ring.
The catalog name speaks of hand tracking: the code uses no camera and no gesture detection. It shows what a virtual hand pointer would look like, and the ring follows anything that points: mouse, pen, finger (touch-action: pan-y keeps vertical scrolling, a sideways drag moves the ring, a tap places it and activates the button touched) and keyboard focus. For real hand tracking, you would need a detection library hooked to the camera, calling the same moveTo(x, y) function with the finger position. The buttons are real buttons: Tab, Enter and Space work with no extra code, and screen readers announce their pressed state.
prefers-reduced-motion: reduce, a block scoped to .fx-0048-root removes the ring squeeze (transform: none), its transitions and the buttons' growth on hover. The ring still follows the pointer, and click feedback stays visible through the active button's color. Measured in Chromium, Firefox and WebKit: no change of scale on the ring after a click.#a5b4fc outline (8.7:1 contrast on the background) and the ring jumping onto the button; Enter or Space activates it. Safari only gives Tab focus to buttons when the system keyboard option is turned on (measured in WebKit: no focus by default); Chromium and Firefox always do.aria-hidden="true"; the buttons announce their name and state (aria-pressed). If your buttons trigger a real action, keep aria-pressed for an exclusive choice, or remove it for a one-off action.#a1a1aa text on the button at 5.8:1 at rest, 4.1:1 on the active button; #6366f1 ring at 3.9:1 on the background, above the 3:1 threshold for graphics.CSS (absolute positioning, calc(), transition, touch-action, :focus-visible) + JS with pointer, focusin and click events, no dependencies. Measured in Chromium 151, Firefox 153 and WebKit 26.5: the ring follows the mouse, squeezes on click, two independent copies; with the keyboard in Chromium and Firefox; with a finger in Chromium in touch mode. No CSS 3D and no WebXR: nothing beyond the pointer is simulated in the browser.
Without :focus-visible (browsers older than those listed), the ring no longer jumps onto the button with the keyboard but the buttons stay usable; without JavaScript, the three buttons stay clickable and the ring stays still below the row.
Copy the three blocks into your page. No dependencies.
<div class="fx-0048-root">
<div class="xr-hand-tracking">
<button type="button" class="xr-hand-btn" aria-pressed="false">Select</button>
<button type="button" class="xr-hand-btn" aria-pressed="false">Grab</button>
<button type="button" class="xr-hand-btn" aria-pressed="false">Point</button>
<div class="xr-hand-cursor" aria-hidden="true"></div>
</div>
</div>
.fx-0048-root,
.fx-0048-root *,
.fx-0048-root *::before,
.fx-0048-root *::after {
box-sizing: border-box
}
.fx-0048-root {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 250px;
overflow: hidden;
position: relative;
background: rgb(26, 26, 36);
color: rgb(255, 255, 255);
font-family: sans-serif;
}
/* Site design system variables this code depends on (values from css/style.css — adapt freely). */
.fx-0048-root {
--text-secondary: #a1a1aa;
}
.xr-hand-tracking {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
position: relative;
/* Touch: a sideways drag moves the ring, a vertical swipe still scrolls the page */
touch-action: pan-y;
}
.xr-hand-cursor {
width: 24px;
height: 24px;
border: 2px solid #6366f1;
border-radius: 50%;
position: absolute;
/* Starting position: below the row of buttons, not covering one */
left: calc(50% - 12px);
top: calc(50% + 34px);
pointer-events: none;
transition: transform 0.1s ease;
}
.xr-hand-cursor::after {
content: '';
position: absolute;
inset: 4px;
border-radius: 50%;
background: rgba(99, 102, 241, 0.3);
}
.xr-hand-btn {
font-family: inherit;
padding: 10px 20px;
background: rgba(99, 102, 241, 0.15);
border: 1px solid rgba(99, 102, 241, 0.3);
border-radius: 8px;
color: var(--text-secondary);
font-size: 0.8rem;
cursor: pointer;
transition: all 0.3s ease;
margin: 0 8px;
}
.xr-hand-btn:hover {
background: rgba(99, 102, 241, 0.3);
transform: scale(1.05);
}
.xr-hand-btn.active {
background: rgba(99, 102, 241, 0.4);
border-color: #6366f1;
}
.xr-hand-btn:focus-visible {
outline: 2px solid #a5b4fc;
outline-offset: 2px;
}
/* Reduced motion: the ring still follows the pointer, but it no longer squeezes and the buttons no longer grow */
@media (prefers-reduced-motion: reduce) {
.fx-0048-root .xr-hand-cursor {
transform: none !important;
transition: none;
}
.fx-0048-root .xr-hand-btn {
transition: none;
}
.fx-0048-root .xr-hand-btn:hover {
transform: none;
}
}
(function() {
// Hand Tracking UI — the ring follows the mouse, the finger or the keyboard focus (fx-0048)
// One instance per .fx-0048-root: several copies on one page each have their own ring.
document.querySelectorAll('.fx-0048-root').forEach(function(root) {
var container = root.querySelector('.xr-hand-tracking');
var cursor = root.querySelector('.xr-hand-cursor');
if (!container || !cursor) return;
var buttons = container.querySelectorAll('.xr-hand-btn');
// x, y: coordinates in the window (pointer, or center of the focused button)
function moveTo(x, y) {
var rect = container.getBoundingClientRect();
cursor.style.left = (x - rect.left - 12) + 'px';
cursor.style.top = (y - rect.top - 12) + 'px';
}
function follow(e) {
moveTo(e.clientX, e.clientY);
}
container.addEventListener('pointermove', follow);
container.addEventListener('pointerdown', follow);
// Keyboard: the ring jumps onto the button that gets the focus (Tab), Enter or Space presses it
container.addEventListener('focusin', function(e) {
var btn = e.target.closest('.xr-hand-btn');
if (!btn || !btn.matches(':focus-visible')) return;
var r = btn.getBoundingClientRect();
moveTo(r.left + r.width / 2, r.top + r.height / 2);
});
buttons.forEach(function(btn) {
btn.addEventListener('click', function() {
buttons.forEach(function(b) {
b.classList.remove('active');
b.setAttribute('aria-pressed', 'false');
});
btn.classList.add('active');
btn.setAttribute('aria-pressed', 'true');
cursor.style.transform = 'scale(0.8)';
setTimeout(function() {
cursor.style.transform = 'scale(1)';
}, 200);
});
});
});
})();
Options passed to the API or data-* attributes:
| Option / property | Default | Effect |
|---|---|---|
Ring size (CSS — width and height of .xr-hand-cursor; JS — the 12 offset) |
24 px | The script centers the ring by subtracting 12 px: for a larger ring, change both 12 values to half its size, or compensate with a negative margin as in the second scene (40 px, margin: -8px 0 0 -8px). |
Ring colors (CSS — border of .xr-hand-cursor, background of its ::after) |
#6366f1, center at 30% | Keep at least 3:1 contrast with the background so the ring stays easy to spot. |
Click squeeze (JS — scale(0.8) and 200) |
80% for 200 ms | 0.6 gives a stronger "pinch"; past 300 ms the feedback feels slow. |
Starting position (CSS — left and top of .xr-hand-cursor) |
centered, 34 px below the middle | Put it where it hides nothing. Remove these two lines and the ring would start at the exact center, on the middle button. |
Buttons (HTML — text of the <button> elements; CSS — .xr-hand-btn and .active) |
Select, Grab, Point; indigo at 15, 30 and 40% | The script takes every .xr-hand-btn in the area, whatever their number. Keep <button> elements: keyboard support and the pressed state work with no extra code. |
Height (CSS — height: 250px of .fx-0048-root) |
250 px | The whole area follows the pointer: make it larger to cover an entire section. |
No. It uses no camera and no gesture detection: the ring follows the mouse, a pen, a finger on a touch screen and the keyboard focus. The name describes the intended use, a virtual hand interface; to hook it to real tracking, you would need a detection library calling moveTo(x, y) with the position of the filmed finger.
At the exact center, it would cover the middle button before any movement and look as if that button were already targeted. So it waits 34 px below the middle of the area, under the row of buttons; as soon as the pointer moves or a button gets the focus, it joins it.
Yes. Each copy has its own .fx-0048-root and the script starts one instance per root, without ids: each area has its own ring and active buttons. That is the case on this page, where the demo at the top and the three examples run on a single script.