Skip to content

Tabs

Tabs gives one durable contract to two things applications routinely need: buttons that switch mounted peer panels, and links that navigate among related pages. The caller writes the real elements; Klean reads their semantics instead of asking for a mode, item schema, or router adapter.

With button[data-value], Klean supplies the missing ARIA tab contract: relationships, selected and hidden state, roving focus, Arrow keys, Home/End, disabled skipping, overflow reveal, and safe fallback when a dynamic tab disappears. With as="nav" and direct a[href][data-value] children—including a framework Link that renders an anchor—Tabs becomes the navigation landmark, preserves native navigation, and adds only active-state and styling hooks. Tailwind, routing, persistence, loading, and close policy remain application code.

Tabs.vue
Focus a tab and use Left/Right, Home, or End. Tab leaves the tab list for the active panel.

Installation

One command detects Vue, React, or Svelte and writes the matching framework-native source:

Run one command from a Boring Stack application. Klean detects the framework and conventional destination, then adds the framework-native source and its direct dependencies.

Terminal
npx klean-ui add tabs

  • No initializer or configuration file
  • No framework, alias, or theme questions
  • No Klean runtime dependency

There is no initializer, provider, item schema, klean-ui.json, generated class helper, or runtime package to configure.

Usage

Klean infers the contract from the real elements you provide:

  • With the default as="div", make the first child a list of button[data-value]. Each later direct child with the same data-value is that button's panel.
  • With as="nav", put a[href][data-value] or framework Links directly inside Tabs. Tabs itself becomes the navigation landmark; no extra wrapper or panels are required.

Keep a group all buttons or all links. A mixed group is ambiguous, so Klean deliberately leaves it unenhanced.

Vue

ProjectTabs.vue
<script setup>
import { ref } from 'vue'
import Tabs from '@/components/ui/tabs/Tabs.vue'

const active = ref('overview')
</script>

<template>
  <Tabs v-model="active" aria-label="Project sections">
    <div class="flex gap-6 overflow-x-auto border-b border-gray-200">
      <button
        type="button"
        data-value="overview"
        class="min-h-11 cursor-pointer border-b-2 border-transparent px-1 py-2 text-sm font-medium text-gray-500 outline-none hover:text-gray-950 focus-visible:ring-2 focus-visible:ring-gray-950 data-[state=active]:border-gray-950 data-[state=active]:text-gray-950"
      >
        Overview
      </button>
      <button
        type="button"
        data-value="activity"
        class="min-h-11 cursor-pointer border-b-2 border-transparent px-1 py-2 text-sm font-medium text-gray-500 outline-none hover:text-gray-950 focus-visible:ring-2 focus-visible:ring-gray-950 data-[state=active]:border-gray-950 data-[state=active]:text-gray-950"
      >
        Activity
      </button>
    </div>

    <section
      data-value="overview"
      class="py-6 outline-none focus-visible:ring-2"
    >
      Project health and ownership.
    </section>
    <section
      data-value="activity"
      class="py-6 outline-none focus-visible:ring-2"
    >
      Recent deployments and changes.
    </section>
  </Tabs>
</template>

React

ProjectTabs.jsx
import { useState } from 'react'
import Tabs from '@/components/ui/tabs/Tabs.jsx'

export default function ProjectTabs() {
  const [active, setActive] = useState('overview')

  return (
    <Tabs
      value={active}
      onValueChange={setActive}
      aria-label="Project sections"
    >
      <div className="flex gap-6 overflow-x-auto border-b border-gray-200">
        <button
          type="button"
          data-value="overview"
          className="min-h-11 cursor-pointer border-b-2 border-transparent px-1 py-2 text-sm font-medium text-gray-500 outline-none hover:text-gray-950 focus-visible:ring-2 focus-visible:ring-gray-950 data-[state=active]:border-gray-950 data-[state=active]:text-gray-950"
        >
          Overview
        </button>
        <button
          type="button"
          data-value="activity"
          className="min-h-11 cursor-pointer border-b-2 border-transparent px-1 py-2 text-sm font-medium text-gray-500 outline-none hover:text-gray-950 focus-visible:ring-2 focus-visible:ring-gray-950 data-[state=active]:border-gray-950 data-[state=active]:text-gray-950"
        >
          Activity
        </button>
      </div>

      <section
        data-value="overview"
        className="py-6 outline-none focus-visible:ring-2"
      >
        Project health and ownership.
      </section>
      <section
        data-value="activity"
        className="py-6 outline-none focus-visible:ring-2"
      >
        Recent deployments and changes.
      </section>
    </Tabs>
  )
}

Svelte

ProjectTabs.svelte
<script>
  import Tabs from '$lib/components/ui/tabs/Tabs.svelte'

  let active = $state('overview')
</script>

<Tabs bind:value={active} aria-label="Project sections">
  <div class="flex gap-6 overflow-x-auto border-b border-gray-200">
    <button
      type="button"
      data-value="overview"
      class="min-h-11 cursor-pointer border-b-2 border-transparent px-1 py-2 text-sm font-medium text-gray-500 outline-none hover:text-gray-950 focus-visible:ring-2 focus-visible:ring-gray-950 data-[state=active]:border-gray-950 data-[state=active]:text-gray-950"
    >
      Overview
    </button>
    <button
      type="button"
      data-value="activity"
      class="min-h-11 cursor-pointer border-b-2 border-transparent px-1 py-2 text-sm font-medium text-gray-500 outline-none hover:text-gray-950 focus-visible:ring-2 focus-visible:ring-gray-950 data-[state=active]:border-gray-950 data-[state=active]:text-gray-950"
    >
      Activity
    </button>
  </div>

  <section data-value="overview" class="py-6 outline-none focus-visible:ring-2">
    Project health and ownership.
  </section>
  <section data-value="activity" class="py-6 outline-none focus-visible:ring-2">
    Recent deployments and changes.
  </section>
</Tabs>

The binding syntax changes, but the visible HTML and data-value relationship stay the same.

API

InputDefaultPurpose
Vue v-modelinferredControlled or uncontrolled selected value.
React value / defaultValue / onValueChangeinferredReact-native controlled or initial selected value.
Svelte bind:valueinferredSvelte-native selected value.
asdivUse nav when the direct children are route destinations.
orientationhorizontalhorizontal uses Left/Right; vertical uses Up/Down.
activationautomaticautomatic selects on focus; manual waits for Enter or Space.
aria-label / aria-labelledbyrequiredAccessible name forwarded to the button list or navigation landmark.
class / classNameOrdinary Tailwind classes merged on the root.

