Zoeken...  ⌘K GitHub

Tabs UI Elements

Tabs component.

/tabs
src/components/ui/Tabs.astro
---
/**
 * Tabs
 * Toegankelijke tab-interface. Tekst per tab via slot of `body`-prop.
 * Tab-wisseling via vanilla JS; volledige ARIA (tablist/tab/tabpanel).
 *
 * Props:
 * - tabs: Array<{ id: string; label: string; body?: string }>
 * - variant?: 'underline' | 'pill' | 'boxed', default: underline
 * - size?: 'sm' | 'md' | 'lg', default: md
 * - active?: string, id van de standaard-actieve tab (default: eerste)
 */
interface Tab {
  id: string;
  label: string;
  body?: string;
}
interface Props {
  tabs?: Tab[];
  variant?: 'underline' | 'pill' | 'boxed';
  size?: 'sm' | 'md' | 'lg';
  active?: string;
}

const {
  tabs = [
    { id: 'tab-1', label: 'Eerste', body: 'Inhoud van de eerste tab.' },
    { id: 'tab-2', label: 'Tweede', body: 'Inhoud van de tweede tab.' },
    { id: 'tab-3', label: 'Derde', body: 'Inhoud van de derde tab.' },
  ],
  variant = 'underline',
  size = 'md',
  active,
} = Astro.props;

const activeId = active && tabs.some(t => t.id === active) ? active : (tabs[0]?.id ?? '');
---

<section class="bl-section tabs-section">
  <div class="bl-inner tabs-section__inner">
    <div class={`tabs tabs--${variant} tabs--${size}`} data-component="tabs" data-active={activeId}>
      <div class="tabs__list" role="tablist" aria-label="Tabs">
        {tabs.map(tab => {
          const isActive = tab.id === activeId;
          return (
            <button
              class:list={['tabs__tab', { 'tabs__tab--active': isActive }]}
              role="tab"
              aria-selected={isActive ? 'true' : 'false'}
              aria-controls={`tabpanel-${tab.id}`}
              id={`tab-${tab.id}`}
              data-tab={tab.id}
              tabindex={isActive ? 0 : -1}
              type="button"
            >
              <span>{tab.label}</span>
            </button>
          );
        })}
      </div>
      <div class="tabs__panels">
        {tabs.map(tab => {
          const isActive = tab.id === activeId;
          return (
            <div
              class:list={['tabs__panel', { 'tabs__panel--active': isActive }]}
              role="tabpanel"
              id={`tabpanel-${tab.id}`}
              aria-labelledby={`tab-${tab.id}`}
              data-tab={tab.id}
              tabindex="0"
              hidden={!isActive}
            >
              {tab.body && <p class="tabs__body">{tab.body}</p>}
            </div>
          );
        })}
      </div>
    </div>
  </div>
</section>

<style>
  .tabs-section { background: var(--color-bg); }
  .tabs__list {
    display: flex;
    flex-wrap: wrap;
    gap: 0.25rem;
    border-bottom: 1px solid color-mix(in srgb, var(--color-text) 12%, transparent);
  }
  .tabs__tab {
    appearance: none;
    background: none;
    border: none;
    font-family: inherit;
    font-weight: 600;
    color: var(--color-muted);
    cursor: pointer;
    padding: 0.75rem 1.125rem;
    position: relative;
    transition: color 0.2s, background 0.2s;
  }
  .tabs--sm .tabs__tab { font-size: 0.875rem; padding: 0.5rem 0.875rem; }
  .tabs--md .tabs__tab { font-size: 0.9375rem; }
  .tabs--lg .tabs__tab { font-size: 1.0625rem; padding: 0.875rem 1.375rem; }
  .tabs__tab:hover { color: var(--color-text); }
  .tabs__tab--active { color: var(--color-text); }

  /* Underline variant */
  .tabs--underline .tabs__tab--active::after {
    content: '';
    position: absolute;
    left: 0; right: 0; bottom: -1px;
    height: 2px;
    background: var(--color-accent);
  }

  /* Pill variant */
  .tabs--pill .tabs__list { border-bottom: none; gap: 0.375rem; }
  .tabs--pill .tabs__tab { border-radius: 999px; }
  .tabs--pill .tabs__tab--active {
    background: var(--color-accent);
    color: #fff;
  }

  /* Boxed variant */
  .tabs--boxed .tabs__tab {
    border: 1px solid transparent;
    border-bottom: none;
    border-radius: var(--radius) var(--radius) 0 0;
    margin-bottom: -1px;
  }
  .tabs--boxed .tabs__tab--active {
    border-color: color-mix(in srgb, var(--color-text) 12%, transparent);
    background: var(--color-bg);
  }

  .tabs__panels { padding-top: 1.5rem; }
  .tabs__panel { outline: none; }
  .tabs__panel[hidden] { display: none; }
  .tabs__body {
    font-size: 0.9375rem;
    color: var(--color-muted);
    line-height: 1.7;
    margin: 0;
  }
</style>

<script>
  document.querySelectorAll('[data-component="tabs"]').forEach(root => {
    const tabsEl = root as HTMLElement;
    const tabBtns = Array.from(tabsEl.querySelectorAll<HTMLButtonElement>('.tabs__tab'));
    const panels = Array.from(tabsEl.querySelectorAll<HTMLElement>('.tabs__panel'));
    if (!tabBtns.length) return;

    function activate(id: string, focus = false) {
      tabsEl.setAttribute('data-active', id);
      tabBtns.forEach(btn => {
        const on = btn.getAttribute('data-tab') === id;
        btn.classList.toggle('tabs__tab--active', on);
        btn.setAttribute('aria-selected', on ? 'true' : 'false');
        btn.tabIndex = on ? 0 : -1;
        if (on && focus) btn.focus();
      });
      panels.forEach(panel => {
        const on = panel.getAttribute('data-tab') === id;
        panel.classList.toggle('tabs__panel--active', on);
        if (on) panel.removeAttribute('hidden');
        else panel.setAttribute('hidden', '');
      });
    }

    tabBtns.forEach((btn, i) => {
      btn.addEventListener('click', () => {
        const id = btn.getAttribute('data-tab');
        if (id) activate(id);
      });
      btn.addEventListener('keydown', e => {
        let next = -1;
        if (e.key === 'ArrowRight' || e.key === 'ArrowDown') next = (i + 1) % tabBtns.length;
        else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') next = (i - 1 + tabBtns.length) % tabBtns.length;
        else if (e.key === 'Home') next = 0;
        else if (e.key === 'End') next = tabBtns.length - 1;
        if (next < 0) return;
        e.preventDefault();
        const id = tabBtns[next].getAttribute('data-tab');
        if (id) activate(id, true);
      });
    });
  });
</script>