Zoeken...  ⌘K GitHub

Tabs UI Elements

Tabs met 3 varianten (underline, pill, boxed). Volledige keyboard navigatie en ARIA. Werkt met TabPanel.

/tabs
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