Tab Panel แท็บ
ARIA tabs with arrow-key navigation; each tab's content is loaded from the server only when first opened.
Live demo
Everything here talks to the real Spring Boot + SQL Server back end.Each tab asks GET /api/categories/{name}/summary the first time it opens. Requests so far: 0
Keyboard: focus a tab, then ← → Home End. The open tab is in the URL hash.
What to notice
- role=tablist / tab / tabpanel with aria-selected and roving tabindex.
- Left / Right / Home / End move between tabs.
- The selected tab is kept in the URL hash, so reload and Back work.
Try it
Source code
6 parts, read from the files in this project
src/main/java/com/study/uiux/web/ProductApi.java
Tab content is fetched the first time a tab opens
@GetMapping("/categories/{category}/summary")
public Map<String, Object> categorySummary(@PathVariable String category,
@RequestParam(defaultValue = "0") int delay) throws InterruptedException {
DemoApi.pause(delay);
List<Product> items = products.findByCategoryOrderByName(category);
var stats = products.categoryStats().stream().filter(s -> s.getCategory().equalsIgnoreCase(category)).findFirst();
Map<String, Object> body = new LinkedHashMap<>();
body.put("category", category);
body.put("items", stats.map(ProductRepository.CategoryStats::getItems).orElse(0L));
body.put("stock", stats.map(ProductRepository.CategoryStats::getStock).orElse(0L));
body.put("avgPrice", stats.map(s -> String.format(Locale.ROOT, "%.2f", s.getAvgPrice())).orElse("0.00"));
body.put("avgRating", stats.map(s -> String.format(Locale.ROOT, "%.1f", s.getAvgRating())).orElse("0.0"));
body.put("top", items.stream().sorted((a, b) -> b.getRating().compareTo(a.getRating())).limit(5).map(ProductApi::view).toList());
return body;
}
src/main/java/com/study/uiux/repo/ProductRepository.java
GROUP BY query: one row of numbers per category tab
@Query("""
select p.category as category, count(p) as items, sum(p.stock) as stock,
avg(p.price) as avgPrice, avg(p.rating) as avgRating
from Product p group by p.category order by p.category""")
List<CategoryStats> categoryStats();
interface CategoryStats {
String getCategory();
long getItems();
long getStock();
Double getAvgPrice();
Double getAvgRating();
}
src/main/resources/templates/demos/l321.html
ARIA tab markup: tablist, tab (aria-controls) and tabpanel (aria-labelledby)
<div class="tabs" data-tabs data-tabs-hash data-tabs-manual id="catTabs">
<div class="tab-list" role="tablist" aria-label="Product categories">
<button th:each="c, s : ${categories}" type="button" role="tab" class="tab"
th:id="'tab-' + ${#strings.toLowerCase(c)}" th:attr="aria-controls='panel-' + ${#strings.toLowerCase(c)},data-category=${c}"
th:text="${c}">Audio</button>
</div>
<div th:each="c : ${categories}" class="tab-panel" role="tabpanel" tabindex="0"
th:id="'panel-' + ${#strings.toLowerCase(c)}" th:attr="aria-labelledby='tab-' + ${#strings.toLowerCase(c)}" hidden>
<div class="tab-content" data-empty="1"></div>
</div>
</div>
src/main/resources/static/css/app.css
Tabs: an underline under the selected tab
.tabs { min-width: 0; }
.tab-list { display: flex; gap: .25rem; border-bottom: 1px solid var(--border); overflow-x: auto; scrollbar-width: thin; }
.tab {
display: inline-flex; align-items: center; gap: .45rem; padding: .6rem .9rem; border: 0; background: none; font: inherit; font-size: .86rem; font-weight: 600;
color: var(--text-3); cursor: pointer; white-space: nowrap; position: relative; border-radius: .5rem .5rem 0 0;
}
.tab:hover { color: var(--text); background: var(--surface-2); }
.tab[aria-selected="true"] { color: var(--primary-text); }
.tab[aria-selected="true"]::after { content: ""; position: absolute; left: .5rem; right: .5rem; bottom: -1px; height: 2.5px; border-radius: 2px; background: var(--primary); }
.tab-panel { padding-top: 1rem; outline: none; }
.tab-panel:focus-visible { box-shadow: inset 0 0 0 2px var(--ring); border-radius: .5rem; }
.tab-badge { font-size: .68rem; background: var(--surface-2); color: var(--text-3); padding: 0 .4rem; border-radius: 999px; }
src/main/resources/static/js/core.js
Roving tabindex + arrow keys + optional URL hash
function initTabs(container, { onShow } = {}) {
const tabs = $$('[role="tab"]', container);
const useHash = container.hasAttribute('data-tabs-hash');
function select(tab, focus = false) {
tabs.forEach((t) => {
const on = t === tab;
t.setAttribute('aria-selected', String(on));
t.tabIndex = on ? 0 : -1;
const panel = document.getElementById(t.getAttribute('aria-controls'));
if (panel) panel.hidden = !on;
});
if (focus) tab.focus();
if (useHash) history.replaceState(null, '', '#' + tab.id);
onShow?.(tab, document.getElementById(tab.getAttribute('aria-controls')));
}
tabs.forEach((tab) => {
tab.addEventListener('click', () => select(tab));
tab.addEventListener('keydown', (e) => {
const i = tabs.indexOf(tab);
const next = { ArrowRight: i + 1, ArrowLeft: i - 1, Home: 0, End: tabs.length - 1 }[e.key];
if (next === undefined) return;
e.preventDefault();
select(tabs[(next + tabs.length) % tabs.length], true);
});
});
const fromHash = useHash && location.hash && tabs.find((t) => '#' + t.id === location.hash);
select(fromHash || tabs.find((t) => t.getAttribute('aria-selected') === 'true') || tabs[0]);
return { select };
}
src/main/resources/static/js/demos.js
Load a tab's content only the first time it is shown
demos[321] = () => {
let requests = 0;
UI.initTabs($('#catTabs'), {
onShow: async (tab, panel) => {
const box = $('.tab-content', panel);
if (!box.dataset.empty) return; // already loaded: no new request
delete box.dataset.empty;
box.innerHTML = `<div class="grid-3">${'<div class="stat"><div class="skeleton sk-line w-40" style="height:1.4rem"></div><div class="skeleton sk-line w-60"></div></div>'.repeat(4)}</div>`;
const d = await api(`/api/categories/${encodeURIComponent(tab.dataset.category)}/summary?delay=500`);
$('#tabRequests').textContent = ++requests;
box.innerHTML = `
<div class="grid-3">
<div class="stat"><b>${d.items}</b><span>products</span></div>
<div class="stat"><b>${d.stock}</b><span>items in stock</span></div>
<div class="stat"><b>$${d.avgPrice}</b><span>average price</span></div>
<div class="stat"><b>★ ${d.avgRating}</b><span>average rating</span></div>
</div>
<h4 style="margin:1.25rem 0 .6rem">Top rated in ${esc(d.category)}</h4>
<div class="product-grid">${d.top.map(productCard).join('')}</div>
<p class="muted small" style="margin-top:.75rem">Loaded at ${new Date().toLocaleTimeString()} - switch away and back: no new request.</p>`;
}
});
};