On-screen Keyboard
A drawn phone keyboard types a message by itself. Each key dips with a pop-up of its letter as the character lands in the field above, capitals press Shift first, and between messages the delete key is held down to clear it. Visitors can tap the keys too.
How to use it
- Paste the snippet just before
</head>. - Add
data-on-screen-keyboardto an empty<div>and put the messages to type indata-text, separated by|.
Options
Add any of these attributes to the same element.
data-text | Messages to type, in turn. |
data-loop | Keep cycling through the messages. |
data-speed | Time per key press. Default 120 ms. |
data-hold | How long a finished message stays before it is deleted. Default 1800 ms. |
data-placeholder | Text in the empty field. Default “Message”. |
--osk-accent | CSS variable for the caret and the return key. Default the site purple. |
The keyboard is a picture, so it is hidden from screen readers and the element is labelled with the messages instead. A tap on a key types it, and the demo rests for a few seconds while someone plays.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// On-screen Keyboard: a drawn phone keyboard types a message by itself. Each
// key presses down (with a pop-up preview of its letter) as the character
// appears in the text field above; capitals press Shift first, and between
// messages the delete key is held down to clear the field.
// Usage: <div data-on-screen-keyboard data-text="Running 5 min late!|See you at the café?"></div>
// Optional: data-text (messages to type, separated by |), data-speed (ms per
// key, default 120), data-hold (ms a finished message stays, default 1800),
// data-placeholder (text in the empty field, default "Message"),
// data-loop (keep cycling; with a single message it retypes it).
// Visitors can tap the keys too: the demo rests while they type.
var DEFAULTS = { text: 'Hello there!', speed: 120, hold: 1800, placeholder: 'Message' };
var ROWS = [
'qwertyuiop'.split(''),
'asdfghjkl'.split(''),
['shift'].concat('zxcvbnm'.split(''), ['back']),
['123', ',', 'space', '.', 'return']
];
var LABELS = { shift: '⇧', back: '⌫', space: 'space', '123': '123', 'return': '↵' };
var css =
'.osk{text-align:left;user-select:none;-webkit-user-select:none}' +
'.osk-field{display:flex;align-items:center;min-height:2.6em;margin-bottom:0.7em;padding:0.55em 0.95em;border-radius:1.4em;' +
'background:#fff;border:1px solid rgba(0,0,0,.1);box-shadow:0 4px 14px rgba(0,0,0,.06);color:#111;overflow:hidden}' +
'.osk-out{display:block;flex:0 1 auto;min-width:0;white-space:pre;overflow:hidden}' +
'.osk-ph{color:#9ca3af;white-space:nowrap}' +
'.osk-caret{flex:none;display:inline-block;width:2px;height:1.15em;margin-left:1px;background:var(--osk-accent,#6a0dad);border-radius:1px}' +
'.osk-caret.osk-blink{animation:osk-blink 1.06s steps(1) infinite}@keyframes osk-blink{50%{opacity:0}}' +
'.osk-kb{display:flex;flex-direction:column;gap:0.5em;padding:0.55em 0.3em 0.7em;border-radius:0.9em;background:#d1d4db}' +
'.osk-row{display:flex;gap:0.3em;justify-content:center}' +
'.osk-row:nth-child(2){padding:0 5%}' +
'.osk-key{position:relative;flex:1 1 0;min-width:0;height:2.6em;display:grid;place-items:center;border-radius:0.4em;background:#fff;color:#111;' +
'box-shadow:0 1px 0 #898a8d;font-size:1em;transition:background-color .12s,transform .08s;cursor:pointer;touch-action:manipulation}' +
'.osk-wide{flex:1.45 1 0;background:#adb2bc;font-size:0.85em}' +
'.osk-space{flex:5 1 0;font-size:0.8em;color:#555}' +
'.osk-return{background:var(--osk-accent,#6a0dad);color:#fff}' +
'.osk-key.osk-down{background:#bfc3cb;transform:translateY(1px);box-shadow:none}' +
'.osk-wide.osk-down,.osk-shift-on{background:#fff}' +
'.osk-return.osk-down{background:var(--osk-accent,#6a0dad);filter:brightness(1.25)}' +
'.osk-pop{position:absolute;left:50%;bottom:100%;margin-bottom:0.25em;transform:translateX(-50%);min-width:1.8em;padding:0.15em 0.3em;' +
'border-radius:0.45em;background:#fff;color:#111;font-size:1.5em;text-align:center;box-shadow:0 4px 12px rgba(0,0,0,.25);pointer-events:none;z-index:2}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function sleep(ms) { return new Promise(function (r) { setTimeout(r, ms); }); }
function opt(d, k) { return d[k] !== undefined && d[k] !== '' ? d[k] : DEFAULTS[k]; }
function setup(el) {
var d = el.dataset;
el.classList.add('osk');
el.textContent = '';
var messages = opt(d, 'text').split('|');
el.setAttribute('role', 'img');
el.setAttribute('aria-label', 'A keyboard typing: ' + messages.join(' / '));
var field = document.createElement('div');
field.className = 'osk-field';
field.setAttribute('aria-hidden', 'true');
field.innerHTML = '<span class="osk-out"></span><span class="osk-caret osk-blink"></span><span class="osk-ph"></span>';
field.lastChild.textContent = opt(d, 'placeholder');
var kb = document.createElement('div');
kb.className = 'osk-kb';
kb.setAttribute('aria-hidden', 'true');
var keys = {};
ROWS.forEach(function (row) {
var r = document.createElement('div');
r.className = 'osk-row';
row.forEach(function (k) {
var key = document.createElement('span');
var special = LABELS[k] !== undefined;
key.className = 'osk-key' + (special && k !== 'space' ? ' osk-wide' : '') + (k === 'space' ? ' osk-space' : '') + (k === 'return' ? ' osk-return' : '');
key.textContent = special ? LABELS[k] : k;
key.dataset.k = k;
keys[k] = key;
r.appendChild(key);
});
kb.appendChild(r);
});
el.appendChild(field);
el.appendChild(kb);
var s = {
el: el, kb: kb, keys: keys, messages: messages, mi: 0, text: '', shift: false,
out: field.firstChild, caret: field.children[1], ph: field.lastChild, visible: false, wake: null, idleUntil: 0, run: 0
};
// Real taps type too, and pause the demo for a while.
kb.addEventListener('pointerdown', function (e) {
var key = e.target.closest && e.target.closest('.osk-key');
if (!key) return;
e.preventDefault();
s.idleUntil = Date.now() + 5000;
s.run++; // stop any demo typing in progress
press(s, key.dataset.k, true);
});
return s;
}
function render(s) {
s.out.textContent = s.text;
s.ph.style.display = s.text ? 'none' : '';
// Keep the end of a long message in view.
s.out.scrollLeft = s.out.scrollWidth;
}
function flash(s, k, pop) {
var key = s.keys[k];
if (!key) return;
key.classList.add('osk-down');
var bubble = null;
if (pop && k.length === 1 && /[a-z,.]/.test(k)) {
bubble = document.createElement('span');
bubble.className = 'osk-pop';
bubble.textContent = s.shift ? k.toUpperCase() : k;
key.appendChild(bubble);
}
setTimeout(function () {
key.classList.remove('osk-down');
if (bubble) bubble.remove();
}, 110);
}
function setShift(s, on) {
s.shift = on;
s.keys.shift.classList.toggle('osk-shift-on', on);
s.kb.querySelectorAll('.osk-key').forEach(function (key) {
var k = key.dataset.k;
if (k.length === 1 && /[a-z]/.test(k)) key.firstChild.textContent = on ? k.toUpperCase() : k;
});
}
// Press one key: update the text the way a phone keyboard would.
function press(s, k, pop) {
flash(s, k, pop);
if (k === 'shift') { setShift(s, !s.shift); return; }
if (k === 'back') s.text = s.text.slice(0, -1);
else if (k === 'space') s.text += ' ';
else if (k === 'return' || k === '123') return;
else {
s.text += s.shift ? k.toUpperCase() : k;
if (s.shift) setShift(s, false);
}
s.caret.classList.remove('osk-blink');
render(s);
}
function keyFor(ch) {
if (ch === ' ') return 'space';
var low = ch.toLowerCase();
return /[a-z,.]/.test(low) ? low : null;
}
function whenReady(s) {
return new Promise(function (r) {
(function check() {
if (!s.visible) { s.wake = check; return; }
var wait = s.idleUntil - Date.now();
if (wait > 0) { setTimeout(check, wait); return; }
r();
})();
});
}
function typeMessage(s, msg, speed, run) {
var chain = Promise.resolve();
msg.split('').forEach(function (ch) {
chain = chain.then(function () {
if (run !== s.run) return;
var k = keyFor(ch);
var upper = ch !== ch.toLowerCase();
var go = Promise.resolve();
if (upper && !s.shift) {
press(s, 'shift');
go = sleep(speed * 0.8);
}
return go.then(function () {
if (run !== s.run) return;
if (k) press(s, k, true);
else {
// Characters not on this layout (?, !, digits…) arrive without a key.
flash(s, '123');
s.text += ch;
render(s);
}
return sleep(speed * (0.6 + Math.random() * 0.8) + (ch === ' ' ? speed * 0.4 : 0));
});
});
});
return chain;
}
function erase(s, run) {
// Hold delete: it starts slowly and speeds up, as phones do.
var n = 0;
return new Promise(function (done) {
(function tick() {
if (run !== s.run || !s.text) { s.keys.back.classList.remove('osk-down'); done(); return; }
s.keys.back.classList.add('osk-down');
s.text = s.text.slice(0, -1);
render(s);
n++;
setTimeout(tick, n < 4 ? 140 : n < 10 ? 70 : 35);
})();
});
}
function loop(s) {
var d = s.el.dataset;
var speed = +opt(d, 'speed');
var looping = d.loop !== undefined && d.loop !== 'false';
whenReady(s).then(function () {
var run = ++s.run;
var msg = s.messages[s.mi % s.messages.length];
return erase(s, run)
.then(function () { return sleep(400); })
.then(function () { return typeMessage(s, msg, speed, run); })
.then(function () {
if (run !== s.run) return loop(s);
s.caret.classList.add('osk-blink');
press(s, 'return');
s.mi++;
if (!looping && s.mi >= s.messages.length) return;
return sleep(+opt(d, 'hold')).then(function () { loop(s); });
});
});
}
function init() {
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
var s = e.target._osk;
s.visible = e.isIntersecting;
if (s.visible && s.wake) { var w = s.wake; s.wake = null; w(); }
});
}, { threshold: 0.3 });
document.querySelectorAll('[data-on-screen-keyboard]').forEach(function (el) {
if (el._osk) return;
var s = (el._osk = setup(el));
if (reduced()) { s.text = s.messages[0]; render(s); return; }
render(s);
io.observe(el);
sleep(300).then(function () { loop(s); });
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-on-screen-keyboard data-loop
data-text="On my way!|See you at six."></div>