Vertical Tabs
Settings-style vertical tabs: a bar slides along the left edge to the active tab and the panel cross-fades upward.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/vertical-tabs.json'use client'
import { useId, useLayoutEffect, useRef, useState, type ReactNode } from 'react'
import './vertical-tabs.css'
export interface VTab { id: string; label: string; content: ReactNode }
export interface VerticalTabsProps {
tabs: VTab[]
defaultValue?: string
indicator?: 'bar' | 'fill'
fill?: 'dark' | 'light'
className?: string
}
/** Vertical tablist with a travelling indicator. */
export function VerticalTabs({ tabs, defaultValue, indicator = 'bar', fill = 'dark', className }: VerticalTabsProps) {
const [v, setV] = useState(defaultValue ?? tabs[0].id)
const refs = useRef<(HTMLButtonElement | null)[]>([])
const [ind, setInd] = useState({ y: 0, h: 0 })
const uid = useId()
const i = Math.max(0, tabs.findIndex((t) => t.id === v))
useLayoutEffect(() => { const el = refs.current[i]; if (el) setInd({ y: el.offsetTop, h: el.offsetHeight }) }, [i])
const go = (n: number) => { const j = (n + tabs.length) % tabs.length; setV(tabs[j].id); refs.current[j]?.focus() }
return (
<div className={['sd-vtb', className].filter(Boolean).join(' ')} data-fill={fill} data-ind={indicator}>
<div className="sd-vtb__list" role="tablist" aria-orientation="vertical">
<span className="sd-vtb__ind" aria-hidden style={{ transform: `translateY(${ind.y}px)`, height: ind.h }} />
{tabs.map((t, j) => (
<button key={t.id} ref={(el) => { refs.current[j] = el }} id={`${uid}-${j}`} type="button" role="tab" aria-selected={j === i} aria-controls={`${uid}-p`} tabIndex={j === i ? 0 : -1} className="sd-vtb__tab" onClick={() => setV(t.id)}
onKeyDown={(e) => { const d = e.key === 'ArrowDown' || e.key === 'ArrowRight' ? 1 : e.key === 'ArrowUp' || e.key === 'ArrowLeft' ? -1 : 0; if (d) { e.preventDefault(); go(j + d) } }}>{t.label}</button>
))}
</div>
<div key={v} id={`${uid}-p`} role="tabpanel" aria-labelledby={`${uid}-${i}`} tabIndex={0} className="sd-vtb__panel">{tabs[i].content}</div>
</div>
)
}.sd-vtb { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-vtb[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-vtb button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-vtb :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-vtb .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-vtb { width: min(100%, 360px); display: grid; grid-template-columns: 120px 1fr; min-height: 260px; border-radius: 20px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); overflow: hidden; }
@media (max-width: 420px) { .sd-vtb { grid-template-columns: 1fr; } .sd-vtb__list { flex-direction: row !important; overflow-x: auto; border-right: 0 !important; border-bottom: 1px solid var(--line); } .sd-vtb__ind { display: none; } .sd-vtb__tab[aria-selected='true'] { background: var(--soft) !important; } }
.sd-vtb__list { position: relative; display: flex; flex-direction: column; gap: 2px; padding: 8px; border-right: 1px solid var(--line); }
.sd-vtb__ind { position: absolute; left: 0; top: 0; width: 2px; border-radius: 2px; background: var(--fg); transition: transform 320ms cubic-bezier(0.3, 1.2, 0.4, 1), height 320ms; }
.sd-vtb[data-ind='fill'] .sd-vtb__ind { left: 8px; right: 8px; width: auto; border-radius: 9px; background: var(--soft); box-shadow: inset 0 0 0 1px var(--line); }
.sd-vtb__tab { position: relative; height: 34px; padding: 0 10px; border-radius: 9px; font-size: 13px; color: var(--mute); text-align: left; white-space: nowrap; transition: color 160ms; }
.sd-vtb__tab:hover, .sd-vtb__tab[aria-selected='true'] { color: var(--fg); }
.sd-vtb__panel { padding: 16px; font-size: 13.5px; animation: sd-vtb-in 280ms cubic-bezier(0.2, 1, 0.3, 1); outline: 0; }
@keyframes sd-vtb-in { from { opacity: 0; transform: translateY(8px); } }
.sd-vtb__panel h4 { margin: 0 0 6px; font-size: 15px; }
.sd-vtb__panel p { margin: 0; color: var(--mute); line-height: 1.5; }
@media (prefers-reduced-motion: reduce) { .sd-vtb *, .sd-vtb *::before, .sd-vtb *::after { animation: none !important; transition: none !important; } }Overview
A left rail of tabs with a short indicator bar that travels to the active one. The panel slides up a few pixels as it fades in, matching the vertical direction of travel.
Below 420px the rail turns into a horizontal scroller so it still fits a phone.
When to use
- Settings, account pages, documentation sections.
When not to use
- Two or three short views: use a segmented control.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/vertical-tabs.json$
npx shadcn@latest add https://sundaeui.dev/r/vertical-tabs.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/vertical-tabs.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/vertical-tabs.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy vertical-tabs.tsx and vertical-tabs.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { VerticalTabs } from '@/components/sundae/vertical-tabs'
<VerticalTabs />Props
| Prop | Type | Default |
|---|---|---|
tabs*Tabs and panels. | { id: string; label: string; content: ReactNode }[] | — |
defaultValueInitial tab id. | string | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- WAI-ARIA tabs with aria-orientation="vertical"; Up/Down move and activate.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-vtb; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
Keyboard
- ↑ / ↓
- Previous / next tab
FAQ
- Is Vertical Tabs free?
- Yes. Vertical Tabs is free for personal and commercial projects, with full source on this page.
- How do I install Vertical Tabs?
- Run npx shadcn@latest add https://sundaeui.dev/r/vertical-tabs.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Vertical Tabs support keyboard and screen readers?
- WAI-ARIA tabs with aria-orientation="vertical"; Up/Down move and activate. Reduced motion removes the movement and keeps every state change.
- Does Vertical Tabs respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All navigation →Underline Tabs
Navigation · Refined- Pro
File Tree
Navigation · Refined - Pro
Collapsible Sidebar
Navigation · Refined Taffy Tabs
Navigation · PlayfulOrbit Nav
Navigation · PlayfulSegmented Control
Navigation · Refined