TL;DR you should include a virtual keyboard option in your applications
Especially if you're building for the web. There are many virtual keyboards that a user can install locally on their own computer, but it's important to also think about times where a user that relies on assistive technologies like a virtual keyboard even then they may not be accessing your application from their own device. It's an often (almost always) overlooked feature but for the people who need one it makes a huge difference.
Let me be specific about who benefits because "accessibility" gets hand-waved too often:
- People with motor disabilities who use a head pointer, eye-gaze system, or an adaptive switch cannot press physical keys, but they can hover over or scan an on-screen key.
- Touch-only and kiosk users often have no physical keyboard attached at all. Ie point-of-sale terminals, medical check-in tablets, or industrial control panels. This may not be an "accessibility" feature per se but if you already have a software keybaord in your codebase moving your application to a non-standard hardware configuration should be easier.
- People with cognitive and learning differences benefit from seeing the whole input surface laid out and labeled instead of recalling a key's location from muscle memory.
- People with low vision get large, high-contrast targets and screen-reader announcements for available keys.
- Anyone on a device where the native software keyboard is unavailable,
It's easy to inaccessibly build accessibility features :(
A virtual keyboard is meant to be helpful but building it incorrectly can make it incredibly unhelpful. An accessible virtual keyboard should absolutely meet WAI-ARIA and WCAG standards:
- Keys are real
<button>elements, so it is focusable, has a role, and is announced. Each carries anaria-labelsuch as "Capital A", "Backspace", or "Space". tabindexwith arrow-key navigation. The arrow keys move between keys; Nobody wants to tab through 40 buttons.- Obvious toggle state. The Shift key reports
aria-pressed, and announces "Shift on" or "Shift off" so sight-impaired users know the mode changed. - Pointer activation doesn't steal field focus. A
preventDefault()onmousedownkeeps the text field's caret in place while keys are clicked. - Targets are at least 44 by 44 CSS pixels (WCAG 2.5.5 Target Size), with a
thick, high-contrast
:focus-visibleoutline. - Motion is optional. Any animation you may add should respect
prefers-reduced-motionpreferences.
Try it out!
Focus either field and a keyboard-navigable, screen-reader-friendly keyboard will dock at the bottom of the page. Try it with a mouse, then try it with the keyboard alone: press Tab to enter it, move with the arrow keys, activate a key with Enter or Space, and dismiss it with Escape.
How you can use it in your app or on your own site
This keyboard is dependency-free vanilla JavaScript so it can be added to just about anywhere.
HTML
<link rel="stylesheet" href="a11y-keyboard.css" />
<script src="a11y-keyboard.js" defer></script>
<input type="text" name="name" data-a11y-keyboard />
<textarea name="message" data-a11y-keyboard></textarea>
CSS
.a11y-kbd {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 2147483000;
margin: 0 auto;
max-width: 760px;
padding: 8px 10px 12px;
background: #1d2433;
color: #f4f6fb;
border: 1px solid #3a465f;
border-bottom: 0;
border-radius: 12px 12px 0 0;
box-shadow: 0 -8px 30px rgba(0, 0, 0, 0.35);
font-family:
system-ui,
-apple-system,
"Segoe UI",
Roboto,
sans-serif;
user-select: none;
-webkit-user-select: none;
animation: a11y-kbd-in 0.18s ease-out;
}
.a11y-kbd[hidden] {
display: none;
}
.a11y-kbd__bar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.a11y-kbd__title {
font-size: 13px;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #9aa6bd;
}
.a11y-kbd__close {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border: 1px solid #3a465f;
border-radius: 8px;
background: #2b3346;
color: #f4f6fb;
font-size: 16px;
cursor: pointer;
}
.a11y-kbd__close:hover {
background: #343d54;
}
.a11y-kbd__row {
display: flex;
gap: 6px;
margin-bottom: 6px;
}
.a11y-kbd__row:last-of-type {
margin-bottom: 0;
}
.a11y-kbd__key {
flex: 1 1 0;
min-width: 0;
min-height: 48px;
border: 1px solid #3a465f;
border-radius: 8px;
background: #2b3346;
color: #f4f6fb;
font-size: 18px;
line-height: 1;
cursor: pointer;
touch-action: manipulation;
}
.a11y-kbd__key:hover {
background: #343d54;
}
.a11y-kbd__key:active {
transform: translateY(1px);
}
.a11y-kbd__key--special {
background: #39435c;
font-size: 14px;
}
.a11y-kbd__key[aria-pressed="true"] {
background: #2f6df6;
border-color: #2f6df6;
color: #fff;
}
.a11y-kbd__key:focus-visible,
.a11y-kbd__key:focus {
outline: 3px solid #ffd24a;
outline-offset: 2px;
position: relative;
z-index: 1;
}
.a11y-kbd__live {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0 0 0 0);
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
@media (prefers-reduced-motion: reduce) {
.a11y-kbd {
animation: none;
}
.a11y-kbd__key:active {
transform: none;
}
}
@keyframes a11y-kbd-in {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
JavaScript
(() => {
"use strict";
if (window.__a11yKeyboardLoaded) return;
window.__a11yKeyboardLoaded = true;
const ROWS = [
[..."1234567890"],
[..."qwertyuiop"],
[..."asdfghjkl"],
["{shift}", ..."zxcvbnm", "{backspace}"],
["{space}", "{enter}"],
];
const SPECIAL = {
"{shift}": { label: "⇧ Shift", name: "Shift", action: "shift", span: 2 },
"{backspace}": { label: "⌫ Del", name: "Backspace", action: "backspace", span: 2 },
"{space}": { label: "Space", name: "Space", action: "space", span: 6 },
"{enter}": { label: "⏎ Enter", name: "Enter", action: "enter", span: 4 },
};
const make = (tag, className, text) => {
const node = document.createElement(tag);
node.className = className;
if (text != null) node.textContent = text;
return node;
};
const setValue = (el, value) => {
const proto = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement : HTMLInputElement;
Object.getOwnPropertyDescriptor(proto.prototype, "value").set.call(el, value);
el.dispatchEvent(new Event("input", { bubbles: true }));
};
class Keyboard {
constructor() {
this.field = null;
this.shifted = false;
this.skipOpen = false;
this.keys = [];
this.row = 0;
this.col = 0;
this.build();
}
build() {
const close = make("button", "a11y-kbd__close", "✕");
close.type = "button";
close.setAttribute("aria-label", "Hide virtual keyboard");
close.addEventListener("click", () => this.close(true));
const bar = make("div", "a11y-kbd__bar");
bar.append(make("span", "a11y-kbd__title", "A11y Keyboard"), close);
this.root = make("div", "a11y-kbd");
this.root.hidden = true;
this.root.setAttribute("role", "group");
this.root.setAttribute("aria-label", "Virtual keyboard");
this.root.append(bar);
this.grid = ROWS.map((row) => {
const rowEl = make("div", "a11y-kbd__row");
const buttons = row.map((key) => {
const def = SPECIAL[key] || { label: key, name: key, action: "char", char: key };
const btn = make("button", "a11y-kbd__key" + (SPECIAL[key] ? " a11y-kbd__key--special" : ""));
btn.type = "button";
btn.tabIndex = -1;
btn.dataset.key = key;
if (def.span) btn.style.flexGrow = def.span;
if (def.action === "shift") btn.setAttribute("aria-pressed", "false");
this.label(btn, def);
btn.addEventListener("mousedown", (e) => e.preventDefault());
btn.addEventListener("click", () => this.press(def));
this.keys.push({ btn, def });
rowEl.append(btn);
return btn;
});
this.root.append(rowEl);
return buttons;
});
this.grid[0][0].tabIndex = 0;
this.live = make("div", "a11y-kbd__live");
this.live.setAttribute("aria-live", "polite");
this.root.append(this.live);
this.root.addEventListener("keydown", (e) => this.onArrow(e));
document.body.append(this.root);
}
label(btn, def) {
if (def.action !== "char") {
btn.textContent = def.label;
btn.setAttribute("aria-label", def.name);
return;
}
const ch = this.shifted ? def.char.toUpperCase() : def.char;
btn.textContent = ch;
btn.setAttribute("aria-label",
/[a-z]/i.test(ch) ? `${this.shifted ? "Capital" : "Lowercase"} ${ch}` : ch);
}
press(def) {
if (!this.field) return;
if (def.action === "char") {
this.insert(this.shifted ? def.char.toUpperCase() : def.char);
if (this.shifted) this.shift(false);
} else if (def.action === "space") this.insert(" ");
else if (def.action === "backspace") this.backspace();
else if (def.action === "enter") this.enter();
else if (def.action === "shift") this.shift(!this.shifted);
}
insert(text) {
const { value, selectionStart, selectionEnd } = this.field;
const start = selectionStart ?? value.length;
const end = selectionEnd ?? value.length;
setValue(this.field, value.slice(0, start) + text + value.slice(end));
this.caret(start + text.length);
}
backspace() {
const { value } = this.field;
let start = this.field.selectionStart ?? value.length;
const end = this.field.selectionEnd ?? start;
if (start === end) {
if (start === 0) return;
start -= 1;
}
setValue(this.field, value.slice(0, start) + value.slice(end));
this.caret(start);
}
enter() {
if (this.field instanceof HTMLTextAreaElement) this.insert("\n");
else this.field.form?.requestSubmit?.();
}
caret(pos) {
try { this.field.setSelectionRange(pos, pos); } catch {}
}
shift(on) {
this.shifted = on;
this.root.querySelector('[data-key="{shift}"]').setAttribute("aria-pressed", String(on));
this.keys.forEach(({ btn, def }) => def.action === "char" && this.label(btn, def));
this.live.textContent = "";
this.live.textContent = on ? "Shift on" : "Shift off";
}
onArrow(e) {
const delta = { ArrowLeft: [0, -1], ArrowRight: [0, 1], ArrowUp: [-1, 0], ArrowDown: [1, 0] };
if (!(e.key in delta) && e.key !== "Home" && e.key !== "End") return;
e.preventDefault();
let r = this.row;
let c = this.col;
if (e.key === "Home") c = 0;
else if (e.key === "End") c = this.grid[r].length - 1;
else { r += delta[e.key][0]; c += delta[e.key][1]; }
r = Math.max(0, Math.min(this.grid.length - 1, r));
c = Math.max(0, Math.min(this.grid[r].length - 1, c));
this.grid[this.row][this.col].tabIndex = -1;
this.grid[r][c].tabIndex = 0;
this.grid[r][c].focus();
this.row = r;
this.col = c;
}
onTab(e) {
if (this.root.hidden) return;
if (document.activeElement === this.field && !e.shiftKey) {
e.preventDefault();
this.grid[this.row][this.col].focus();
} else if (this.root.contains(document.activeElement)) {
e.preventDefault();
this.field?.focus();
}
}
open(field) {
if (this.skipOpen) { this.skipOpen = false; return; }
this.field = field;
this.root.hidden = false;
}
close(returnFocus) {
if (this.root.hidden) return;
const field = this.field;
this.root.hidden = true;
this.field = null;
if (returnFocus && field) { this.skipOpen = true; field.focus(); }
}
}
let kb = null;
const ensure = () => (kb ??= new Keyboard());
document.addEventListener("focusin", (e) => {
if (kb?.root.contains(e.target)) return;
if (e.target.matches?.("[data-a11y-keyboard]")) ensure().open(e.target);
else kb?.close(false);
});
document.addEventListener("keydown", (e) => {
if (!kb || kb.root.hidden) return;
if (e.key === "Escape") {
e.preventDefault();
kb.close(kb.root.contains(document.activeElement));
} else if (e.key === "Tab") {
kb.onTab(e);
}
}, true);
})();
License
This code is released under the MIT License, one of the most permissive and widely used open-source licenses. You may use it commercially, modify it, distribute it, and include it in proprietary software. The only requirement is that you keep the copyright and permission notice. You can read the license text at opensource.org/licenses/MIT.
MIT License
Copyright (c) 2026 Travis Witt
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
This is the same virtual keyboard I use on this site. You can turn it on and off by
pressing ? and toggling the button in the modal or by clicking the ? button in the
top-right of the page if you'd like. Hopefully you think it's useful, and even if you
don't I guarantee someone out there will.
- Travis