Virtual Joystick
An on-screen thumb stick for touch games and controls. The knob follows your finger inside its ring and springs back to the centre with a little wobble when you let go. Point it at an element and it steers it around its box.
Drag the stick, or focus it and use the arrow keys
How to use it
- Paste the snippet just before
</head>. - Add
data-virtual-joystickto an empty element. The script draws the ring and knob. - Listen for
joystickto read the direction, or give itdata-targetto steer something for you.
Options
Add any of these attributes to the same element.
data-size | The ring’s width in px. Default 120. |
data-target | A CSS selector for an element to steer. It moves around its positioned parent and turns to face the way it’s going. |
data-speed | How fast the target moves at full tilt, in px per second. Default 220. |
data-dead-zone | Share of the ring near the centre that counts as still. Default 0.12. |
data-autoplay | A pretend thumb steers it on a wandering path, pausing every so many ms. Default 1800. A real touch pauses it. |
joystick fires with detail.x and detail.y, each from −1 to 1 (y goes down). Focus it and hold the arrow keys to steer from the keyboard.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Virtual Joystick: an on-screen thumb stick. Drag the knob and it follows
// your finger within its ring; let go and it springs back to the centre
// with a little wobble. It reports a direction from -1 to 1 on each axis,
// and can steer an element around its box for you.
// Usage: <div data-virtual-joystick></div>
// Optional: data-size (ring diameter in px, default 120), data-target (a CSS
// selector for something to steer, looked up next to the joystick first;
// it moves around its positioned parent and turns to face its way),
// data-speed (px per second at full tilt, default 220), data-dead-zone
// (share of the radius that counts as centred, default 0.12), data-autoplay
// (a pretend thumb steers it while in view, pausing every so many ms,
// default 1800; a real touch pauses it).
// Events: "joystick" with detail.x and detail.y (-1 to 1, y down) as it moves.
// Keyboard: focus it and hold the arrow keys.
var DEFAULTS = { size: 120, speed: 220, deadZone: 0.12, autoplay: 1800 };
var css =
'.vjs{position:relative;flex:none;border-radius:50%;touch-action:none;user-select:none;-webkit-user-select:none;cursor:grab;outline-offset:4px;' +
'background:radial-gradient(circle,rgba(255,255,255,.14) 0 55%,rgba(255,255,255,.04) 56%);border:2px solid rgba(255,255,255,.35);' +
'box-shadow:inset 0 2px 12px rgba(0,0,0,.25),0 6px 20px rgba(0,0,0,.18)}' +
'.vjs.vjs-held{cursor:grabbing}' +
'.vjs-ring{position:absolute;inset:14%;border-radius:50%;border:1px dashed rgba(255,255,255,.3);pointer-events:none}' +
'.vjs-arrow{position:absolute;left:50%;top:50%;width:0;height:0;border:5px solid transparent;border-bottom:6px solid rgba(255,255,255,.55);pointer-events:none}' +
'.vjs-knob{position:absolute;left:50%;top:50%;width:46%;height:46%;margin:-23% 0 0 -23%;border-radius:50%;pointer-events:none;will-change:transform;' +
'background:radial-gradient(circle at 35% 30%,#fff,#e9e3f5 45%,#b9b0cc);box-shadow:0 6px 14px rgba(0,0,0,.3),inset 0 -3px 6px rgba(0,0,0,.12)}' +
'.vjs-finger{position:absolute;left:50%;top:50%;width:34px;height:34px;margin:-17px 0 0 -17px;border-radius:50%;' +
'background:rgba(255,255,255,.35);border:2px solid rgba(255,255,255,.9);pointer-events:none;opacity:0}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function setup(el) {
var d = el.dataset;
var size = +d.size || DEFAULTS.size;
var speed = +d.speed || DEFAULTS.speed;
var dead = d.deadZone !== undefined ? +d.deadZone : DEFAULTS.deadZone;
var thumb = !!el.closest('[aria-hidden="true"]');
el.classList.add('vjs');
el.style.width = el.style.height = size + 'px';
if (!thumb) {
el.tabIndex = 0;
el.setAttribute('role', 'application');
if (!el.hasAttribute('aria-label')) el.setAttribute('aria-label', 'Joystick: hold the arrow keys to steer');
}
var ring = document.createElement('span');
ring.className = 'vjs-ring';
el.appendChild(ring);
[0, 90, 180, 270].forEach(function (a) {
var ar = document.createElement('span');
ar.className = 'vjs-arrow';
ar.style.transform = 'translate(-50%,-50%) rotate(' + a + 'deg) translateY(' + (-size * 0.42) + 'px)';
el.appendChild(ar);
});
var knob = document.createElement('span');
knob.className = 'vjs-knob';
el.appendChild(knob);
el.querySelectorAll('span').forEach(function (n) { n.setAttribute('aria-hidden', 'true'); });
var sel = d.target;
var target = sel ? ((el.parentElement && el.parentElement.querySelector(sel)) || document.querySelector(sel)) : null;
var s = el._vjs = { x: 0, y: 0, userAt: -1e9, anim: null, held: false };
var keys = {};
var radius = function () { return el.clientWidth * 0.33; };
function setKnob(px, py) {
var r = radius();
var len = Math.hypot(px, py);
if (len > r) { px = px / len * r; py = py / len * r; }
s.x = px / r; s.y = py / r;
anime.utils.set(knob, { x: px, y: py, scale: s.held ? 1.06 : 1 });
var m = Math.hypot(s.x, s.y), out = m < dead ? 0 : (m - dead) / (1 - dead) / (m || 1);
el.dispatchEvent(new CustomEvent('joystick', { detail: { x: s.x * out, y: s.y * out } }));
kick();
}
function grab() {
if (s.anim) { s.anim.pause(); s.anim = null; }
s.held = true;
el.classList.add('vjs-held');
}
function release() {
if (!s.held) return;
s.held = false;
el.classList.remove('vjs-held');
var r = radius(), o = { x: s.x * r, y: s.y * r };
s.anim = anime.animate(o, {
x: 0, y: 0,
duration: reduced() ? 0 : 700,
ease: 'outElastic(1, .5)',
onUpdate: function () { setKnob(o.x, o.y); },
});
}
s.grab = grab; s.release = release; s.setKnob = setKnob;
function centre() {
var b = el.getBoundingClientRect();
return { x: b.left + b.width / 2, y: b.top + b.height / 2 };
}
if (!thumb) {
el.addEventListener('pointerdown', function (e) {
if (e.button) return;
e.preventDefault();
s.userAt = performance.now();
hideFinger();
el.setPointerCapture(e.pointerId);
el.focus({ preventScroll: true });
grab();
var c = centre();
setKnob(e.clientX - c.x, e.clientY - c.y);
});
el.addEventListener('pointermove', function (e) {
if (!s.held || !el.hasPointerCapture(e.pointerId)) return;
s.userAt = performance.now();
var c = centre();
setKnob(e.clientX - c.x, e.clientY - c.y);
});
el.addEventListener('pointerup', release);
el.addEventListener('pointercancel', release);
var dirs = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] };
function fromKeys() {
var x = 0, y = 0;
Object.keys(keys).forEach(function (k) { x += dirs[k][0]; y += dirs[k][1]; });
if (!x && !y) { release(); return; }
grab();
var len = Math.hypot(x, y), r = radius();
setKnob(x / len * r, y / len * r);
}
el.addEventListener('keydown', function (e) {
if (!dirs[e.key]) return;
e.preventDefault();
s.userAt = performance.now();
keys[e.key] = 1;
fromKeys();
});
el.addEventListener('keyup', function (e) {
if (!dirs[e.key]) return;
delete keys[e.key];
fromKeys();
});
el.addEventListener('blur', function () { keys = {}; release(); });
}
// Steer the target around its box while the stick is off centre.
var raf = 0, last = 0, pos = null, heading = 0, onScreen = true;
function kick() { if (target && !raf && onScreen) { last = performance.now(); raf = requestAnimationFrame(tick); } }
function tick(now) {
raf = 0;
var dt = Math.min(0.05, (now - last) / 1000);
last = now;
var box = target.offsetParent || target.parentElement;
var bw = box.clientWidth - target.offsetWidth, bh = box.clientHeight - target.offsetHeight;
if (!pos) pos = { x: bw / 2, y: bh / 2 };
var m = Math.hypot(s.x, s.y);
if (m < dead) return;
var k = (m - dead) / (1 - dead) / m;
pos.x = Math.max(0, Math.min(bw, pos.x + s.x * k * speed * dt));
pos.y = Math.max(0, Math.min(bh, pos.y + s.y * k * speed * dt));
var want = Math.atan2(s.y, s.x) * 180 / Math.PI + 90;
var diff = ((want - heading) % 360 + 540) % 360 - 180;
heading += diff * Math.min(1, dt * 12);
anime.utils.set(target, { x: pos.x, y: pos.y, rotate: heading });
raf = requestAnimationFrame(tick);
}
if (target) {
target.style.position = 'absolute';
target.style.left = target.style.top = '0';
var b0 = target.offsetParent || target.parentElement;
pos = { x: (b0.clientWidth - target.offsetWidth) / 2, y: (b0.clientHeight - target.offsetHeight) / 2 };
anime.utils.set(target, { x: pos.x, y: pos.y, rotate: 0 });
new ResizeObserver(function () {
var bx = b0.clientWidth - target.offsetWidth, by = b0.clientHeight - target.offsetHeight;
pos.x = Math.max(0, Math.min(bx, pos.x)); pos.y = Math.max(0, Math.min(by, pos.y));
anime.utils.set(target, { x: pos.x, y: pos.y });
}).observe(b0);
}
var finger = null, visible = false, timer = 0;
function hideFinger() { if (finger) anime.animate(finger, { opacity: 0, duration: 200 }); }
new IntersectionObserver(function (entries) {
onScreen = visible = entries[0].isIntersecting;
clearTimeout(timer);
if (visible && finger) timer = setTimeout(play, 600);
}).observe(el);
if (d.autoplay === undefined || d.autoplay === 'false' || reduced()) return;
var every = +d.autoplay || DEFAULTS.autoplay;
finger = document.createElement('span');
finger.className = 'vjs-finger';
finger.setAttribute('aria-hidden', 'true');
el.appendChild(finger);
var tour = 0;
// Push the stick round a wandering path, then let it spring back.
function play() {
if (!visible) return;
if (performance.now() - s.userAt < 5000 || s.held) { timer = setTimeout(play, 900); return; }
var r = radius(), a0 = Math.random() * Math.PI * 2, turn = (tour++ % 2 ? -1 : 1) * (1.2 + Math.random() * 1.6);
var o = { t: 0 };
anime.animate(finger, { opacity: 1, duration: 200 });
grab();
anime.animate(o, {
t: 1,
duration: 2400,
ease: 'inOut(1.5)',
onUpdate: function () {
if (!s.held || performance.now() - s.userAt < 5000) return;
var reach = Math.min(1, o.t * 5, (1 - o.t) * 6) * r * 1.1;
var a = a0 + turn * o.t * Math.PI;
var x = Math.cos(a) * reach, y = Math.sin(a) * reach;
setKnob(x, y);
anime.utils.set(finger, { x: s.x * r, y: s.y * r });
},
onComplete: function () {
if (performance.now() - s.userAt < 5000) { timer = setTimeout(play, 900); return; }
release();
anime.animate(finger, { opacity: 0, duration: 250 });
timer = setTimeout(play, every);
},
});
}
}
function init() {
document.querySelectorAll('[data-virtual-joystick]').forEach(function (el) {
if (el._vjs) return;
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div class="arena" style="position: relative; height: 300px">
<span class="ship">▲</span>
<div data-virtual-joystick data-target=".ship"></div>
</div>
<!-- or read it yourself: -->
<script>
document.querySelector('[data-virtual-joystick]').addEventListener('joystick', (e) => {
console.log(e.detail.x, e.detail.y);
});
</script>