Example Components
Tab Group
Accessible tabs built from the sections you pass in, with arrow-key navigation and IDs that stay unique when the component appears more than once on a page. Without JavaScript every section stays visible.
Usage
Each child of the slot becomes one panel, and its data-tab attribute is the tab's text. label names the tab list for screen readers.
true
examples/components/tab-group/tab-group.html
$10 per month, cancel any time.
$100 per year, two months free.
$25 per person per month.
html
<tab-group data-bascik-prop-label="Pricing plans">
<section data-tab="Monthly"><p>$10 per month, cancel any time.</p></section>
<section data-tab="Yearly"><p>$100 per year, two months free.</p></section>
<section data-tab="Team"><p>$25 per person per month.</p></section>
</tab-group> html examples/components/tab-group/tab-group.html
<!-- @bascik
Accessible tabs built from the sections passed in. Each child of the default slot becomes one tab panel, labeled by its data-tab attribute. Without JavaScript every section stays visible, stacked.
@prop label - Accessible name for the tab list, for example "Pricing plans".
@slot default - One element per tab, each with a data-tab="Label" attribute.
-->
<style>
.tab-group {
display: block;
}
.tablist {
display: flex;
gap: 0.25rem;
border-bottom: 1px solid var(--color-border, color-mix(in srgb, currentColor 30%, transparent));
}
.tab {
padding: 0.5rem 1rem;
border: 0;
border-bottom: 3px solid transparent;
border-radius: var(--radius, 0.5rem) var(--radius, 0.5rem) 0 0;
background: none;
color: var(--color-muted, inherit);
font: inherit;
cursor: pointer;
}
.tab:hover {
background: var(--color-surface, color-mix(in srgb, currentColor 8%, transparent));
}
.tab[aria-selected="true"] {
border-bottom-color: var(--color-accent, currentColor);
background: var(--color-surface, color-mix(in srgb, currentColor 8%, transparent));
color: var(--color-text, inherit);
font-weight: 700;
}
.tab:focus-visible,
.panel:focus-visible {
outline: 2px solid var(--color-accent, currentColor);
outline-offset: 2px;
}
.panel {
padding: 1rem;
background: var(--color-surface, color-mix(in srgb, currentColor 8%, transparent));
border-radius: 0 0 var(--radius, 0.5rem) var(--radius, 0.5rem);
}
</style>
<div class="tab-group" id="root">
<div class="tablist" id="tablist" role="tablist" data-bascik-attr-aria-label="label" hidden></div>
<div class="panels" id="panels">
<div data-bascik-slot></div>
</div>
</div>
<script>
// Every id below is rewritten per instance, so several tab groups on one page stay independent.
const root = document.getElementById('root');
const tablist = document.getElementById('tablist');
const panels = [...document.getElementById('panels').children];
const tabs = [];
const select = (index, moveFocus) => {
tabs.forEach((tab, i) => {
const selected = i === index;
tab.setAttribute('aria-selected', String(selected));
tab.tabIndex = selected ? 0 : -1;
panels[i].hidden = !selected;
});
if (moveFocus) tabs[index].focus();
};
panels.forEach((panel, index) => {
// Runtime ids are derived from the root's scoped id, so they are unique per instance.
const tabId = `${root.id}-tab-${index}`;
const panelId = `${root.id}-panel-${index}`;
const tab = document.createElement('button');
tab.type = 'button';
// A static class string is rewritten to the scoped name defined in the <style> above.
tab.className = 'tab';
tab.textContent = panel.dataset.tab || `Tab ${index + 1}`;
tab.setAttribute('role', 'tab');
tab.setAttribute('id', tabId);
tab.setAttribute('aria-controls', panelId);
tab.addEventListener('click', () => select(index, false));
tablist.append(tab);
tabs.push(tab);
panel.classList.add('panel');
panel.setAttribute('id', panelId);
panel.setAttribute('role', 'tabpanel');
panel.setAttribute('aria-labelledby', tabId);
panel.tabIndex = 0;
});
tablist.addEventListener('keydown', (event) => {
const current = tabs.indexOf(document.activeElement);
if (current === -1) return;
const last = tabs.length - 1;
const next = {
ArrowRight: current === last ? 0 : current + 1,
ArrowLeft: current === 0 ? last : current - 1,
Home: 0,
End: last,
}[event.key];
if (next === undefined) return;
event.preventDefault();
select(next, true);
});
if (tabs.length > 0) {
tablist.hidden = false;
select(0, false);
}
</script> Output is shown unminified (HTML, CSS, JS, and identifier names) for readability.
How It Works
- The script builds one
<button role="tab">per section and gives every tab and panel an ID derived from the component root's scoped ID (${root.id}-tab-0). Bascik rewritesrootto a different ID in each instance, so two tab groups never share IDs, andaria-controlsandaria-labelledbyalways point at the right element. - Left and Right move between tabs and wrap at the ends; Home and End jump to the first and last tab. Only the selected tab is in the Tab order (a roving
tabindex). tab.className = 'tab'is a static string, so Bascik rewrites it to the scoped class from the component's<style>block. A template literal would not be rewritten.- The tab list is
hiddenin the markup and shown by the script, so a visitor without JavaScript never sees buttons that do nothing.
Adapt It
- Give each section a heading if it must make sense when the sections are stacked without JavaScript.
- To remember the selected tab across reloads, store its index in
sessionStorage, keyed byroot.id. - To open a specific tab from a link, read
location.hashon load and select the panel whosedata-tabmatches.