Vertical Tabs
vertical-tabsuiSettings-page navigation pattern — labels stack on the left, content panel on the right. Same API as Tabs but with a vertical orientation. Use for dense, multi-section settings UIs.
Installation
$pnpm dlx shadcn-svelte@latest add https://uipkge.dev/r/svelte/vertical-tabs.json$npx shadcn-svelte@latest add https://uipkge.dev/r/svelte/vertical-tabs.json$yarn dlx shadcn-svelte@latest add https://uipkge.dev/r/svelte/vertical-tabs.json$bunx shadcn-svelte@latest add https://uipkge.dev/r/svelte/vertical-tabs.jsonManifest:
https://uipkge.dev/r/svelte/vertical-tabs.jsonInstalls to:src/lib/components/ui/vertical-tabs/Examples
Loading interactive previews…
Loading interactive previews…
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
children | Snippet | — | optional |
valueControlled active value. Omit for uncontrolled mode with `defaultValue`. | string | — | optional |
defaultValueInitial active value in uncontrolled mode. | string | — | optional |
onValueChange | (value: string) => void | — | optional |
Files installed (6)
src/lib/components/ui/vertical-tabs/VerticalTabs.svelte1.9 kB
<script lang="ts" module> import { getContext, setContext } from 'svelte' import type { Snippet } from 'svelte' import type { HTMLAttributes } from 'svelte/elements' export interface VerticalTabsProps extends HTMLAttributes<HTMLDivElement> { children?: Snippet /** Controlled active value. Omit for uncontrolled mode with `defaultValue`. */ value?: string /** Initial active value in uncontrolled mode. */ defaultValue?: string onValueChange?: (value: string) => void } export interface VerticalTabsContext { getValue: () => string | undefined select: (value: string) => void rootId: string } const VERTICAL_TABS_KEY = Symbol('vertical-tabs') export function setVerticalTabsContext(ctx: VerticalTabsContext) { setContext(VERTICAL_TABS_KEY, ctx) } export function getVerticalTabsContext(component: string): VerticalTabsContext { const ctx = getContext<VerticalTabsContext | undefined>(VERTICAL_TABS_KEY) if (!ctx) throw new Error(`<${component}> must be used inside <VerticalTabs>`) return ctx } /** Keep value-derived element ids valid when values contain spaces or symbols. */ export function sanitizeId(value: string): string { return value.replace(/[^a-zA-Z0-9_-]/g, '-') } </script> <script lang="ts"> import { cn } from '$lib/utils' let { children, value = $bindable(), defaultValue, onValueChange, class: className, ...restProps }: VerticalTabsProps = $props() const rootId = $props.id() let internal = $state(defaultValue) const current = $derived(value ?? internal) function select(v: string) { if (value === undefined) internal = v onValueChange?.(v) } setVerticalTabsContext({ getValue: () => current, select, rootId, }) </script> <div data-uipkge data-slot="vertical-tabs" data-orientation="vertical" class={cn('flex w-full gap-6', className)} {...restProps} > {@render children?.()} </div>src/lib/components/ui/vertical-tabs/VerticalTabsList.svelte5.5 kB
<script lang="ts" module> import type { Snippet } from 'svelte' import type { HTMLAttributes } from 'svelte/elements' export interface VerticalTabsListProps extends HTMLAttributes<HTMLDivElement> { children?: Snippet /** Wrap keyboard focus from last trigger to first (and vice versa). Default true. */ loop?: boolean /** Enable sliding active indicator (default true). When false, active chrome paints on the trigger. */ animated?: boolean } </script> <script lang="ts"> import { cn } from '$lib/utils' import { getVerticalTabsContext } from './VerticalTabs.svelte' let { children, loop = true, animated = true, class: className, ...restProps }: VerticalTabsListProps = $props() const ctx = getVerticalTabsContext('VerticalTabsList') let listEl: HTMLDivElement | null = $state(null) let indicatorStyle = $state<Record<string, string>>({ opacity: '0' }) let firstPosition = true function motionSafeTransition() { 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() { if (!animated) return const root = listEl if (!root) return const active = root.querySelector<HTMLElement>('[data-slot="vertical-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() // Full active surface slides (muted pill); primary rail is nested absolute so it stays inset-y-1. indicatorStyle = { width: `${activeRect.width}px`, height: `${activeRect.height}px`, transform: `translate3d(${left}px, ${top}px, 0)`, opacity: '1', transition, } firstPosition = false } function styleAttr(style: Record<string, string>): string { return Object.entries(style) .map(([k, v]) => `${k.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}: ${v}`) .join('; ') } $effect(() => { const root = listEl const on = animated firstPosition = true if (!root || !on) { indicatorStyle = { opacity: '0' } return } const ro = new ResizeObserver(() => updateIndicator()) ro.observe(root) root.querySelectorAll('[data-slot="vertical-tabs-trigger"]').forEach((el) => ro.observe(el)) const mo = new MutationObserver((mutations) => { // Re-observe new triggers without treating parent re-renders as first paint. for (const m of mutations) { if (m.type === 'childList') { root.querySelectorAll('[data-slot="vertical-tabs-trigger"]').forEach((el) => ro.observe(el)) } } queueMicrotask(updateIndicator) }) mo.observe(root, { attributes: true, attributeFilter: ['data-state'], subtree: true, childList: true, }) updateIndicator() // The scroll container may settle a frame after mount (fonts, layout). const raf = requestAnimationFrame(() => updateIndicator()) return () => { ro.disconnect() mo.disconnect() cancelAnimationFrame(raf) } }) function enabledTriggers(root: HTMLElement): HTMLElement[] { return Array.from( root.querySelectorAll<HTMLElement>('[data-slot="vertical-tabs-trigger"]:not([data-disabled])'), ).filter((el) => !el.hasAttribute('disabled')) } function handleKeydown(e: KeyboardEvent) { const root = listEl if (!root) return const target = e.target as HTMLElement | null if (!target?.closest('[data-slot="vertical-tabs-trigger"]')) return const triggers = enabledTriggers(root) const idx = triggers.indexOf(target.closest('[data-slot="vertical-tabs-trigger"]') as HTMLElement) if (idx < 0) return let next = -1 if (e.key === 'ArrowDown' || e.key === 'ArrowRight') next = idx + 1 else if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') next = idx - 1 else if (e.key === 'Home') next = 0 else if (e.key === 'End') next = triggers.length - 1 else return e.preventDefault() if (next < 0) next = loop ? triggers.length - 1 : 0 if (next >= triggers.length) next = loop ? 0 : triggers.length - 1 const el = triggers[next] if (!el) return el.focus() // Automatic activation, like reka-ui tabs: focus selects. const v = el.getAttribute('data-value') if (v) ctx.select(v) } </script> <div bind:this={listEl} data-uipkge data-slot="vertical-tabs-list" data-animated={animated ? 'true' : 'false'} role="tablist" aria-orientation="vertical" class={cn('group/list border-border relative flex w-56 shrink-0 flex-col gap-0.5 border-r pr-3', className)} onkeydown={handleKeydown} {...restProps} > {#if animated} <span data-slot="vertical-tabs-indicator" aria-hidden="true" class="bg-muted pointer-events-none absolute top-0 left-0 z-0 rounded-md will-change-transform" style={styleAttr(indicatorStyle)} > <!-- Primary rail stays inset relative to the sliding surface (matches prior trigger chrome). --> <span class="bg-primary absolute inset-y-1 left-0 w-0.5 rounded-full"></span> </span> {/if} {@render children?.()} </div>src/lib/components/ui/vertical-tabs/VerticalTabsSection.svelte0.5 kB
<script lang="ts" module> import type { HTMLAttributes } from 'svelte/elements' export interface VerticalTabsSectionProps extends HTMLAttributes<HTMLDivElement> { label: string } </script> <script lang="ts"> import { cn } from '$lib/utils' let { label, class: className, ...restProps }: VerticalTabsSectionProps = $props() </script> <div data-uipkge data-slot="vertical-tabs-section" class={cn('text-muted-foreground mt-3 mb-1 px-2 text-xs font-medium tracking-wider uppercase first:mt-0', className)} {...restProps} > {label} </div>src/lib/components/ui/vertical-tabs/VerticalTabsTrigger.svelte2.3 kB
<script lang="ts" module> import type { Snippet } from 'svelte' import type { HTMLButtonAttributes } from 'svelte/elements' export interface VerticalTabsTriggerProps extends HTMLButtonAttributes { children?: Snippet value: string disabled?: boolean } </script> <script lang="ts"> import { cn } from '$lib/utils' import { getVerticalTabsContext, sanitizeId } from './VerticalTabs.svelte' let { children, value, disabled = false, class: className, ...restProps }: VerticalTabsTriggerProps = $props() const ctx = getVerticalTabsContext('VerticalTabsTrigger') const active = $derived(ctx.getValue() === value) const triggerId = $derived(`${ctx.rootId}-trigger-${sanitizeId(value)}`) const contentId = $derived(`${ctx.rootId}-content-${sanitizeId(value)}`) function handleClick() { if (!disabled) ctx.select(value) } </script> <button type="button" data-uipkge data-slot="vertical-tabs-trigger" data-value={value} data-state={active ? 'active' : 'inactive'} data-disabled={disabled ? '' : undefined} id={triggerId} role="tab" aria-selected={active} aria-controls={contentId} tabindex={active ? 0 : -1} {disabled} class={cn( // z-10 keeps label above the sliding indicator; active surface paints on the list // indicator when the parent list has data-animated=true. Static active chrome // restores when data-animated=false (group-data variants below). 'group/trigger text-muted-foreground relative z-10 flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm font-medium transition-[color,background-color] duration-150', 'hover:bg-muted/60 hover:text-foreground', 'focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none', 'disabled:pointer-events-none disabled:opacity-50', 'data-[state=active]:text-foreground', 'group-data-[animated=false]/list:data-[state=active]:bg-muted', // Static primary rail (only when list animation is off). `before:bg-primary before:pointer-events-none before:absolute before:inset-y-1 before:left-0 before:w-0.5 before:rounded-full before:opacity-0 before:content-['']`, 'group-data-[animated=false]/list:data-[state=active]:before:opacity-100', '[&>svg]:size-4 [&>svg]:shrink-0', className, )} onclick={handleClick} {...restProps} > {@render children?.()} </button>src/lib/components/ui/vertical-tabs/VerticalTabsContent.svelte1.3 kB
<script lang="ts" module> import type { Snippet } from 'svelte' import type { HTMLAttributes } from 'svelte/elements' export interface VerticalTabsContentProps extends HTMLAttributes<HTMLDivElement> { children?: Snippet value: string forceMount?: boolean } </script> <script lang="ts"> import { cn } from '$lib/utils' import { getVerticalTabsContext, sanitizeId } from './VerticalTabs.svelte' let { children, value, forceMount = false, class: className, ...restProps }: VerticalTabsContentProps = $props() const ctx = getVerticalTabsContext('VerticalTabsContent') const active = $derived(ctx.getValue() === value) const triggerId = $derived(`${ctx.rootId}-trigger-${sanitizeId(value)}`) const contentId = $derived(`${ctx.rootId}-content-${sanitizeId(value)}`) </script> {#if forceMount || active} <div data-uipkge data-slot="vertical-tabs-content" data-state={active ? 'active' : 'inactive'} id={contentId} role="tabpanel" aria-labelledby={triggerId} tabindex="0" hidden={forceMount && !active ? true : undefined} class={cn( 'ring-offset-background focus-visible:ring-ring/50 flex-1 focus-visible:ring-2 focus-visible:outline-none', className, )} {...restProps} > {@render children?.()} </div> {/if}src/lib/components/ui/vertical-tabs/index.ts0.5 kB
export { default as VerticalTabs, type VerticalTabsProps } from './VerticalTabs.svelte' export { default as VerticalTabsList, type VerticalTabsListProps } from './VerticalTabsList.svelte' export { default as VerticalTabsSection, type VerticalTabsSectionProps } from './VerticalTabsSection.svelte' export { default as VerticalTabsTrigger, type VerticalTabsTriggerProps } from './VerticalTabsTrigger.svelte' export { default as VerticalTabsContent, type VerticalTabsContentProps } from './VerticalTabsContent.svelte'
Raw manifest:https://uipkge.dev/r/svelte/vertical-tabs.json