UI Components Easy
Toggle Group
Group of toggle buttons for single or multi-selection — like a toolbar or filter chip group. Keyboard navigation with arrow keys.
Open in Lab
MCP
css vanilla-js
Targets: JS HTML
Code
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: Inter, system-ui, sans-serif;
background: #050910;
color: #f2f6ff;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
}
/* ── Demo shell ── */
.demo {
width: min(520px, 100%);
display: flex;
flex-direction: column;
gap: 2rem;
}
.demo-title {
font-size: 1.5rem;
font-weight: 700;
letter-spacing: -0.02em;
color: #f2f6ff;
}
.demo-sub {
font-size: 0.9rem;
color: #8090b0;
line-height: 1.6;
}
/* ── Demo sections ── */
.demo-section {
display: flex;
flex-direction: column;
gap: 1rem;
}
.demo-section__label {
font-size: 0.72rem;
font-weight: 600;
color: #4a5a7a;
text-transform: uppercase;
letter-spacing: 0.08em;
}
/* ════════════════════════════════════
Toggle Group — toolbar variant
════════════════════════════════════ */
.toggle-group {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.3rem;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 0.625rem;
}
/* Toolbar button */
.tg-btn {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.45rem 0.75rem;
border-radius: 0.4rem;
border: none;
background: transparent;
color: #6a7a9a;
cursor: pointer;
font-size: 0.82rem;
font-weight: 500;
transition: background 0.15s ease, color 0.15s ease, box-shadow 0.15s ease;
white-space: nowrap;
}
.tg-btn:hover {
background: rgba(255, 255, 255, 0.07);
color: #c5d3f0;
}
.tg-btn:focus-visible {
outline: 2px solid #4f6ef7;
outline-offset: -1px;
}
.tg-btn[aria-pressed="true"] {
background: rgba(79, 110, 247, 0.2);
color: #a0b8ff;
box-shadow: inset 0 0 0 1px rgba(79, 110, 247, 0.35);
}
.tg-btn[aria-pressed="true"]:hover {
background: rgba(79, 110, 247, 0.28);
color: #c0d0ff;
}
.tg-btn__label {
font-size: 0.8rem;
}
/* ── Text preview area ── */
.text-preview {
padding: 1rem 1.2rem;
border-radius: 0.625rem;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.07);
min-height: 3.5rem;
display: flex;
align-items: center;
}
#preview-text {
font-size: 1rem;
color: #c5d3f0;
transition: font-weight 0.15s, font-style 0.15s, text-decoration 0.15s;
}
/* Dynamic text styles applied via JS */
#preview-text.is-bold {
font-weight: 700;
}
#preview-text.is-italic {
font-style: italic;
}
#preview-text.is-underline {
text-decoration: underline;
}
#preview-text.is-strikethrough {
text-decoration: line-through;
}
/* Multiple can combine */
#preview-text.is-underline.is-strikethrough {
text-decoration: underline line-through;
}
/* ════════════════════════════════════
Toggle Group — chips variant
════════════════════════════════════ */
.toggle-group--chips {
flex-wrap: wrap;
padding: 0;
background: none;
border: none;
border-radius: 0;
gap: 0.5rem;
}
.tg-chip {
display: inline-flex;
align-items: center;
padding: 0.45rem 1rem;
border-radius: 2rem;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.04);
color: #6a7a9a;
cursor: pointer;
font-size: 0.83rem;
font-weight: 500;
font-family: inherit;
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease, transform 0.15s ease;
}
.tg-chip:hover {
background: rgba(255, 255, 255, 0.08);
color: #c5d3f0;
border-color: rgba(255, 255, 255, 0.16);
}
.tg-chip:focus-visible {
outline: 2px solid #4f6ef7;
outline-offset: 2px;
}
.tg-chip:active {
transform: scale(0.95);
}
.tg-chip[aria-pressed="true"] {
background: rgba(79, 110, 247, 0.18);
color: #a0b8ff;
border-color: rgba(79, 110, 247, 0.45);
}
.tg-chip[aria-pressed="true"]:hover {
background: rgba(79, 110, 247, 0.26);
border-color: rgba(79, 110, 247, 0.6);
}
/* ── Filter display ── */
.filter-display {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.82rem;
}
.filter-display__label {
color: #4a5a7a;
}
.filter-display__value {
color: #a0b8ff;
font-weight: 500;
}(function () {
"use strict";
/**
* Initialize a toggle group.
* @param {HTMLElement} group
*/
function initToggleGroup(group) {
const mode = group.dataset.mode || "single"; // "single" | "multi"
const buttons = Array.from(group.querySelectorAll("[role='button']"));
// ── Click handling ──────────────────────────────────────────
buttons.forEach((btn) => {
btn.addEventListener("click", () => activate(btn));
});
// ── Keyboard navigation (roving tabindex) ───────────────────
group.addEventListener("keydown", (e) => {
const focused = document.activeElement;
const idx = buttons.indexOf(focused);
if (idx === -1) return;
let nextIdx = idx;
switch (e.key) {
case "ArrowRight":
case "ArrowDown":
e.preventDefault();
nextIdx = (idx + 1) % buttons.length;
break;
case "ArrowLeft":
case "ArrowUp":
e.preventDefault();
nextIdx = (idx - 1 + buttons.length) % buttons.length;
break;
case "Home":
e.preventDefault();
nextIdx = 0;
break;
case "End":
e.preventDefault();
nextIdx = buttons.length - 1;
break;
case " ":
case "Enter":
e.preventDefault();
activate(focused);
return;
default:
return;
}
moveFocus(nextIdx);
});
/** Move roving tabindex focus */
function moveFocus(idx) {
buttons.forEach((b, i) => b.setAttribute("tabindex", i === idx ? "0" : "-1"));
buttons[idx].focus();
}
/** Activate a button */
function activate(btn) {
if (mode === "single") {
buttons.forEach((b) => {
const isThis = b === btn;
b.setAttribute("aria-pressed", isThis ? "true" : "false");
b.setAttribute("tabindex", isThis ? "0" : "-1");
});
} else {
// multi: toggle independently
const pressed = btn.getAttribute("aria-pressed") === "true";
btn.setAttribute("aria-pressed", pressed ? "false" : "true");
}
// Trigger side-effects
onGroupChange(group);
}
}
// ── Side-effects: update preview / filter display ────────────
function onGroupChange(group) {
// Formatting toolbar → update text preview
if (group.getAttribute("aria-label") === "Text formatting") {
updateTextPreview();
}
// Filter chips → update active list
if (group.getAttribute("aria-label") === "Filter by category") {
updateFilterDisplay(group);
}
}
function updateTextPreview() {
const toolbar = document.querySelector('[aria-label="Text formatting"]');
if (!toolbar) return;
const btns = toolbar.querySelectorAll("[role='button']");
const preview = document.getElementById("preview-text");
if (!preview) return;
// Single-select: only one can be active
const activeLabel =
[...btns]
.find((b) => b.getAttribute("aria-pressed") === "true")
?.getAttribute("aria-label") || "";
// Remove all format classes
preview.classList.remove("is-bold", "is-italic", "is-underline", "is-strikethrough");
switch (activeLabel) {
case "Bold":
preview.classList.add("is-bold");
break;
case "Italic":
preview.classList.add("is-italic");
break;
case "Underline":
preview.classList.add("is-underline");
break;
case "Strikethrough":
preview.classList.add("is-strikethrough");
break;
}
}
function updateFilterDisplay(group) {
const active = [...group.querySelectorAll("[aria-pressed='true']")]
.map((b) => b.textContent.trim())
.join(", ");
const display = document.getElementById("active-filters");
if (display) {
display.textContent = active || "None";
}
}
// ── Bootstrap all groups ─────────────────────────────────────
document.querySelectorAll(".toggle-group").forEach(initToggleGroup);
// Run initial side-effects to reflect default state
document.querySelectorAll(".toggle-group").forEach(onGroupChange);
})();<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Toggle Group</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="demo">
<h2 class="demo-title">Toggle Group</h2>
<p class="demo-sub">Single-select toolbar and multi-select filter chips. Use arrow keys to navigate.</p>
<div class="demo-section">
<span class="demo-section__label">Text Formatting — single select</span>
<!-- Single-select toolbar -->
<div
class="toggle-group"
role="toolbar"
aria-label="Text formatting"
data-mode="single"
>
<button
class="tg-btn"
role="button"
aria-pressed="false"
aria-label="Bold"
tabindex="0"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<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>
<span class="tg-btn__label">Bold</span>
</button>
<button
class="tg-btn"
role="button"
aria-pressed="false"
aria-label="Italic"
tabindex="-1"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<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>
<span class="tg-btn__label">Italic</span>
</button>
<button
class="tg-btn"
role="button"
aria-pressed="false"
aria-label="Underline"
tabindex="-1"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M6 3v7a6 6 0 0 0 6 6 6 6 0 0 0 6-6V3"/>
<line x1="4" y1="21" x2="20" y2="21"/>
</svg>
<span class="tg-btn__label">Underline</span>
</button>
<button
class="tg-btn"
role="button"
aria-pressed="false"
aria-label="Strikethrough"
tabindex="-1"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M17.3 12H6.7"/><path d="M12 3c-2.76 0-5 1.79-5 4 0 1.12.61 2.12 1.6 2.83"/>
<path d="M12 21c2.76 0 5-1.79 5-4 0-1.12-.61-2.12-1.6-2.83"/>
</svg>
<span class="tg-btn__label">Strike</span>
</button>
</div>
<!-- Preview area -->
<div class="text-preview" id="text-preview" aria-live="polite">
<span id="preview-text">Preview text</span>
</div>
</div>
<div class="demo-section">
<span class="demo-section__label">Filter by category — multi select</span>
<!-- Multi-select filter chips -->
<div
class="toggle-group toggle-group--chips"
role="group"
aria-label="Filter by category"
data-mode="multi"
>
<button class="tg-chip" role="button" aria-pressed="true" tabindex="0">Design</button>
<button class="tg-chip" role="button" aria-pressed="false" tabindex="-1">Development</button>
<button class="tg-chip" role="button" aria-pressed="true" tabindex="-1">Motion</button>
<button class="tg-chip" role="button" aria-pressed="false" tabindex="-1">3D</button>
<button class="tg-chip" role="button" aria-pressed="false" tabindex="-1">Branding</button>
</div>
<!-- Active filter display -->
<div class="filter-display" aria-live="polite">
<span class="filter-display__label">Active filters:</span>
<span class="filter-display__value" id="active-filters">Design, Motion</span>
</div>
</div>
</div>
<script src="script.js"></script>
</body>
</html>Toggle Group
A flexible group of toggle buttons that supports both single-select (like a radio group) and multi-select (like checkboxes) modes. Matches the WAI-ARIA toolbar pattern and supports full keyboard navigation with arrow keys.
How it works
- Each
.toggle-grouphas adata-modeattribute:"single"or"multi" - In single mode, selecting a button deselects all others (radio-like)
- In multi mode, each button toggles independently (checkbox-like)
aria-pressedreflects the active state for screen readers- Arrow keys navigate within the group;
Home/Endjump to first/last
Demos
Single-select — Text Formatting toolbar: Bold, Italic, Underline, Strikethrough — only one active at a time
Multi-select — Filter chips: Design, Development, Motion, 3D, Branding — multiple selections allowed
Keyboard navigation
| Key | Action |
|---|---|
→ / ↓ | Next button |
← / ↑ | Previous button |
Home | First button |
End | Last button |
Space / Enter | Activate focused button |
When to use it
- Text editor toolbars (bold/italic/underline)
- View mode switchers (grid/list/kanban)
- Filter chip groups
- Segmented controls