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.
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.
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 ofbutton[data-value]. Each later direct child with the samedata-valueis that button's panel. - With
as="nav", puta[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
<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
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
<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
| Input | Default | Purpose |
|---|---|---|
Vue v-model | inferred | Controlled or uncontrolled selected value. |
React value / defaultValue / onValueChange | inferred | React-native controlled or initial selected value. |
Svelte bind:value | inferred | Svelte-native selected value. |
as | div | Use nav when the direct children are route destinations. |
orientation | horizontal | horizontal uses Left/Right; vertical uses Up/Down. |
activation | automatic | automatic selects on focus; manual waits for Enter or Space. |
aria-label / aria-labelledby | required | Accessible name forwarded to the button list or navigation landmark. |
class / className | — | Ordinary 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.
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.
Vue Link navigation
<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>
React Link navigation
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
<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.
<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", anddata-orientation; - panel list:
data-slot="tabs-list",data-mode, anddata-orientation; - buttons or links:
data-slot="tab",data-mode,data-state="active|inactive", anddata-orientation; - panels:
data-slot="tab-panel",data-state="active|inactive", anddata-orientation.
<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:
<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:
<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, andtabpanelrelationship. - Generated IDs connect
aria-controlsandaria-labelledbywithout 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 receiverole="tab",aria-selected, or rovingtabindex. - 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
<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
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
<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>
Related components
- 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.