Tabs UI Elements
Tabs met 3 varianten (underline, pill, boxed). Volledige keyboard navigatie en ARIA. Werkt met TabPanel.
src/components/ui/Tabs.astro
---
/**
* Tabs
* Toegankelijke tab component: keyboard navigeerbaar, ARIA roles.
* 3 visuele varianten: underline, pill, boxed.
*/
interface Tab {
id: string;
label: string;
icon?: string;
badge?: string | number;
disabled?: boolean;
}
interface Props {
tabs: Tab[];
defaultTab?: string;
variant?: 'underline' | 'pill' | 'boxed';
size?: 'sm' | 'md' | 'lg';
fullWidth?: boolean;
}
const {
tabs = [],
defaultTab,
variant = 'underline',
size = 'md',
fullWidth = false,
} = Astro.props;
const activeTab = defaultTab ?? tabs[0]?.id;
---
<div
class:list={['tabs', `tabs--${variant}`, `tabs--${size}`, { 'tabs--full': fullWidth }]}
data-component="tabs"
data-active={activeTab}
>
<!-- Tab list -->
<div class="tabs__list" role="tablist" aria-label="Tabs">
{tabs.map(tab => (
<button
class:list={['tabs__tab', { 'tabs__tab--active': tab.id === activeTab, 'tabs__tab--disabled': tab.disabled }]}
role="tab"
aria-selected={tab.id === activeTab ? 'true' : 'false'}
aria-controls={`tabpanel-${tab.id}`}
id={`tab-${tab.id}`}
data-tab={tab.id}
disabled={tab.disabled}
tabindex={tab.id === activeTab ? 0 : -1}
>
{tab.icon && <span class="tabs__tab-icon" aria-hidden="true" set:html={tab.icon} />}
<span>{tab.label}</span>
{tab.badge !== undefined && (
<span class="tabs__badge">{tab.badge}</span>
)}
</button>
))}
</div>
<!-- Tab panels (content via slot, keyed by data-tab) -->
<div class="tabs__panels">
<slot />
</div>
</div>
<script>
document.querySelectorAll<HTMLElement>('[data-component="tabs"]').forEach(tabsEl => {
const list = tabsEl.querySelector<HTMLElement>('.tabs__list');
const buttons = tabsEl.querySelectorAll<HTMLButtonElement>('.tabs__tab:not(.tabs__tab--disabled)');
const panels = tabsEl.querySelectorAll<HTMLElement>('[role="tabpanel"]');
function activate(btn: HTMLButtonElement) {
const id = btn.dataset.tab!;
buttons.forEach(b => {
b.classList.toggle('tabs__tab--active', b === btn);
b.setAttribute('aria-selected', b === btn ? 'true' : 'false');
b.tabIndex = b === btn ? 0 : -1;
});
panels.forEach(p => {
p.hidden = p.id !== `tabpanel-${id}`;
});
}
buttons.forEach(btn => {
btn.addEventListener('click', () => activate(btn));
});
// Keyboard navigation
list?.addEventListener('keydown', (e) => {
const active = tabsEl.querySelector<HTMLButtonElement>('.tabs__tab--active');
const arr = Array.from(buttons);
const idx = arr.indexOf(active!);
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
e.preventDefault();
const next = arr[(idx + 1) % arr.length];
next.focus();
activate(next);
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
e.preventDefault();
const prev = arr[(idx - 1 + arr.length) % arr.length];
prev.focus();
activate(prev);
} else if (e.key === 'Home') {
e.preventDefault();
arr[0].focus();
activate(arr[0]);
} else if (e.key === 'End') {
e.preventDefault();
arr[arr.length - 1].focus();
activate(arr[arr.length - 1]);
}
});
// Init: hide non-active panels
const activeId = tabsEl.dataset.active;
panels.forEach(p => { p.hidden = p.id !== `tabpanel-${activeId}`; });
});
</script>
<style>
.tabs { display: flex; flex-direction: column; gap: 0; }
/* === TAB LIST === */
.tabs__list {
display: flex;
gap: 0;
overflow-x: auto;
scrollbar-width: none;
}
.tabs__list::-webkit-scrollbar { display: none; }
.tabs--full .tabs__list { width: 100%; }
.tabs--full .tabs__tab { flex: 1; }
/* === TAB BUTTON BASE === */
.tabs__tab {
display: inline-flex;
align-items: center;
gap: 0.5rem;
background: none;
border: none;
cursor: pointer;
font-family: inherit;
font-size: 0.9375rem;
font-weight: 500;
color: var(--color-muted, #6b7280);
white-space: nowrap;
transition: color 0.15s, background 0.15s, box-shadow 0.15s;
justify-content: center;
}
.tabs--sm .tabs__tab { font-size: 0.8125rem; }
.tabs--lg .tabs__tab { font-size: 1rem; }
.tabs__tab--disabled { opacity: 0.4; cursor: not-allowed; }
/* === UNDERLINE VARIANT === */
.tabs--underline .tabs__list {
border-bottom: 2px solid rgba(0,0,0,0.08);
}
.tabs--underline .tabs__tab {
padding: 0.875rem 1.25rem;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
border-radius: 0;
}
.tabs--sm.tabs--underline .tabs__tab { padding: 0.625rem 1rem; }
.tabs--lg.tabs--underline .tabs__tab { padding: 1rem 1.5rem; }
.tabs--underline .tabs__tab:hover:not(.tabs__tab--disabled) { color: var(--color-primary, #0a0a0a); }
.tabs--underline .tabs__tab--active {
color: var(--color-accent, #6366f1);
border-bottom-color: var(--color-accent, #6366f1);
font-weight: 600;
}
/* === PILL VARIANT === */
.tabs--pill .tabs__list {
padding: 0.25rem;
background: rgba(0,0,0,0.04);
border-radius: var(--radius, 0.5rem);
gap: 0.25rem;
}
.tabs--pill .tabs__tab {
padding: 0.5rem 1rem;
border-radius: calc(var(--radius, 0.5rem) - 2px);
}
.tabs--sm.tabs--pill .tabs__tab { padding: 0.375rem 0.75rem; }
.tabs--lg.tabs--pill .tabs__tab { padding: 0.625rem 1.25rem; }
.tabs--pill .tabs__tab:hover:not(.tabs__tab--disabled):not(.tabs__tab--active) {
background: rgba(0,0,0,0.05);
color: var(--color-primary, #0a0a0a);
}
.tabs--pill .tabs__tab--active {
background: var(--color-bg, #fff);
color: var(--color-primary, #0a0a0a);
font-weight: 600;
box-shadow: 0 1px 4px rgba(0,0,0,0.1);
}
/* === BOXED VARIANT === */
.tabs--boxed .tabs__list {
border-bottom: 1px solid rgba(0,0,0,0.1);
gap: -1px;
}
.tabs--boxed .tabs__tab {
padding: 0.75rem 1.25rem;
border: 1px solid transparent;
border-bottom: none;
border-radius: var(--radius, 0.5rem) var(--radius, 0.5rem) 0 0;
margin-bottom: -1px;
}
.tabs--boxed .tabs__tab--active {
background: var(--color-bg, #fff);
border-color: rgba(0,0,0,0.1);
color: var(--color-primary, #0a0a0a);
font-weight: 600;
}
/* === BADGE === */
.tabs__badge {
min-width: 20px;
height: 20px;
padding: 0 0.35rem;
background: rgba(0,0,0,0.08);
border-radius: 999px;
font-size: 0.6875rem;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
color: var(--color-primary, #0a0a0a);
}
.tabs__tab--active .tabs__badge {
background: rgba(99,102,241,0.12);
color: var(--color-accent, #6366f1);
}
/* === ICON === */
.tabs__tab-icon {
display: flex;
align-items: center;
flex-shrink: 0;
}
/* === PANELS === */
.tabs__panels { padding-top: 1.5rem; }
[role="tabpanel"] { outline: none; }
[role="tabpanel"][hidden] { display: none; }
</style>
Props
| Prop | Type | Default | Beschrijving |
|---|---|---|---|
tabs * | { id: string; label: string; icon?: string }[] | — | Tab items |
activeTab | string | — | Standaard actieve tab ID |
variant | 'underline' | 'pill' | 'boxed' | 'underline' | Visuele variant |
size | 'sm' | 'md' | 'lg' | 'md' | Grootte |
* = verplicht