Tabs
tabsuiHorizontal tab navigation with content panels — pick one panel at a time. Underline or pills variants. Hand-rolled roving keyboard navigation with automatic activation.
Installation
$pnpm dlx shadcn-svelte@latest add https://uipkge.dev/r/svelte/tabs.json$npx shadcn-svelte@latest add https://uipkge.dev/r/svelte/tabs.json$yarn dlx shadcn-svelte@latest add https://uipkge.dev/r/svelte/tabs.json$bunx shadcn-svelte@latest add https://uipkge.dev/r/svelte/tabs.jsonManifest:
https://uipkge.dev/r/svelte/tabs.jsonInstalls to:src/lib/components/ui/tabs/Examples
Loading interactive previews…
Loading interactive previews…
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
variant | | segmented | optional |
orientation | 'horizontal''vertical' | horizontal | optional |
valueControlled active value. Bind with `bind:value`. | string | — | optional |
defaultValueInitial active value for uncontrolled usage. | string | — | optional |
ref | HTMLDivElement | null | — | optional |
onValueChange | (value: string) => void | — | optional |
Schema
Type aliases from this item's source — use them to shape the data you pass in.
TabsContextValueinterface TabsContextValue {
current: () => string | undefined
orientation: () => TabsOrientation
select: (value: string) => void
triggerId: (value: string) => string
contentId: (value: string) => string
}npm dependencies
Used by
Files installed (7)
src/lib/components/ui/tabs/Tabs.svelte1.4 kB
<script lang="ts" module> import type { HTMLAttributes } from 'svelte/elements' import type { TabsOrientation } from './context' export interface TabsProps extends HTMLAttributes<HTMLDivElement> { /** Controlled active value. Bind with `bind:value`. */ value?: string /** Initial active value for uncontrolled usage. */ defaultValue?: string orientation?: TabsOrientation ref?: HTMLDivElement | null onValueChange?: (value: string) => void } </script> <script lang="ts"> import { cn } from '$lib/utils' import { nextTabsId, setTabsContext } from './context' let { class: className, defaultValue, value = $bindable(defaultValue), orientation = 'horizontal', children, ref = $bindable(null), onValueChange, ...restProps }: TabsProps = $props() const baseId = nextTabsId() function select(next: string) { value = next onValueChange?.(next) } setTabsContext({ current: () => value, orientation: () => orientation, select, triggerId: (v: string) => `${baseId}-trigger-${v}`, contentId: (v: string) => `${baseId}-content-${v}`, }) </script> <div bind:this={ref} data-uipkge="" data-slot="tabs" data-orientation={orientation} class={cn('flex w-full', orientation === 'vertical' ? 'flex-row gap-4' : 'flex-col gap-2', className)} {...restProps} > {@render children?.()} </div>src/lib/components/ui/tabs/TabsContent.svelte1.4 kB
<script lang="ts" module> import type { HTMLAttributes } from 'svelte/elements' export interface TabsContentProps extends HTMLAttributes<HTMLDivElement> { value: string forceMount?: boolean ref?: HTMLDivElement | null } </script> <script lang="ts"> import { cn } from '$lib/utils' import { getTabsContext } from './context' let { class: className, value, forceMount = false, children, ref = $bindable(null), ...restProps }: TabsContentProps = $props() const ctx = getTabsContext() const isActive = $derived(ctx?.current() === value) </script> {#if forceMount || isActive} <div bind:this={ref} role="tabpanel" id={ctx?.contentId(value)} aria-labelledby={ctx?.triggerId(value)} tabindex={0} hidden={!isActive || undefined} data-uipkge="" data-slot="tabs-content" data-state={isActive ? 'active' : 'inactive'} class={cn( 'ring-offset-background focus-visible:border-ring focus-visible:ring-ring/50 motion-safe:data-[state=active]:animate-in motion-safe:data-[state=active]:fade-in-0 motion-safe:data-[state=active]:blur-in-2 motion-safe:data-[state=active]:slide-in-from-bottom-1 motion-safe:data-[state=active]:ease-emphasized flex-1 focus-visible:ring-2 focus-visible:ring-[3px] focus-visible:outline-none motion-safe:data-[state=active]:duration-200', className, )} {...restProps} > {@render children?.()} </div> {/if}src/lib/components/ui/tabs/TabsList.svelte6.7 kB
<script lang="ts" module> import type { HTMLAttributes } from 'svelte/elements' import type { TabsOrientation } from './context' import type { TabsListVariants } from './tabs.variants' export interface TabsListProps extends HTMLAttributes<HTMLDivElement> { variant?: TabsListVariants['variant'] orientation?: TabsOrientation /** Enable sliding active indicator (default true). When false, active surface paints on the trigger. */ animated?: boolean ref?: HTMLDivElement | null } </script> <script lang="ts"> import type { KeyboardEventHandler } from 'svelte/elements' import { cn } from '$lib/utils' import { getTabsContext } from './context' import { tabsListVariants } from './tabs.variants' let { class: className, variant, orientation, animated = true, children, ref = $bindable(null), onkeydown, ...restProps }: TabsListProps = $props() const ctx = getTabsContext() // Inherit orientation from <Tabs> when not set explicitly. const effectiveOrientation = $derived(orientation ?? ctx?.orientation() ?? 'horizontal') const effectiveVariant = $derived(variant ?? 'segmented') let listEl = $state<HTMLDivElement | null>(null) let indicatorStyle = $state<Record<string, string>>({ opacity: '0' }) let firstPosition = true $effect(() => { ref = listEl }) function motionSafeTransition(): string { if (typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches) { return 'none' } return firstPosition ? 'none' : 'transform 220ms cubic-bezier(0.22, 1, 0.36, 1), width 220ms cubic-bezier(0.22, 1, 0.36, 1), height 220ms cubic-bezier(0.22, 1, 0.36, 1)' } function updateIndicator() { const root = listEl ?? ref if (!root || typeof window === 'undefined') return const active = root.querySelector<HTMLElement>('[data-slot="tabs-trigger"][data-state="active"]') if (!active) { indicatorStyle = { opacity: '0' } return } const listRect = root.getBoundingClientRect() const activeRect = active.getBoundingClientRect() const left = activeRect.left - listRect.left + root.scrollLeft const top = activeRect.top - listRect.top + root.scrollTop const transition = motionSafeTransition() if (effectiveVariant === 'underline') { const thickness = 2 if (effectiveOrientation === 'vertical') { indicatorStyle = { width: `${thickness}px`, height: `${activeRect.height}px`, transform: `translate3d(${listRect.width - thickness}px, ${top}px, 0)`, opacity: '1', transition, } } else { indicatorStyle = { width: `${activeRect.width}px`, height: `${thickness}px`, transform: `translate3d(${left}px, ${listRect.height - thickness}px, 0)`, opacity: '1', transition, } } } else { indicatorStyle = { width: `${activeRect.width}px`, height: `${activeRect.height}px`, transform: `translate3d(${left}px, ${top}px, 0)`, opacity: '1', transition, } } firstPosition = false } // Observe size + active-trigger changes while animated. Re-binds when the // variant, orientation, or animated flag changes (first paint has no slide). $effect(() => { if (!animated) { indicatorStyle = { opacity: '0' } return } // Tracked reads: re-run the effect when these change. void effectiveVariant void effectiveOrientation void ctx?.current() const root = listEl ?? ref if (!root || typeof window === 'undefined') return firstPosition = true // Defer past layout so getBoundingClientRect reads settled geometry. const raf = requestAnimationFrame(() => updateIndicator()) const ro = new ResizeObserver(() => updateIndicator()) ro.observe(root) root.querySelectorAll('[data-slot="tabs-trigger"]').forEach((el) => ro.observe(el)) const mo = new MutationObserver((mutations) => { for (const m of mutations) { if (m.type === 'childList') root.querySelectorAll('[data-slot="tabs-trigger"]').forEach((el) => ro.observe(el)) } updateIndicator() }) mo.observe(root, { attributes: true, attributeFilter: ['data-state'], subtree: true, childList: true }) return () => { cancelAnimationFrame(raf) ro.disconnect() mo.disconnect() } }) const indicatorClass = $derived( effectiveVariant === 'pill' ? 'pointer-events-none absolute top-0 left-0 z-0 rounded-full bg-primary shadow-xs will-change-transform' : effectiveVariant === 'underline' ? 'pointer-events-none absolute top-0 left-0 z-0 bg-foreground will-change-transform' : 'pointer-events-none absolute top-0 left-0 z-0 rounded-sm bg-background shadow-xs will-change-transform', ) function indicatorStyleAttr(): string { return Object.entries(indicatorStyle) .map(([k, v]) => `${k}: ${v}`) .join('; ') } // Roving keyboard navigation with automatic activation (matches reka-ui // Tabs defaults): arrows move + select, Home/End jump. const handleKeydown: KeyboardEventHandler<HTMLDivElement> = (event) => { onkeydown?.(event) if (event.defaultPrevented) return const root = listEl ?? ref if (!root) return const vertical = effectiveOrientation === 'vertical' const nextKeys = vertical ? ['ArrowDown', 'ArrowRight'] : ['ArrowRight', 'ArrowDown'] const prevKeys = vertical ? ['ArrowUp', 'ArrowLeft'] : ['ArrowLeft', 'ArrowUp'] if (![...nextKeys, ...prevKeys, 'Home', 'End'].includes(event.key)) return event.preventDefault() const triggers = [...root.querySelectorAll<HTMLElement>('[data-slot="tabs-trigger"]:not([disabled])')] if (triggers.length === 0) return const current = triggers.indexOf(document.activeElement as HTMLElement) let next: number if (event.key === 'Home') next = 0 else if (event.key === 'End') next = triggers.length - 1 else if (nextKeys.includes(event.key)) next = (current + 1 + triggers.length) % triggers.length else next = (current - 1 + triggers.length) % triggers.length triggers[next]?.focus() triggers[next]?.click() } </script> <div bind:this={listEl} role="tablist" aria-orientation={effectiveOrientation} data-uipkge="" data-slot="tabs-list" data-animated={animated ? 'true' : 'false'} class={cn( 'group/list relative', tabsListVariants({ variant: effectiveVariant, orientation: effectiveOrientation }), className, )} onkeydown={handleKeydown} {...restProps} > {#if animated} <span data-slot="tabs-indicator" aria-hidden="true" class={indicatorClass} style={indicatorStyleAttr()}></span> {/if} {@render children?.()} </div>src/lib/components/ui/tabs/TabsTrigger.svelte1.5 kB
<script lang="ts" module> import type { HTMLButtonAttributes } from 'svelte/elements' import type { TabsOrientation } from './context' import type { TabsTriggerVariants } from './tabs.variants' export interface TabsTriggerProps extends HTMLButtonAttributes { value: string size?: TabsTriggerVariants['size'] variant?: TabsTriggerVariants['variant'] orientation?: TabsOrientation ref?: HTMLButtonElement | null } </script> <script lang="ts"> import { cn } from '$lib/utils' import { getTabsContext } from './context' import { tabsTriggerVariants } from './tabs.variants' let { class: className, value, size, variant, orientation, disabled = false, children, ref = $bindable(null), onclick, ...restProps }: TabsTriggerProps = $props() const ctx = getTabsContext() const effectiveOrientation = $derived(orientation ?? ctx?.orientation() ?? 'horizontal') const isActive = $derived(ctx?.current() === value) </script> <button bind:this={ref} type="button" role="tab" id={ctx?.triggerId(value)} aria-selected={isActive} aria-controls={ctx?.contentId(value)} tabindex={isActive ? 0 : -1} data-uipkge="" data-slot="tabs-trigger" data-state={isActive ? 'active' : 'inactive'} disabled={disabled} class={cn( tabsTriggerVariants({ size, variant, orientation: effectiveOrientation }), className, )} onclick={(event) => { onclick?.(event) if (!event.defaultPrevented && !disabled) ctx?.select(value) }} {...restProps} > {@render children?.()} </button>src/lib/components/ui/tabs/tabs.variants.ts4.3 kB
import type { VariantProps } from 'class-variance-authority' import { cva } from 'class-variance-authority' /** * Variant definitions live in their own file (rather than the package * `index.ts`) so consuming Vue SFCs can import without creating a circular * dependency through the index. See card.variants.ts for the canonical * example + the SSR symptom that motivated the split. */ export const tabsListVariants = cva( // Scrollable when triggers overflow. Centering uses trigger auto margins // instead of justify-center so an overflowing list stays scrollable from its // start edge (justify-center + overflow makes leading items unreachable). // The scrollbar is hidden; scrolling still works via wheel/touch/keyboard. 'inline-flex h-9 max-w-full items-center overflow-x-auto rounded-lg bg-muted p-1 text-muted-foreground overscroll-x-contain [scrollbar-width:none] [&::-webkit-scrollbar]:hidden [&>[data-slot=tabs-trigger]:first-child]:ml-auto [&>[data-slot=tabs-trigger]:last-child]:mr-auto', { variants: { variant: { // Solid muted track with rounded inset triggers — the default look. segmented: 'gap-1 rounded-lg bg-muted p-1', // Transparent track with rounded-full pill triggers. pill: 'h-auto gap-2 bg-transparent p-0', // Bottom-border bar (horizontal) or right-border bar (vertical), with // an underline on the active trigger. underline: 'h-auto gap-0 bg-transparent p-0', }, orientation: { horizontal: 'flex-row', vertical: 'h-auto flex-col items-stretch', }, }, compoundVariants: [ { variant: 'underline', orientation: 'horizontal', // Neutralize the base auto-margin centering; this variant aligns start. class: 'w-full justify-start border-b border-border [&>[data-slot=tabs-trigger]:first-child]:ml-0 [&>[data-slot=tabs-trigger]:last-child]:mr-0', }, { variant: 'underline', orientation: 'vertical', class: 'border-r border-border', }, ], defaultVariants: { variant: 'segmented', orientation: 'horizontal', }, }, ) export const tabsTriggerVariants = cva( // z-10 keeps label above the sliding indicator; active surface paints on the indicator // when the parent list has data-animated="true". Static active chrome restores when // data-animated="false" (see group-data variants below). 'relative z-10 inline-flex shrink-0 items-center justify-center gap-1.5 whitespace-nowrap font-medium ring-offset-background [&_svg]:shrink-0 transition-colors duration-200 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50', { variants: { variant: { // Active bg/shadow live on TabsList indicator so it can slide between triggers. segmented: 'rounded-md data-[state=active]:text-foreground group-data-[animated=false]/list:data-[state=active]:bg-background group-data-[animated=false]/list:data-[state=active]:shadow-xs', pill: 'rounded-full border border-border bg-transparent data-[state=active]:border-transparent data-[state=active]:text-primary-foreground group-data-[animated=false]/list:data-[state=active]:bg-primary group-data-[animated=false]/list:data-[state=active]:border-primary', // Underline bar slides on TabsList indicator; keep transparent border for layout stability. underline: 'rounded-none border-b-2 border-transparent -mb-px data-[state=active]:text-foreground group-data-[animated=false]/list:data-[state=active]:border-foreground', }, size: { default: 'h-7 px-3 text-sm', sm: 'h-6 px-2.5 text-xs', lg: 'h-8 px-4 text-sm', }, orientation: { horizontal: '', vertical: 'w-full justify-start', }, }, compoundVariants: [ { variant: 'underline', orientation: 'vertical', class: 'border-b-0 border-r-2 -mr-px group-data-[animated=false]/list:data-[state=active]:border-foreground', }, ], defaultVariants: { variant: 'segmented', size: 'default', orientation: 'horizontal', }, }, ) export type TabsListVariants = VariantProps<typeof tabsListVariants> export type TabsTriggerVariants = VariantProps<typeof tabsTriggerVariants>src/lib/components/ui/tabs/context.ts0.9 kB
import { getContext, setContext } from 'svelte' export type TabsOrientation = 'horizontal' | 'vertical' /** * Tabs state shared from <Tabs> to its descendants. Exposed as functions so * reads inside child templates/`$derived` track the parent's runes state. */ export interface TabsContextValue { current: () => string | undefined orientation: () => TabsOrientation select: (value: string) => void triggerId: (value: string) => string contentId: (value: string) => string } const KEY = Symbol.for('uipkge:tabs') export function setTabsContext(value: TabsContextValue): TabsContextValue { setContext(KEY, value) return value } export function getTabsContext(): TabsContextValue | null { return getContext<TabsContextValue | null>(KEY) ?? null } let uid = 0 /** Instance-unique id prefix for trigger/content aria linking. */ export function nextTabsId(): string { uid += 1 return `uipkge-tabs-${uid}` }src/lib/components/ui/tabs/index.ts0.5 kB
export { default as Tabs, type TabsProps } from './Tabs.svelte' export { default as TabsContent, type TabsContentProps } from './TabsContent.svelte' export { default as TabsList, type TabsListProps } from './TabsList.svelte' export { default as TabsTrigger, type TabsTriggerProps } from './TabsTrigger.svelte' export { tabsListVariants, tabsTriggerVariants, type TabsListVariants, type TabsTriggerVariants } from './tabs.variants' export type { TabsOrientation } from './context'
Raw manifest:https://uipkge.dev/r/svelte/tabs.json