UI Components Medium
Roving Tabindex
Roving tabindex pattern implementation for toolbar and list components enabling single Tab stop with arrow key navigation.
Open in Lab
MCP
vanilla-js
Targets: JS HTML
Code
*,
*::before,
*::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background: #0a0a0a;
color: #e5e5e5;
min-height: 100vh;
display: flex;
justify-content: center;
padding: 3rem 1.5rem;
}
.demo-wrapper {
width: 100%;
max-width: 680px;
}
.demo-title {
font-size: 1.75rem;
font-weight: 700;
color: #f5f5f5;
margin-bottom: 0.375rem;
}
.demo-subtitle {
font-size: 0.9rem;
color: #888;
margin-bottom: 2rem;
line-height: 1.5;
}
/* โโ Info Panel โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
.info-panel {
background: #111827;
border: 1px solid #1e3a5f;
border-radius: 10px;
padding: 20px 24px;
margin-bottom: 2.5rem;
}
.info-title {
font-size: 0.95rem;
font-weight: 600;
color: #93c5fd;
margin-bottom: 8px;
}
.info-panel p {
font-size: 0.85rem;
color: #94a3b8;
line-height: 1.6;
}
.info-panel code {
background: #1e293b;
padding: 2px 6px;
border-radius: 4px;
font-size: 0.8rem;
color: #c4b5fd;
}
.info-panel kbd {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 24px;
height: 22px;
padding: 0 6px;
background: #1a1a1a;
border: 1px solid #333;
border-bottom-width: 2px;
border-radius: 4px;
font-family: inherit;
font-size: 0.7rem;
font-weight: 600;
color: #ccc;
}
/* โโ Section โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
.example-section {
margin-bottom: 2.5rem;
}
.section-title {
font-size: 1.05rem;
font-weight: 600;
color: #d4d4d4;
margin-bottom: 14px;
}
/* โโ Toolbar โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
.toolbar {
display: flex;
align-items: center;
gap: 4px;
background: #161616;
border: 1px solid #262626;
border-radius: 10px;
padding: 6px 8px;
}
.toolbar-btn {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 36px;
background: transparent;
border: none;
border-radius: 7px;
color: #999;
cursor: pointer;
transition: background 0.15s, color 0.15s, box-shadow 0.15s;
}
.toolbar-btn:hover {
background: #222;
color: #d4d4d4;
}
.toolbar-btn:focus-visible {
outline: 2px solid #6366f1;
outline-offset: -2px;
color: #f5f5f5;
}
.toolbar-btn[aria-pressed="true"] {
background: #2d2d5e;
color: #a5b4fc;
}
.toolbar-separator {
width: 1px;
height: 24px;
background: #333;
margin: 0 4px;
flex-shrink: 0;
}
/* โโ Radio Group โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
.radio-group {
display: flex;
gap: 6px;
background: #161616;
border: 1px solid #262626;
border-radius: 10px;
padding: 6px 8px;
}
.radio-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
border-radius: 7px;
cursor: pointer;
transition: background 0.15s;
font-size: 0.875rem;
color: #999;
}
.radio-item:hover {
background: #222;
color: #d4d4d4;
}
.radio-item:focus-visible {
outline: 2px solid #6366f1;
outline-offset: -2px;
color: #f5f5f5;
}
.radio-item[aria-checked="true"] {
background: #1e1e2e;
color: #e5e5e5;
}
.radio-indicator {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border: 2px solid #444;
border-radius: 50%;
flex-shrink: 0;
transition: border-color 0.15s;
}
.radio-item[aria-checked="true"] .radio-indicator {
border-color: #6366f1;
}
.radio-item[aria-checked="true"] .radio-indicator::after {
content: "";
width: 8px;
height: 8px;
background: #6366f1;
border-radius: 50%;
}
.radio-label {
font-weight: 500;
}
/* โโ Color Grid โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
.color-grid {
display: inline-flex;
flex-direction: column;
gap: 6px;
background: #161616;
border: 1px solid #262626;
border-radius: 10px;
padding: 12px;
}
.color-row {
display: flex;
gap: 6px;
}
.color-swatch {
width: 40px;
height: 40px;
background: var(--swatch);
border-radius: 8px;
cursor: pointer;
transition: transform 0.15s, box-shadow 0.15s;
border: 2px solid transparent;
}
.color-swatch:hover {
transform: scale(1.1);
}
.color-swatch:focus-visible {
outline: 2px solid #6366f1;
outline-offset: 2px;
}
.color-swatch[aria-selected="true"] {
border-color: #fff;
box-shadow: 0 0 0 2px #6366f1;
transform: scale(1.05);
}
.color-selected {
margin-top: 12px;
font-size: 0.85rem;
color: #888;
}
/* โโ Tab Order Visualizer โโโโโโโโโโโโโโโโโโโโโโโโโโโ */
.tab-order-info {
font-size: 0.85rem;
color: #888;
line-height: 1.6;
margin-bottom: 14px;
}
.tab-order-info kbd {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 24px;
height: 22px;
padding: 0 6px;
background: #1a1a1a;
border: 1px solid #333;
border-bottom-width: 2px;
border-radius: 4px;
font-family: inherit;
font-size: 0.7rem;
font-weight: 600;
color: #ccc;
}
.tabindex-display {
background: #111;
border: 1px solid #222;
border-radius: 10px;
padding: 16px 20px;
font-family: "SF Mono", "Fira Code", "Cascadia Code", monospace;
font-size: 0.78rem;
color: #888;
line-height: 1.8;
white-space: pre-wrap;
word-break: break-all;
}
.tabindex-display .active-tab {
color: #22c55e;
font-weight: 600;
}
.tabindex-display .inactive-tab {
color: #555;
}
.tabindex-display .group-label {
color: #6366f1;
font-weight: 600;
}
/* โโ Responsive โโ */
@media (max-width: 600px) {
body {
padding: 1.25rem 0.75rem;
}
.demo-title {
font-size: 1.35rem;
}
.demo-subtitle {
font-size: 0.82rem;
margin-bottom: 1.25rem;
}
.info-panel {
padding: 14px 16px;
margin-bottom: 1.5rem;
}
.info-title {
font-size: 0.88rem;
}
.info-panel p {
font-size: 0.8rem;
}
.example-section {
margin-bottom: 1.5rem;
}
.section-title {
font-size: 0.95rem;
margin-bottom: 10px;
}
/* Toolbar: allow wrap */
.toolbar {
flex-wrap: wrap;
padding: 6px;
}
.toolbar-btn {
width: 36px;
height: 34px;
}
/* Radio group: wrap into rows */
.radio-group {
flex-wrap: wrap;
gap: 4px;
padding: 6px;
}
.radio-item {
padding: 7px 12px;
font-size: 0.82rem;
gap: 6px;
}
.radio-indicator {
width: 16px;
height: 16px;
}
.radio-item[aria-checked="true"] .radio-indicator::after {
width: 7px;
height: 7px;
}
/* Color grid: smaller swatches */
.color-grid {
padding: 8px;
gap: 4px;
max-width: 100%;
}
.color-row {
gap: 4px;
}
.color-swatch {
width: 34px;
height: 34px;
border-radius: 6px;
}
.color-selected {
font-size: 0.8rem;
}
/* Tab order visualizer */
.tabindex-display {
padding: 12px 14px;
font-size: 0.7rem;
overflow-x: auto;
}
.tab-order-info {
font-size: 0.8rem;
margin-bottom: 10px;
}
}
@media (max-width: 360px) {
.color-swatch {
width: 28px;
height: 28px;
}
.toolbar-btn {
width: 32px;
height: 30px;
}
.toolbar-btn svg {
width: 15px;
height: 15px;
}
}(() => {
const groups = document.querySelectorAll("[data-roving-group]");
const tabindexDisplay = document.getElementById("tabindexDisplay");
const colorSelected = document.getElementById("colorSelected");
/* โโ Roving Tabindex Core โโโโโโโโโโโโโโโโโโโโโโโโโ */
function getItems(group) {
// For grid, get all gridcells; for toolbar, get buttons; for radiogroup, get radios
if (group.hasAttribute("data-grid")) {
return [...group.querySelectorAll('[role="gridcell"]')];
}
if (group.getAttribute("role") === "radiogroup") {
return [...group.querySelectorAll('[role="radio"]')];
}
// Toolbar: skip separators
return [...group.querySelectorAll('button:not([role="separator"])')];
}
function setRovingFocus(group, targetItem) {
const items = getItems(group);
items.forEach((item) => item.setAttribute("tabindex", "-1"));
targetItem.setAttribute("tabindex", "0");
targetItem.focus();
updateDisplay();
}
function getCurrentIndex(group) {
const items = getItems(group);
return items.findIndex((item) => item.getAttribute("tabindex") === "0");
}
/* โโ Toolbar navigation (horizontal) โโโโโโโโโโโโโโ */
function handleLinearNav(group, e) {
const items = getItems(group);
const current = getCurrentIndex(group);
let next;
switch (e.key) {
case "ArrowRight":
case "ArrowDown":
e.preventDefault();
next = (current + 1) % items.length;
setRovingFocus(group, items[next]);
break;
case "ArrowLeft":
case "ArrowUp":
e.preventDefault();
next = (current - 1 + items.length) % items.length;
setRovingFocus(group, items[next]);
break;
case "Home":
e.preventDefault();
setRovingFocus(group, items[0]);
break;
case "End":
e.preventDefault();
setRovingFocus(group, items[items.length - 1]);
break;
}
}
/* โโ Grid navigation (2D) โโโโโโโโโโโโโโโโโโโโโโโโโ */
function handleGridNav(group, e) {
const items = getItems(group);
const cols = parseInt(group.dataset.cols) || 5;
const current = getCurrentIndex(group);
const row = Math.floor(current / cols);
const col = current % cols;
let next;
switch (e.key) {
case "ArrowRight":
e.preventDefault();
next = col + 1 < cols && current + 1 < items.length ? current + 1 : row * cols;
setRovingFocus(group, items[next]);
break;
case "ArrowLeft":
e.preventDefault();
next = col - 1 >= 0 ? current - 1 : Math.min(row * cols + cols - 1, items.length - 1);
setRovingFocus(group, items[next]);
break;
case "ArrowDown": {
e.preventDefault();
const nextRow = current + cols;
next = nextRow < items.length ? nextRow : col;
setRovingFocus(group, items[next]);
break;
}
case "ArrowUp": {
e.preventDefault();
const prevRow = current - cols;
const totalRows = Math.ceil(items.length / cols);
next = prevRow >= 0 ? prevRow : Math.min((totalRows - 1) * cols + col, items.length - 1);
setRovingFocus(group, items[next]);
break;
}
case "Home":
e.preventDefault();
setRovingFocus(group, items[0]);
break;
case "End":
e.preventDefault();
setRovingFocus(group, items[items.length - 1]);
break;
case "Enter":
case " ":
e.preventDefault();
selectColor(items[current]);
break;
}
}
/* โโ Radio group selection โโโโโโโโโโโโโโโโโโโโโโโโ */
function handleRadioNav(group, e) {
const items = getItems(group);
const current = getCurrentIndex(group);
let next;
switch (e.key) {
case "ArrowRight":
case "ArrowDown":
e.preventDefault();
next = (current + 1) % items.length;
selectRadio(group, items[next]);
break;
case "ArrowLeft":
case "ArrowUp":
e.preventDefault();
next = (current - 1 + items.length) % items.length;
selectRadio(group, items[next]);
break;
case "Home":
e.preventDefault();
selectRadio(group, items[0]);
break;
case "End":
e.preventDefault();
selectRadio(group, items[items.length - 1]);
break;
}
}
function selectRadio(group, item) {
getItems(group).forEach((el) => el.setAttribute("aria-checked", "false"));
item.setAttribute("aria-checked", "true");
setRovingFocus(group, item);
}
/* โโ Color swatch selection โโโโโโโโโโโโโโโโโโโโโโโ */
function selectColor(swatch) {
document
.querySelectorAll(".color-swatch")
.forEach((s) => s.setAttribute("aria-selected", "false"));
swatch.setAttribute("aria-selected", "true");
const color = swatch.dataset.color;
const name = swatch.getAttribute("aria-label");
colorSelected.innerHTML = `Selected: <span style="color: ${color}; font-weight: 600;">${name} (${color})</span>`;
}
/* โโ Toolbar button toggle โโโโโโโโโโโโโโโโโโโโโโโโ */
function handleToolbarAction(button) {
const pressed = button.getAttribute("aria-pressed");
if (pressed !== null) {
button.setAttribute("aria-pressed", pressed === "true" ? "false" : "true");
}
}
/* โโ Bind events โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
groups.forEach((group) => {
const isGrid = group.hasAttribute("data-grid");
const isRadio = group.getAttribute("role") === "radiogroup";
group.addEventListener("keydown", (e) => {
if (isGrid) {
handleGridNav(group, e);
} else if (isRadio) {
handleRadioNav(group, e);
} else {
handleLinearNav(group, e);
// Toggle on Enter/Space for toolbar
if ((e.key === "Enter" || e.key === " ") && !isGrid && !isRadio) {
e.preventDefault();
const items = getItems(group);
const current = getCurrentIndex(group);
if (current >= 0) handleToolbarAction(items[current]);
}
}
});
// Click handlers
getItems(group).forEach((item) => {
item.addEventListener("click", () => {
setRovingFocus(group, item);
if (isRadio) {
selectRadio(group, item);
} else if (isGrid) {
selectColor(item);
} else {
handleToolbarAction(item);
}
});
});
// Radio items also select on click
if (isRadio) {
getItems(group).forEach((item) => {
item.addEventListener("click", () => selectRadio(group, item));
});
}
});
/* โโ Tabindex Display โโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
function updateDisplay() {
if (!tabindexDisplay) return;
let html = "";
const labels = ["Toolbar", "Radio Group", "Color Grid"];
groups.forEach((group, gi) => {
const items = getItems(group);
html += `<span class="group-label">${labels[gi]}:</span> `;
html += items
.map((item) => {
const val = item.getAttribute("tabindex");
const cls = val === "0" ? "active-tab" : "inactive-tab";
const label =
item.getAttribute("aria-label") || item.textContent.trim() || item.dataset.color || "?";
const short = label.length > 8 ? label.slice(0, 8) : label;
return `<span class="${cls}">[${val}] ${short}</span>`;
})
.join(" ");
html += "\n";
});
tabindexDisplay.innerHTML = html;
}
// Update display on any focus change
document.addEventListener("focusin", () => {
requestAnimationFrame(updateDisplay);
});
// Initial display
updateDisplay();
})();<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Roving Tabindex</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="demo-wrapper">
<h1 class="demo-title">Roving Tabindex</h1>
<p class="demo-subtitle">Single Tab stop per widget group — arrow keys navigate within, Tab moves between groups</p>
<div class="info-panel">
<h2 class="info-title">How it works</h2>
<p>Only one element per group has <code>tabindex="0"</code>; all others have <code>tabindex="-1"</code>. Pressing an arrow key moves <code>tabindex="0"</code> to the next item and focuses it. <kbd>Tab</kbd> moves to the next group, not the next item.</p>
</div>
<!-- Example 1: Toolbar -->
<section class="example-section">
<h2 class="section-title">Formatting Toolbar</h2>
<div class="toolbar" role="toolbar" aria-label="Text formatting" data-roving-group>
<button type="button" class="toolbar-btn" tabindex="0" data-action="bold" aria-label="Bold" aria-pressed="false">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M6 4h8a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"/><path d="M6 12h9a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"/></svg>
</button>
<button type="button" class="toolbar-btn" tabindex="-1" data-action="italic" aria-label="Italic" aria-pressed="false">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="19" y1="4" x2="10" y2="4"/><line x1="14" y1="20" x2="5" y2="20"/><line x1="15" y1="4" x2="9" y2="20"/></svg>
</button>
<button type="button" class="toolbar-btn" tabindex="-1" data-action="underline" aria-label="Underline" aria-pressed="false">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M6 4v6a6 6 0 0 0 12 0V4"/><line x1="4" y1="20" x2="20" y2="20"/></svg>
</button>
<span class="toolbar-separator" role="separator" aria-orientation="vertical"></span>
<button type="button" class="toolbar-btn" tabindex="-1" data-action="align-left" aria-label="Align left" aria-pressed="true">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="17" y1="10" x2="3" y2="10"/><line x1="21" y1="6" x2="3" y2="6"/><line x1="21" y1="14" x2="3" y2="14"/><line x1="17" y1="18" x2="3" y2="18"/></svg>
</button>
<button type="button" class="toolbar-btn" tabindex="-1" data-action="align-center" aria-label="Align center" aria-pressed="false">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="18" y1="10" x2="6" y2="10"/><line x1="21" y1="6" x2="3" y2="6"/><line x1="21" y1="14" x2="3" y2="14"/><line x1="18" y1="18" x2="6" y2="18"/></svg>
</button>
<button type="button" class="toolbar-btn" tabindex="-1" data-action="align-right" aria-label="Align right" aria-pressed="false">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="21" y1="10" x2="7" y2="10"/><line x1="21" y1="6" x2="3" y2="6"/><line x1="21" y1="14" x2="3" y2="14"/><line x1="21" y1="18" x2="7" y2="18"/></svg>
</button>
</div>
</section>
<!-- Example 2: Radio Group -->
<section class="example-section">
<h2 class="section-title">Theme Selection</h2>
<div class="radio-group" role="radiogroup" aria-label="Choose a theme" data-roving-group>
<div class="radio-item" role="radio" aria-checked="true" tabindex="0" data-value="dark">
<span class="radio-indicator"></span>
<span class="radio-label">Dark</span>
</div>
<div class="radio-item" role="radio" aria-checked="false" tabindex="-1" data-value="light">
<span class="radio-indicator"></span>
<span class="radio-label">Light</span>
</div>
<div class="radio-item" role="radio" aria-checked="false" tabindex="-1" data-value="dim">
<span class="radio-indicator"></span>
<span class="radio-label">Dim</span>
</div>
<div class="radio-item" role="radio" aria-checked="false" tabindex="-1" data-value="auto">
<span class="radio-indicator"></span>
<span class="radio-label">System</span>
</div>
</div>
</section>
<!-- Example 3: Color Swatch Grid -->
<section class="example-section">
<h2 class="section-title">Color Palette</h2>
<div class="color-grid" role="grid" aria-label="Choose accent color" data-roving-group data-grid data-cols="5">
<div class="color-row" role="row">
<div class="color-swatch" role="gridcell" tabindex="0" data-color="#ef4444" style="--swatch: #ef4444" aria-label="Red" aria-selected="false"></div>
<div class="color-swatch" role="gridcell" tabindex="-1" data-color="#f97316" style="--swatch: #f97316" aria-label="Orange" aria-selected="false"></div>
<div class="color-swatch" role="gridcell" tabindex="-1" data-color="#eab308" style="--swatch: #eab308" aria-label="Yellow" aria-selected="false"></div>
<div class="color-swatch" role="gridcell" tabindex="-1" data-color="#22c55e" style="--swatch: #22c55e" aria-label="Green" aria-selected="false"></div>
<div class="color-swatch" role="gridcell" tabindex="-1" data-color="#06b6d4" style="--swatch: #06b6d4" aria-label="Cyan" aria-selected="false"></div>
</div>
<div class="color-row" role="row">
<div class="color-swatch" role="gridcell" tabindex="-1" data-color="#3b82f6" style="--swatch: #3b82f6" aria-label="Blue" aria-selected="false"></div>
<div class="color-swatch" role="gridcell" tabindex="-1" data-color="#8b5cf6" style="--swatch: #8b5cf6" aria-label="Violet" aria-selected="false"></div>
<div class="color-swatch" role="gridcell" tabindex="-1" data-color="#d946ef" style="--swatch: #d946ef" aria-label="Fuchsia" aria-selected="false"></div>
<div class="color-swatch" role="gridcell" tabindex="-1" data-color="#ec4899" style="--swatch: #ec4899" aria-label="Pink" aria-selected="false"></div>
<div class="color-swatch" role="gridcell" tabindex="-1" data-color="#f5f5f5" style="--swatch: #f5f5f5" aria-label="White" aria-selected="false"></div>
</div>
</div>
<p class="color-selected" id="colorSelected">No color selected</p>
</section>
<section class="example-section">
<h2 class="section-title">Tab Order Visualizer</h2>
<p class="tab-order-info">Press <kbd>Tab</kbd> to see how focus jumps between the three groups above — never between items within a group. The current tabindex values are shown below.</p>
<div class="tabindex-display" id="tabindexDisplay"></div>
</section>
</div>
<script src="script.js"></script>
</body>
</html>Demonstrates the roving tabindex pattern across three interactive examples: a formatting toolbar, a radio group, and a color swatch grid. Each composite widget acts as a single Tab stop with internal arrow key navigation.