Panel mode falls back to the first enabled button. Navigation mode uses the controlled value or an existing aria-current="page"; it never guesses which URL shape means current. Tabs forwards other non-conflicting attributes to its root. Individual button, link, and panel attributes stay on the caller's elements, where they remain easy to inspect and change.

Horizontal, vertical, and settings navigation

orientation="horizontal" is the default. It uses Left/Right Arrow keys and is appropriate for a short row of peer panels. orientation="vertical" uses Up/Down Arrow keys and works well when the peer panels belong beside a settings-style rail.

vertical-tabs.vue
Focus the rail and use Up/Down, Home, or End. The caller owns the rail and panel layout.

The visual shape does not decide the semantics. If every settings item has its own URL or Inertia page, use as="nav" and put the real links directly inside Tabs. Klean skips the ARIA tab widget behavior and applies aria-current="page" plus data-state="active" to the selected destination. Reload, sharing, Back/Forward, prefetch, modified clicks, and open-in-new-tab remain native.

SettingsNavigation.vue
These are real links, not buttons wearing link styling. Try opening one in a new tab or using a modified click.

SettingsNavigation.vue
<script setup>
import { Link } from '@inertiajs/vue3'
import Tabs from '@/components/ui/tabs/Tabs.vue'

defineProps({
  current: {
    type: String,
    required: true
  }
})

const sections = [
  { value: 'profile', label: 'Profile', href: '/settings/profile' },
  { value: 'billing', label: 'Billing', href: '/settings/billing' },
  { value: 'schedule', label: 'Schedule', href: '/settings/schedule' }
]
</script>

<template>
  <Tabs
    as="nav"
    :model-value="current"
    orientation="vertical"
    aria-label="Account settings"
    class="flex flex-col gap-1"
  >
    <Link
      v-for="section in sections"
      :key="section.value"
      :href="section.href"
      :data-value="section.value"
      prefetch
      class="block min-h-11 cursor-pointer rounded-lg px-3 py-2 text-sm font-medium text-black/60 no-underline outline-none hover:bg-black/5 hover:text-black focus-visible:ring-2 focus-visible:ring-black data-[state=active]:bg-black data-[state=active]:text-white"
    >
      {{ section.label }}
    </Link>
  </Tabs>
</template>

SettingsNavigation.jsx
import { Link } from '@inertiajs/react'
import Tabs from '@/components/ui/tabs/Tabs.jsx'

const sections = [
  { value: 'profile', label: 'Profile', href: '/settings/profile' },
  { value: 'billing', label: 'Billing', href: '/settings/billing' },
  { value: 'schedule', label: 'Schedule', href: '/settings/schedule' }
]

export default function SettingsNavigation({ current }) {
  return (
    <Tabs
      as="nav"
      value={current}
      orientation="vertical"
      aria-label="Account settings"
      className="flex flex-col gap-1"
    >
      {sections.map((section) => (
        <Link
          key={section.value}
          href={section.href}
          data-value={section.value}
          prefetch
          className="block min-h-11 cursor-pointer rounded-lg px-3 py-2 text-sm font-medium text-black/60 no-underline outline-none hover:bg-black/5 hover:text-black focus-visible:ring-2 focus-visible:ring-black data-[state=active]:bg-black data-[state=active]:text-white"
        >
          {section.label}
        </Link>
      ))}
    </Tabs>
  )
}

SvelteKit navigation

SettingsNavigation.svelte
<script>
  import Tabs from '$lib/components/ui/tabs/Tabs.svelte'

  let { current } = $props()
  const sections = [
    { value: 'profile', label: 'Profile', href: '/settings/profile' },
    { value: 'billing', label: 'Billing', href: '/settings/billing' },
    { value: 'schedule', label: 'Schedule', href: '/settings/schedule' }
  ]
</script>

<Tabs
  as="nav"
  value={current}
  orientation="vertical"
  aria-label="Account settings"
  class="flex flex-col gap-1"
>
  {#each sections as section (section.value)}
    <a
      href={section.href}
      data-value={section.value}
      data-sveltekit-preload-data="hover"
      class="block min-h-11 cursor-pointer rounded-lg px-3 py-2 text-sm font-medium text-black/60 no-underline outline-none hover:bg-black/5 hover:text-black focus-visible:ring-2 focus-visible:ring-black data-[state=active]:bg-black data-[state=active]:text-white"
    >
      {section.label}
    </a>
  {/each}
</Tabs>

Vue and React pass their Inertia Link directly. SvelteKit enhances ordinary anchors, so no Link wrapper is needed. A native <a> works in every framework.

When to use

Use Tabs for related sections that share one visual navigation treatment:

  • use buttons and panels for workspace results, editor documents, dashboard views, or instant detail sections;
  • use anchors or framework Links when each section has its own durable destination.

Tabs work best when the labels are short, the active panel is clear, and every automatic panel is already mounted and fast.

When not to use

  • Do not use button mode when each destination is a page. Put real links or the Boring Stack Link inside Tabs instead.
  • Use Radio when the choice changes a value rather than which panel is visible.
  • Use Select or Combobox for a long choice list where simultaneous labels are not useful.
  • Use disclosure or an accordion when several sections may be open together or the content is hierarchical rather than peer views.
  • Use Menu for a temporary list of actions or destinations.

Do not turn every row of page navigation into tabs. The visual resemblance does not change the underlying semantic decision.

Automatic and manual activation

Automatic activation is the default because it feels direct when every panel is already present and switching is instant. Moving focus selects the next tab.

Use activation="manual" when selecting a tab starts a request, performs meaningful work, or could noticeably delay focus. Arrow keys then move focus without changing the panel; Enter or Space selects.

vue
<Tabs v-model="activeReport" activation="manual" aria-label="Report sections">
  <!-- same native buttons and panels -->
</Tabs>

Loading policy still belongs to the page. Tabs does not fetch, cache, suspend, or invent a loading state.

Styling with Tailwind

Style the real button, link, and panel elements directly. Klean adds stable state hooks:

  • root: data-slot="tabs", data-mode="panels|navigation", and data-orientation;
  • panel list: data-slot="tabs-list", data-mode, and data-orientation;
  • buttons or links: data-slot="tab", data-mode, data-state="active|inactive", and data-orientation;
  • panels: data-slot="tab-panel", data-state="active|inactive", and data-orientation.
html
<button
  type="button"
  data-value="activity"
  class="cursor-pointer border-b-2 border-transparent px-3 py-2 text-gray-500
         data-[state=active]:border-black data-[state=active]:text-black"
>
  Activity
</button>

These are ordinary Tailwind selectors, not Klean color, size, tone, elevation, or variant APIs. A product-owned wrapper may repeat a house treatment without hiding the semantic buttons.

With as="nav", the root is also the navigation list, so it keeps data-slot="tabs"; there is intentionally no extra tabs-list wrapper.

The same state selector styles navigation without a second styling API:

html
<a
  href="/settings/billing"
  data-value="billing"
  class="rounded-lg px-3 py-2 text-gray-500
         data-[state=active]:bg-black data-[state=active]:text-white"
>
  Billing
</a>

Dynamic workspace tabs

Slipway workspaces add, rename, reorder, overflow, and close result tabs. Keep a close action adjacent to its tab—never nest a button inside another button:

vue
<Tabs v-model="active" class="relative" aria-label="Open results">
  <div class="flex">
    <button
      v-for="item in openTabs"
      :key="item.value"
      type="button"
      :data-value="item.value"
      class="w-36 cursor-pointer pr-10"
    >
      {{ item.label }}
    </button>
  </div>

  <div class="pointer-events-none absolute left-0 top-0 flex">
    <span v-for="item in openTabs" :key="item.value" class="flex w-36 justify-end">
      <button
        type="button"
        class="pointer-events-auto cursor-pointer"
        :aria-label="`Close ${item.label}`"
        @click="close(item.value)"
      >
        ×
      </button>
    </span>
  </div>

  <section v-for="item in openTabs" :key="item.value" :data-value="item.value">
    <!-- result -->
  </section>
</Tabs>

The overlaid action row is a sibling of the semantic tab list. It appears beside each tab without placing non-tab controls inside role="tablist".

When the active tab disappears or becomes disabled, Tabs selects the enabled tab now occupying that position, then falls back backward at the end. If focus was on the removed active tab, focus follows the safe replacement. A focused tab in an overflowing list scrolls into view without moving the page.

The application still decides whether a tab may close, whether unsaved work needs confirmation, and how a label is renamed.

Durable state

Selection durability depends on what the sections mean:

  • bind a same-page operational view to a URL query parameter such as ?tab=activity;
  • use route links for settings pages where each section already has its own URL;
  • keep browser history meaningful when Back should restore an earlier view;
  • use Durable UI storage for a local editor preference that should survive reloads but should not be shared;
  • keep disposable result tabs in local component state.

Tabs never writes local storage, cookies, the URL, or server state. Bind panel mode to the application's chosen source of truth. For query-backed panels, preserve unrelated query parameters, push a history entry when a tab change should be reversible with Back, listen for Back/Forward changes, and let Tabs report a valid fallback when restored state names a tab that no longer exists.

In navigation mode, the router already owns durability. Pass the current route-derived value—or render one link with aria-current="page" during SSR—and let the anchor or framework Link perform navigation. Klean does not intercept clicks, so browser history, prefetch, reload, middle-click, and modifier keys continue to work.

Accessible behavior

Button mode:

  • The list, tabs, and panels receive the complete tablist, tab, and tabpanel relationship.
  • Generated IDs connect aria-controls and aria-labelledby without caller coordination.
  • Only the selected enabled tab is in the page Tab order.
  • Horizontal Arrow keys or vertical Arrow keys move and wrap among enabled tabs.
  • Home and End focus the first and last enabled tabs.
  • Manual activation uses Enter or Space; automatic activation follows focus.
  • Native Tab leaves the tab list instead of visiting every tab.
  • Inactive panels stay caller-owned and mounted, but receive native hidden state.
  • A panel remains keyboard-focusable so a person can move from its tab into its content.
  • Disabled tabs are skipped and cannot be selected.
  • Observers and generated behavior are removed with the component.

Navigation mode:

  • <Tabs as="nav"> renders the navigation landmark itself, and every destination remains an anchor.
  • The selected destination receives aria-current="page"; links never receive role="tab", aria-selected, or roving tabindex.
  • Native Tab visits links normally. Klean does not replace native link keyboard behavior with Arrow-key handling.
  • Ordinary and modified clicks are not cancelled, so the browser or framework Link remains in control.

Use a concise visible heading near the component when possible. Otherwise provide aria-label; use aria-labelledby when an existing heading should name the panel list or navigation landmark. In navigation mode the name is rendered on the <nav> immediately, including during server rendering.

Complete framework source

Copy, inspect, and change the complete source for your framework.

Vue source

Tabs.vue
<script setup>
import {
  computed,
  nextTick,
  onBeforeUnmount,
  onMounted,
  ref,
  useAttrs,
  useId,
  watch
} from 'vue'
import { twMerge } from 'tailwind-merge'

defineOptions({ inheritAttrs: false })

const props = defineProps({
  /** Root element. Use `nav` when the children are route destinations. */
  as: {
    type: String,
    default: 'div',
    validator: (value) => ['div', 'nav'].includes(value)
  },
  /** Framework-native controlled value. Omit for uncontrolled use. */
  modelValue: { type: String, default: undefined },
  /** Initial value when `modelValue` is not controlled. */
  defaultValue: { type: String, default: undefined },
  /** Arrow-key direction. */
  orientation: {
    type: String,
    default: 'horizontal',
    validator: (value) => ['horizontal', 'vertical'].includes(value)
  },
  /** Whether moving focus also selects a tab. */
  activation: {
    type: String,
    default: 'automatic',
    validator: (value) => ['automatic', 'manual'].includes(value)
  }
})

const emit = defineEmits(['update:modelValue', 'change'])
const attrs = useAttrs()
const componentId = useId().replace(/[^a-zA-Z0-9_-]/g, '')
const root = ref()
const internalValue = ref(props.defaultValue)
const isControlled = computed(() => props.modelValue !== undefined)
const value = computed(() =>
  isControlled.value ? props.modelValue : internalValue.value
)
const rootAttrs = computed(() => {
  const { class: _class, role: _role, ...rest } = attrs
  if (props.as !== 'nav') {
    delete rest['aria-label']
    delete rest['aria-labelledby']
  }
  return rest
})
const rootClasses = computed(() => twMerge(attrs.class))

let observer
let previousValues = []
let lastFocusedValue
let syncing = false

function listElement() {
  if (root.value?.matches('nav')) return root.value
  return root.value?.firstElementChild
}

function tabValue(element) {
  return element?.getAttribute('data-value') ?? ''
}

function belongsToThisTabs(element) {
  return element.closest('[data-slot="tabs"]') === root.value
}

function triggers() {
  const list = listElement()
  if (!list) return []
  return [
    ...list.querySelectorAll('button[data-value], a[href][data-value]')
  ].filter(belongsToThisTabs)
}

function mode(elements = triggers()) {
  if (!elements.length) return 'empty'
  if (props.as === 'nav') {
    return elements.every((element) => element.matches('a[href]'))
      ? 'navigation'
      : 'mixed'
  }
  if (elements.every((element) => element.matches('button'))) return 'panels'
  if (elements.every((element) => element.matches('a[href]'))) {
    return 'navigation'
  }
  return 'mixed'
}

function tabs() {
  return triggers().filter((trigger) => trigger.matches('button'))
}

function panels() {
  const list = listElement()
  if (!root.value || !list) return []
  return [...root.value.children]
    .slice(1)
    .filter((element) => element.hasAttribute('data-value'))
}

function disabled(tab) {
  return tab.disabled || tab.getAttribute('aria-disabled') === 'true'
}

function enabledTabs() {
  return tabs().filter((tab) => !disabled(tab))
}

function tabFor(candidate) {
  return tabs().find((tab) => tabValue(tab) === candidate)
}

function panelFor(candidate) {
  return panels().find((panel) => tabValue(panel) === candidate)
}

function fallbackValue(current) {
  const available = enabledTabs()
  if (!available.length) return undefined

  const oldIndex = previousValues.indexOf(current)
  const index = oldIndex < 0 ? 0 : Math.min(oldIndex, available.length - 1)
  return tabValue(available[index])
}

function requestValue(nextValue, { user = false } = {}) {
  if (!nextValue || nextValue === value.value) return
  if (!isControlled.value) internalValue.value = nextValue
  emit('update:modelValue', nextValue)
  if (user) emit('change', nextValue)
  nextTick(sync)
}

function generatedPairId(candidate, index) {
  const slug = candidate.replace(/[^a-zA-Z0-9_-]/g, '-') || String(index)
  return `klean-tabs-${componentId}-${slug}-${index}`
}

function setAttribute(element, name, nextValue) {
  if (element.getAttribute(name) !== nextValue) {
    element.setAttribute(name, nextValue)
  }
}

function syncList(list, currentMode) {
  if (!list) return
  const isRoot = list === root.value
  if (!isRoot) list.setAttribute('data-slot', 'tabs-list')
  list.setAttribute('data-mode', currentMode)
  list.setAttribute('data-orientation', props.orientation)
  if (!isRoot && attrs['aria-label'])
    list.setAttribute('aria-label', attrs['aria-label'])
  if (!isRoot && attrs['aria-labelledby'])
    list.setAttribute('aria-labelledby', attrs['aria-labelledby'])
}

function syncNavigation(list, links) {
  if (list.getAttribute('role') === 'tablist') list.removeAttribute('role')
  list.removeAttribute('aria-orientation')

  const current = value.value
  const currentLink = links.find((link) => tabValue(link) === current)
  const markedLink = links.find(
    (link) => link.getAttribute('aria-current') === 'page'
  )
  const selected = currentLink ?? (!isControlled.value ? markedLink : undefined)

  if (!isControlled.value && selected && tabValue(selected) !== current) {
    internalValue.value = tabValue(selected)
  }

  links.forEach((link) => {
    const active = link === selected
    link.setAttribute('data-slot', 'tab')
    link.setAttribute('data-mode', 'navigation')
    link.setAttribute('data-state', active ? 'active' : 'inactive')
    link.setAttribute('data-orientation', props.orientation)
    if (link.getAttribute('role') === 'tab') link.removeAttribute('role')
    link.removeAttribute('aria-selected')
    link.removeAttribute('aria-controls')
    if (active) setAttribute(link, 'aria-current', 'page')
    else if (link.getAttribute('aria-current') === 'page') {
      link.removeAttribute('aria-current')
    }
  })
}

function sync() {
  if (!root.value || syncing) return
  syncing = true

  const list = listElement()
  const allTriggers = triggers()
  const currentMode = mode(allTriggers)
  root.value.setAttribute('data-mode', currentMode)
  syncList(list, currentMode)

  if (currentMode === 'navigation') {
    syncNavigation(list, allTriggers)
    previousValues = []
    syncing = false
    return
  }

  if (currentMode !== 'panels') {
    syncing = false
    return
  }

  const allTabs = tabs()
  const allPanels = panels()
  const current = value.value
  const currentTab = tabFor(current)
  const resolved =
    currentTab && !disabled(currentTab) ? current : fallbackValue(current)

  if (resolved && resolved !== current) {
    if (!isControlled.value) internalValue.value = resolved
    else emit('update:modelValue', resolved)
  }

  if (list) {
    list.setAttribute('role', 'tablist')
    list.setAttribute('aria-orientation', props.orientation)
  }

  allTabs.forEach((tab, index) => {
    const candidate = tabValue(tab)
    const panel = panelFor(candidate)
    const pairId = generatedPairId(candidate, index)
    const selected = candidate === resolved

    if (!tab.hasAttribute('type')) tab.setAttribute('type', 'button')
    tab.setAttribute('role', 'tab')
    tab.setAttribute('data-slot', 'tab')
    tab.setAttribute('data-mode', 'panels')
    tab.setAttribute('data-state', selected ? 'active' : 'inactive')
    tab.setAttribute('data-orientation', props.orientation)
    tab.setAttribute('aria-selected', String(selected))
    tab.tabIndex = selected ? 0 : -1
    if (!tab.id) tab.id = `${pairId}-tab`

    if (panel) {
      if (!panel.id) panel.id = `${pairId}-panel`
      tab.setAttribute('aria-controls', panel.id)
      panel.setAttribute('role', 'tabpanel')
      panel.setAttribute('data-slot', 'tab-panel')
      panel.setAttribute('data-state', selected ? 'active' : 'inactive')
      panel.setAttribute('data-orientation', props.orientation)
      panel.setAttribute('aria-labelledby', tab.id)
      panel.hidden = !selected
      if (!panel.hasAttribute('tabindex')) panel.tabIndex = 0
    } else {
      tab.removeAttribute('aria-controls')
    }
  })

  allPanels.forEach((panel) => {
    if (tabFor(tabValue(panel))) return
    panel.hidden = true
  })

  const shouldRestoreFocus =
    lastFocusedValue === current && current && !tabFor(current) && resolved
  previousValues = allTabs.map(tabValue)
  syncing = false

  if (shouldRestoreFocus) {
    nextTick(() => tabFor(resolved)?.focus({ preventScroll: true }))
  }
}

function reveal(tab) {
  tab.scrollIntoView?.({ block: 'nearest', inline: 'nearest' })
}

function focusTab(tab) {
  if (!tab) return
  tab.focus({ preventScroll: true })
  reveal(tab)
  if (props.activation === 'automatic') {
    requestValue(tabValue(tab), { user: true })
  }
}

function eventTab(event) {
  const candidate = event.target.closest?.('button[data-value]')
  return candidate && listElement()?.contains(candidate) ? candidate : undefined
}

function handleClick(event) {
  const tab = eventTab(event)
  if (!tab || disabled(tab)) return
  lastFocusedValue = tabValue(tab)
  requestValue(tabValue(tab), { user: true })
}

function handleFocusIn(event) {
  const tab = eventTab(event)
  if (!tab || disabled(tab)) return
  lastFocusedValue = tabValue(tab)
}

function handleKeydown(event) {
  const tab = eventTab(event)
  if (!tab || disabled(tab)) return
  const available = enabledTabs()
  const index = available.indexOf(tab)
  let next

  if (
    (props.orientation === 'horizontal' && event.key === 'ArrowRight') ||
    (props.orientation === 'vertical' && event.key === 'ArrowDown')
  ) {
    next = available[(index + 1) % available.length]
  } else if (
    (props.orientation === 'horizontal' && event.key === 'ArrowLeft') ||
    (props.orientation === 'vertical' && event.key === 'ArrowUp')
  ) {
    next = available[(index - 1 + available.length) % available.length]
  } else if (event.key === 'Home') {
    next = available[0]
  } else if (event.key === 'End') {
    next = available.at(-1)
  } else if (
    props.activation === 'manual' &&
    ['Enter', ' '].includes(event.key)
  ) {
    event.preventDefault()
    requestValue(tabValue(tab), { user: true })
    return
  } else {
    return
  }

  event.preventDefault()
  focusTab(next)
}

onMounted(async () => {
  await nextTick()
  sync()
  observer = new MutationObserver(sync)
  observer.observe(root.value, {
    childList: true,
    subtree: true,
    attributes: true,
    attributeFilter: [
      'data-value',
      'disabled',
      'aria-disabled',
      'href',
      'aria-current'
    ]
  })
})

watch(
  () => [props.modelValue, props.orientation, props.activation],
  () => nextTick(sync)
)

onBeforeUnmount(() => observer?.disconnect())
</script>

<template>
  <component
    :is="as"
    v-bind="rootAttrs"
    ref="root"
    data-slot="tabs"
    :data-orientation="orientation"
    :class="rootClasses"
    @click="handleClick"
    @focusin="handleFocusIn"
    @keydown="handleKeydown"
  >
    <slot />
  </component>
</template>

React source

Tabs.jsx
import {
  forwardRef,
  useCallback,
  useEffect,
  useId,
  useLayoutEffect,
  useRef,
  useState
} from 'react'
import { twMerge } from 'tailwind-merge'

function assignRef(ref, value) {
  if (typeof ref === 'function') ref(value)
  else if (ref) ref.current = value
}

function setAttribute(element, name, nextValue) {
  if (element.getAttribute(name) !== nextValue) {
    element.setAttribute(name, nextValue)
  }
}

const Tabs = forwardRef(function Tabs(
  {
    as: Root = 'div',
    value,
    defaultValue,
    onValueChange,
    orientation = 'horizontal',
    activation = 'automatic',
    className,
    children,
    'aria-label': ariaLabel,
    'aria-labelledby': ariaLabelledby,
    onClick,
    onFocus,
    onKeyDown,
    'data-slot': _dataSlot,
    'data-orientation': _dataOrientation,
    ...rootProps
  },
  forwardedRef
) {
  const rawComponentId = useId()
  const componentId = rawComponentId.replace(/[^a-zA-Z0-9_-]/g, '')
  const rootRef = useRef(null)
  const observerRef = useRef()
  const previousValuesRef = useRef([])
  const lastFocusedValueRef = useRef()
  const syncingRef = useRef(false)
  const controlled = value !== undefined
  const [localValue, setLocalValue] = useState(defaultValue)
  const resolvedValue = controlled ? value : localValue
  const isNavigationRoot = Root === 'nav'

  const setRoot = useCallback(
    (node) => {
      rootRef.current = node
      assignRef(forwardedRef, node)
    },
    [forwardedRef]
  )

  const listElement = useCallback(
    () =>
      rootRef.current?.matches('nav')
        ? rootRef.current
        : rootRef.current?.firstElementChild,
    []
  )

  const tabValue = useCallback(
    (element) => element?.getAttribute('data-value') ?? '',
    []
  )

  const triggers = useCallback(() => {
    const list = listElement()
    if (!list) return []
    return [
      ...list.querySelectorAll('button[data-value], a[href][data-value]')
    ].filter(
      (element) => element.closest('[data-slot="tabs"]') === rootRef.current
    )
  }, [listElement])

  const mode = useCallback(
    (elements = triggers()) => {
      if (!elements.length) return 'empty'
      if (isNavigationRoot) {
        return elements.every((element) => element.matches('a[href]'))
          ? 'navigation'
          : 'mixed'
      }
      if (elements.every((element) => element.matches('button')))
        return 'panels'
      if (elements.every((element) => element.matches('a[href]'))) {
        return 'navigation'
      }
      return 'mixed'
    },
    [isNavigationRoot, triggers]
  )

  const tabs = useCallback(
    () => triggers().filter((trigger) => trigger.matches('button')),
    [triggers]
  )

  const panels = useCallback(() => {
    const list = listElement()
    if (!rootRef.current || !list) return []
    return [...rootRef.current.children]
      .slice(1)
      .filter((element) => element.hasAttribute('data-value'))
  }, [listElement])

  const disabled = useCallback(
    (tab) => tab.disabled || tab.getAttribute('aria-disabled') === 'true',
    []
  )

  const enabledTabs = useCallback(
    () => tabs().filter((tab) => !disabled(tab)),
    [disabled, tabs]
  )

  const tabFor = useCallback(
    (candidate) => tabs().find((tab) => tabValue(tab) === candidate),
    [tabValue, tabs]
  )

  const panelFor = useCallback(
    (candidate) => panels().find((panel) => tabValue(panel) === candidate),
    [panels, tabValue]
  )

  const fallbackValue = useCallback(
    (current) => {
      const available = enabledTabs()
      if (!available.length) return undefined

      const oldIndex = previousValuesRef.current.indexOf(current)
      const index = oldIndex < 0 ? 0 : Math.min(oldIndex, available.length - 1)
      return tabValue(available[index])
    },
    [enabledTabs, tabValue]
  )

  const requestValue = useCallback(
    (nextValue, { user = false } = {}) => {
      if (!nextValue || nextValue === resolvedValue) return
      if (!controlled) setLocalValue(nextValue)
      onValueChange?.(nextValue, { user })
    },
    [controlled, onValueChange, resolvedValue]
  )

  const generatedPairId = useCallback(
    (candidate, index) => {
      const slug = candidate.replace(/[^a-zA-Z0-9_-]/g, '-') || String(index)
      return `klean-tabs-${componentId}-${slug}-${index}`
    },
    [componentId]
  )

  const sync = useCallback(() => {
    if (!rootRef.current || syncingRef.current) return
    syncingRef.current = true

    const list = listElement()
    const allTriggers = triggers()
    const currentMode = mode(allTriggers)
    rootRef.current.setAttribute('data-mode', currentMode)

    if (list) {
      const listIsRoot = list === rootRef.current
      if (!listIsRoot) list.setAttribute('data-slot', 'tabs-list')
      list.setAttribute('data-mode', currentMode)
      list.setAttribute('data-orientation', orientation)
      if (!listIsRoot && ariaLabel) list.setAttribute('aria-label', ariaLabel)
      if (!listIsRoot && ariaLabelledby) {
        list.setAttribute('aria-labelledby', ariaLabelledby)
      }
    }

    if (currentMode === 'navigation') {
      if (list.getAttribute('role') === 'tablist') list.removeAttribute('role')
      list.removeAttribute('aria-orientation')

      const currentLink = allTriggers.find(
        (link) => tabValue(link) === resolvedValue
      )
      const markedLink = allTriggers.find(
        (link) => link.getAttribute('aria-current') === 'page'
      )
      const selected = currentLink ?? (!controlled ? markedLink : undefined)

      if (!controlled && selected && tabValue(selected) !== resolvedValue) {
        setLocalValue(tabValue(selected))
      }

      allTriggers.forEach((link) => {
        const active = link === selected
        link.setAttribute('data-slot', 'tab')
        link.setAttribute('data-mode', 'navigation')
        link.setAttribute('data-state', active ? 'active' : 'inactive')
        link.setAttribute('data-orientation', orientation)
        if (link.getAttribute('role') === 'tab') link.removeAttribute('role')
        link.removeAttribute('aria-selected')
        link.removeAttribute('aria-controls')
        if (active) setAttribute(link, 'aria-current', 'page')
        else if (link.getAttribute('aria-current') === 'page') {
          link.removeAttribute('aria-current')
        }
      })

      previousValuesRef.current = []
      syncingRef.current = false
      return
    }

    if (currentMode !== 'panels') {
      syncingRef.current = false
      return
    }

    const allTabs = tabs()
    const allPanels = panels()
    const current = resolvedValue
    const currentTab = tabFor(current)
    const nextValue =
      currentTab && !disabled(currentTab) ? current : fallbackValue(current)

    if (nextValue && nextValue !== current) {
      if (!controlled) setLocalValue(nextValue)
      else onValueChange?.(nextValue, { user: false })
    }

    if (list) {
      list.setAttribute('role', 'tablist')
      list.setAttribute('aria-orientation', orientation)
    }

    allTabs.forEach((tab, index) => {
      const candidate = tabValue(tab)
      const panel = panelFor(candidate)
      const pairId = generatedPairId(candidate, index)
      const selected = candidate === nextValue

      if (!tab.hasAttribute('type')) tab.setAttribute('type', 'button')
      tab.setAttribute('role', 'tab')
      tab.setAttribute('data-slot', 'tab')
      tab.setAttribute('data-mode', 'panels')
      tab.setAttribute('data-state', selected ? 'active' : 'inactive')
      tab.setAttribute('data-orientation', orientation)
      tab.setAttribute('aria-selected', String(selected))
      tab.tabIndex = selected ? 0 : -1
      if (!tab.id) tab.id = `${pairId}-tab`

      if (panel) {
        if (!panel.id) panel.id = `${pairId}-panel`
        tab.setAttribute('aria-controls', panel.id)
        panel.setAttribute('role', 'tabpanel')
        panel.setAttribute('data-slot', 'tab-panel')
        panel.setAttribute('data-state', selected ? 'active' : 'inactive')
        panel.setAttribute('data-orientation', orientation)
        panel.setAttribute('aria-labelledby', tab.id)
        panel.hidden = !selected
        if (!panel.hasAttribute('tabindex')) panel.tabIndex = 0
      } else {
        tab.removeAttribute('aria-controls')
      }
    })

    allPanels.forEach((panel) => {
      if (!tabFor(tabValue(panel))) panel.hidden = true
    })

    const shouldRestoreFocus =
      lastFocusedValueRef.current === current &&
      current &&
      !tabFor(current) &&
      nextValue
    previousValuesRef.current = allTabs.map(tabValue)
    syncingRef.current = false

    if (shouldRestoreFocus) {
      queueMicrotask(() => tabFor(nextValue)?.focus({ preventScroll: true }))
    }
  }, [
    ariaLabel,
    ariaLabelledby,
    controlled,
    disabled,
    fallbackValue,
    generatedPairId,
    listElement,
    mode,
    onValueChange,
    orientation,
    panelFor,
    panels,
    resolvedValue,
    tabFor,
    tabs,
    tabValue,
    triggers
  ])

  useLayoutEffect(sync)

  useEffect(() => {
    if (!rootRef.current) return undefined
    observerRef.current = new MutationObserver(sync)
    observerRef.current.observe(rootRef.current, {
      childList: true,
      subtree: true,
      attributes: true,
      attributeFilter: [
        'data-value',
        'disabled',
        'aria-disabled',
        'href',
        'aria-current'
      ]
    })
    return () => observerRef.current?.disconnect()
  }, [sync])

  function reveal(tab) {
    tab.scrollIntoView?.({ block: 'nearest', inline: 'nearest' })
  }

  function focusTab(tab) {
    if (!tab) return
    tab.focus({ preventScroll: true })
    reveal(tab)
    if (activation === 'automatic') {
      requestValue(tabValue(tab), { user: true })
    }
  }

  function eventTab(event) {
    const candidate = event.target.closest?.('button[data-value]')
    return candidate && listElement()?.contains(candidate)
      ? candidate
      : undefined
  }

  function handleClick(event) {
    onClick?.(event)
    if (event.defaultPrevented) return
    const tab = eventTab(event)
    if (!tab || disabled(tab)) return
    lastFocusedValueRef.current = tabValue(tab)
    requestValue(tabValue(tab), { user: true })
  }

  function handleFocus(event) {
    onFocus?.(event)
    if (event.defaultPrevented) return
    const tab = eventTab(event)
    if (!tab || disabled(tab)) return
    lastFocusedValueRef.current = tabValue(tab)
  }

  function handleKeydown(event) {
    onKeyDown?.(event)
    if (event.defaultPrevented) return
    const tab = eventTab(event)
    if (!tab || disabled(tab)) return
    const available = enabledTabs()
    const index = available.indexOf(tab)
    let next

    if (
      (orientation === 'horizontal' && event.key === 'ArrowRight') ||
      (orientation === 'vertical' && event.key === 'ArrowDown')
    ) {
      next = available[(index + 1) % available.length]
    } else if (
      (orientation === 'horizontal' && event.key === 'ArrowLeft') ||
      (orientation === 'vertical' && event.key === 'ArrowUp')
    ) {
      next = available[(index - 1 + available.length) % available.length]
    } else if (event.key === 'Home') {
      next = available[0]
    } else if (event.key === 'End') {
      next = available.at(-1)
    } else if (activation === 'manual' && ['Enter', ' '].includes(event.key)) {
      event.preventDefault()
      requestValue(tabValue(tab), { user: true })
      return
    } else {
      return
    }

    event.preventDefault()
    focusTab(next)
  }

  return (
    <Root
      {...rootProps}
      ref={setRoot}
      data-slot="tabs"
      data-orientation={orientation}
      aria-label={isNavigationRoot ? ariaLabel : undefined}
      aria-labelledby={isNavigationRoot ? ariaLabelledby : undefined}
      className={twMerge(className)}
      onClick={handleClick}
      onFocus={handleFocus}
      onKeyDown={handleKeydown}
    >
      {children}
    </Root>
  )
})

export default Tabs

Svelte source

Tabs.svelte
<script>
  import { onMount } from "svelte";
  import { twMerge } from "tailwind-merge";

  let {
    as = "div",
    value = $bindable(),
    defaultValue,
    onValueChange,
    orientation = "horizontal",
    activation = "automatic",
    class: className,
    children,
    "aria-label": ariaLabel,
    "aria-labelledby": ariaLabelledby,
    onclick: callerClick,
    onfocusin: callerFocusIn,
    onkeydown: callerKeydown,
    "data-slot": _dataSlot,
    "data-orientation": _dataOrientation,
    ...rootProps
  } = $props();

  const rawComponentId = $props.id();
  const componentId = rawComponentId.replace(/[^a-zA-Z0-9_-]/g, "");
  let rootElement;
  let observer;
  let previousValues = [];
  let lastFocusedValue;
  let syncing = false;
  let initialized = false;

  function listElement() {
    if (rootElement?.matches("nav")) return rootElement;
    return rootElement?.firstElementChild;
  }

  function tabValue(element) {
    return element?.getAttribute("data-value") ?? "";
  }

  function triggers() {
    const list = listElement();
    if (!list) return [];
    return [
      ...list.querySelectorAll("button[data-value], a[href][data-value]"),
    ].filter(
      (element) => element.closest('[data-slot="tabs"]') === rootElement,
    );
  }

  function mode(elements = triggers()) {
    if (!elements.length) return "empty";
    if (as === "nav") {
      return elements.every((element) => element.matches("a[href]"))
        ? "navigation"
        : "mixed";
    }
    if (elements.every((element) => element.matches("button"))) return "panels";
    if (elements.every((element) => element.matches("a[href]"))) {
      return "navigation";
    }
    return "mixed";
  }

  function tabs() {
    return triggers().filter((trigger) => trigger.matches("button"));
  }

  function panels() {
    const list = listElement();
    if (!rootElement || !list) return [];
    return [...rootElement.children]
      .slice(1)
      .filter((element) => element.hasAttribute("data-value"));
  }

  function disabled(tab) {
    return tab.disabled || tab.getAttribute("aria-disabled") === "true";
  }

  function enabledTabs() {
    return tabs().filter((tab) => !disabled(tab));
  }

  function tabFor(candidate) {
    return tabs().find((tab) => tabValue(tab) === candidate);
  }

  function panelFor(candidate) {
    return panels().find((panel) => tabValue(panel) === candidate);
  }

  function fallbackValue(current) {
    const available = enabledTabs();
    if (!available.length) return undefined;

    const oldIndex = previousValues.indexOf(current);
    const index = oldIndex < 0 ? 0 : Math.min(oldIndex, available.length - 1);
    return tabValue(available[index]);
  }

  function requestValue(nextValue, { user = false } = {}) {
    if (!nextValue || nextValue === value) return;
    value = nextValue;
    onValueChange?.(nextValue, { user });
    queueMicrotask(sync);
  }

  function generatedPairId(candidate, index) {
    const slug = candidate.replace(/[^a-zA-Z0-9_-]/g, "-") || String(index);
    return `klean-tabs-${componentId}-${slug}-${index}`;
  }

  function setAttribute(element, name, nextValue) {
    if (element.getAttribute(name) !== nextValue) {
      element.setAttribute(name, nextValue);
    }
  }

  function sync() {
    if (!rootElement || syncing) return;
    syncing = true;

    if (!initialized) {
      initialized = true;
      if (value === undefined && defaultValue !== undefined)
        value = defaultValue;
    }

    const list = listElement();
    const allTriggers = triggers();
    const currentMode = mode(allTriggers);
    rootElement.setAttribute("data-mode", currentMode);

    if (list) {
      const listIsRoot = list === rootElement;
      if (!listIsRoot) list.setAttribute("data-slot", "tabs-list");
      list.setAttribute("data-mode", currentMode);
      list.setAttribute("data-orientation", orientation);
      if (!listIsRoot && ariaLabel) list.setAttribute("aria-label", ariaLabel);
      if (!listIsRoot && ariaLabelledby)
        list.setAttribute("aria-labelledby", ariaLabelledby);
    }

    if (currentMode === "navigation") {
      if (list.getAttribute("role") === "tablist") list.removeAttribute("role");
      list.removeAttribute("aria-orientation");

      const currentLink = allTriggers.find((link) => tabValue(link) === value);
      const markedLink = allTriggers.find(
        (link) => link.getAttribute("aria-current") === "page",
      );
      const selected =
        currentLink ?? (value === undefined ? markedLink : undefined);

      if (value === undefined && selected) value = tabValue(selected);

      allTriggers.forEach((link) => {
        const active = link === selected;
        link.setAttribute("data-slot", "tab");
        link.setAttribute("data-mode", "navigation");
        link.setAttribute("data-state", active ? "active" : "inactive");
        link.setAttribute("data-orientation", orientation);
        if (link.getAttribute("role") === "tab") link.removeAttribute("role");
        link.removeAttribute("aria-selected");
        link.removeAttribute("aria-controls");
        if (active) setAttribute(link, "aria-current", "page");
        else if (link.getAttribute("aria-current") === "page") {
          link.removeAttribute("aria-current");
        }
      });

      previousValues = [];
      syncing = false;
      return;
    }

    if (currentMode !== "panels") {
      syncing = false;
      return;
    }

    const allTabs = tabs();
    const allPanels = panels();
    const current = value;
    const currentTab = tabFor(current);
    const resolved =
      currentTab && !disabled(currentTab) ? current : fallbackValue(current);

    if (resolved && resolved !== current) {
      value = resolved;
      onValueChange?.(resolved, { user: false });
    }

    if (list) {
      list.setAttribute("role", "tablist");
      list.setAttribute("aria-orientation", orientation);
    }

    allTabs.forEach((tab, index) => {
      const candidate = tabValue(tab);
      const panel = panelFor(candidate);
      const pairId = generatedPairId(candidate, index);
      const selected = candidate === resolved;

      if (!tab.hasAttribute("type")) tab.setAttribute("type", "button");
      tab.setAttribute("role", "tab");
      tab.setAttribute("data-slot", "tab");
      tab.setAttribute("data-mode", "panels");
      tab.setAttribute("data-state", selected ? "active" : "inactive");
      tab.setAttribute("data-orientation", orientation);
      tab.setAttribute("aria-selected", String(selected));
      tab.tabIndex = selected ? 0 : -1;
      if (!tab.id) tab.id = `${pairId}-tab`;

      if (panel) {
        if (!panel.id) panel.id = `${pairId}-panel`;
        tab.setAttribute("aria-controls", panel.id);
        panel.setAttribute("role", "tabpanel");
        panel.setAttribute("data-slot", "tab-panel");
        panel.setAttribute("data-state", selected ? "active" : "inactive");
        panel.setAttribute("data-orientation", orientation);
        panel.setAttribute("aria-labelledby", tab.id);
        panel.hidden = !selected;
        if (!panel.hasAttribute("tabindex")) panel.tabIndex = 0;
      } else {
        tab.removeAttribute("aria-controls");
      }
    });

    allPanels.forEach((panel) => {
      if (!tabFor(tabValue(panel))) panel.hidden = true;
    });

    const shouldRestoreFocus =
      lastFocusedValue === current && current && !tabFor(current) && resolved;
    previousValues = allTabs.map(tabValue);
    syncing = false;

    if (shouldRestoreFocus) {
      queueMicrotask(() => tabFor(resolved)?.focus({ preventScroll: true }));
    }
  }

  function reveal(tab) {
    tab.scrollIntoView?.({ block: "nearest", inline: "nearest" });
  }

  function focusTab(tab) {
    if (!tab) return;
    tab.focus({ preventScroll: true });
    reveal(tab);
    if (activation === "automatic") {
      requestValue(tabValue(tab), { user: true });
    }
  }

  function eventTab(event) {
    const candidate = event.target.closest?.("button[data-value]");
    return candidate && listElement()?.contains(candidate)
      ? candidate
      : undefined;
  }

  function handleClick(event) {
    callerClick?.(event);
    if (event.defaultPrevented) return;
    const tab = eventTab(event);
    if (!tab || disabled(tab)) return;
    lastFocusedValue = tabValue(tab);
    requestValue(tabValue(tab), { user: true });
  }

  function handleFocusIn(event) {
    callerFocusIn?.(event);
    if (event.defaultPrevented) return;
    const tab = eventTab(event);
    if (!tab || disabled(tab)) return;
    lastFocusedValue = tabValue(tab);
  }

  function handleKeydown(event) {
    callerKeydown?.(event);
    if (event.defaultPrevented) return;
    const tab = eventTab(event);
    if (!tab || disabled(tab)) return;
    const available = enabledTabs();
    const index = available.indexOf(tab);
    let next;

    if (
      (orientation === "horizontal" && event.key === "ArrowRight") ||
      (orientation === "vertical" && event.key === "ArrowDown")
    ) {
      next = available[(index + 1) % available.length];
    } else if (
      (orientation === "horizontal" && event.key === "ArrowLeft") ||
      (orientation === "vertical" && event.key === "ArrowUp")
    ) {
      next = available[(index - 1 + available.length) % available.length];
    } else if (event.key === "Home") {
      next = available[0];
    } else if (event.key === "End") {
      next = available.at(-1);
    } else if (activation === "manual" && ["Enter", " "].includes(event.key)) {
      event.preventDefault();
      requestValue(tabValue(tab), { user: true });
      return;
    } else {
      return;
    }

    event.preventDefault();
    focusTab(next);
  }

  onMount(() => {
    sync();
    observer = new MutationObserver(sync);
    observer.observe(rootElement, {
      childList: true,
      subtree: true,
      attributes: true,
      attributeFilter: [
        "data-value",
        "disabled",
        "aria-disabled",
        "href",
        "aria-current",
      ],
    });
    return () => observer?.disconnect();
  });

  $effect(() => {
    as;
    value;
    orientation;
    activation;
    ariaLabel;
    ariaLabelledby;
    queueMicrotask(sync);
  });
</script>

<svelte:element
  this={as}
  {...rootProps}
  bind:this={rootElement}
  data-slot="tabs"
  data-orientation={orientation}
  aria-label={as === "nav" ? ariaLabel : undefined}
  aria-labelledby={as === "nav" ? ariaLabelledby : undefined}
  class={twMerge(className)}
  onclick={handleClick}
  onfocusin={handleFocusIn}
  onkeydown={handleKeydown}
>
  {@render children?.()}
</svelte:element>

  • Button — gives panel tabs or adjacent close actions truthful button semantics and can render a destination as an anchor or framework Link.
  • Radio — represents one selected value rather than one visible peer panel.
  • Menu — presents a temporary collection of actions or destinations.
  • Select — chooses one value from a longer fixed list in less space.
  • Combobox — searches and chooses from a long or remote list.

All open source projects are released under the MIT License.