Settings-style side list with a gliding highlight that folds into a select.
Page navigation@base-ui/react
01Preview
Members
12 people, 3 pending invites. Admins can change roles.
Maya Okafor · Ownermaya@northwind.dev
Jonas Lindqvist · Adminjonas@northwind.dev
Priya Raman · Memberpriya.raman@northwind.dev
02Install
Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup. It needs:
npm install @base-ui/react03Usage
import {
VerticalTabs, VerticalTabsLabel, VerticalTabsList, VerticalTabsPanel, VerticalTabsPanels, VerticalTabsTab,
} from "@/components/ui/vertical-tabs";
<VerticalTabs defaultValue="profile">
<VerticalTabsList aria-label="Settings">
<VerticalTabsLabel>Account</VerticalTabsLabel>
<VerticalTabsTab value="profile" icon={<User />}>Profile</VerticalTabsTab>
<VerticalTabsTab value="billing" icon={<CreditCard />} hint="Pro">Billing</VerticalTabsTab>
</VerticalTabsList>
<VerticalTabsPanels>
<VerticalTabsPanel value="profile">…</VerticalTabsPanel>
<VerticalTabsPanel value="billing">…</VerticalTabsPanel>
</VerticalTabsPanels>
</VerticalTabs>04Source
"use client";
import { Select } from "@base-ui/react/select";
import { Tabs as BaseTabs } from "@base-ui/react/tabs";
import { createContext, use, useLayoutEffect, useRef, useState, useSyncExternalStore } from "react";
import { cn } from "@/lib/cn";
import { Check, ChevronsUpDown } from "@/lib/icons";
import { useControllableState } from "@/lib/use-controllable-state";
type Value = string;
type Entry = { value: Value; label: React.ReactNode; icon?: React.ReactNode; hint?: React.ReactNode; disabled?: boolean; el: HTMLElement };
/**
* Every tab registers what it shows, so the narrow layout can offer the same
* sections as a select without the consumer describing them twice.
*/
function createRegistry() {
let entries: Entry[] = [];
const listeners = new Set<() => void>();
const emit = () => listeners.forEach((l) => l());
return {
subscribe(l: () => void) {
listeners.add(l);
return () => void listeners.delete(l);
},
snapshot: () => entries,
set(entry: Entry) {
const rest = entries.filter((e) => e.value !== entry.value);
// Keep document order, whatever order the tabs mounted in.
entries = [...rest, entry].sort((a, b) => (a.el.compareDocumentPosition(b.el) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1));
emit();
},
remove(value: Value) {
entries = entries.filter((e) => e.value !== value);
emit();
},
};
}
type Registry = ReturnType<typeof createRegistry>;
const empty: Entry[] = [];
type Collapse = "sm" | "md";
type Ctx = { value: Value | null; setValue: (v: Value) => void; registry: Registry; collapse: Collapse };
const VerticalTabsContext = createContext<Ctx | null>(null);
const useVT = () => {
const ctx = use(VerticalTabsContext);
if (!ctx) throw new Error("VerticalTabs parts must be used inside <VerticalTabs>");
return ctx;
};
export type VerticalTabsProps = Omit<React.ComponentProps<"div">, "defaultValue" | "onChange" | "dir"> & {
value?: Value | null;
/** The section shown first. Without it no section is active until one is picked. */
defaultValue?: Value | null;
onValueChange?: (value: Value | null) => void;
/** Below this container width the side list becomes a select above the panel. */
collapseBelow?: Collapse;
};
export function VerticalTabs({ value: valueProp, defaultValue = null, onValueChange, collapseBelow = "sm", className, children, onKeyDownCapture, onPointerDownCapture, ...rest }: VerticalTabsProps) {
const [value, setValue] = useControllableState<Value | null>({ value: valueProp, defaultValue, onChange: onValueChange });
const [registry] = useState(createRegistry);
return (
<VerticalTabsContext value={{ value, setValue, registry, collapse: collapseBelow }}>
<BaseTabs.Root
value={value}
onValueChange={(v) => setValue(v as Value)}
orientation="vertical"
data-vtabs-root=""
data-collapse={collapseBelow}
// Arrow keys switch sections on the same frame; only pointer switches animate.
onKeyDownCapture={(e) => {
e.currentTarget.dataset.nav = "key";
onKeyDownCapture?.(e);
}}
onPointerDownCapture={(e) => {
e.currentTarget.dataset.nav = "pointer";
onPointerDownCapture?.(e);
}}
className={cn("group/vtabs @container/vtabs min-w-0", className)}
{...rest}
>
<div
className={cn(
"flex flex-col gap-4",
collapseBelow === "sm" ? "@[30rem]/vtabs:flex-row @[30rem]/vtabs:gap-6" : "@[40rem]/vtabs:flex-row @[40rem]/vtabs:gap-8",
)}
>
{children}
</div>
</BaseTabs.Root>
</VerticalTabsContext>
);
}
// The side list and the select swap at the same container width: 480px, or 640px for longer labels.
const wide: Record<Collapse, string> = {
sm: "hidden @[30rem]/vtabs:block @[30rem]/vtabs:w-[176px] @[40rem]/vtabs:w-[200px]",
md: "hidden @[40rem]/vtabs:block @[40rem]/vtabs:w-[200px]",
};
const narrow: Record<Collapse, string> = { sm: "@[30rem]/vtabs:hidden", md: "@[40rem]/vtabs:hidden" };
export type VerticalTabsListProps = Omit<BaseTabs.List.Props, "className"> & {
className?: string;
/** Names the list, and the select that replaces it on narrow widths. */
"aria-label": string;
};
export function VerticalTabsList({ className, children, activateOnFocus = true, "aria-label": label, ...rest }: VerticalTabsListProps) {
const { value, setValue, registry, collapse } = useVT();
const entries = useSyncExternalStore(registry.subscribe, registry.snapshot, () => empty);
const listRef = useRef<HTMLDivElement>(null);
// One hover wash for the whole list, handed from row to row.
const [hover, setHover] = useState<{ top: number; height: number; on: boolean; instant: boolean }>({ top: 0, height: 0, on: false, instant: true });
const onPointerOver = (e: React.PointerEvent) => {
if (e.pointerType !== "mouse") return;
const tab = (e.target as HTMLElement).closest<HTMLElement>("[role=tab]");
if (!tab || !listRef.current?.contains(tab) || tab.hasAttribute("data-disabled")) return;
const next = { top: tab.offsetTop, height: tab.offsetHeight };
setHover((h) => (h.on && h.top === next.top ? h : { ...next, on: true, instant: !h.on }));
};
return (
<>
<div className={cn("shrink-0", wide[collapse])}>
<BaseTabs.List
ref={listRef}
aria-label={label}
activateOnFocus={activateOnFocus}
onPointerOver={onPointerOver}
onPointerLeave={() => setHover((h) => ({ ...h, on: false }))}
className={cn("relative isolate flex flex-col gap-px", className)}
{...rest}
>
{children}
<span
aria-hidden
className={cn(
"pointer-events-none absolute inset-x-0 top-0 -z-10 rounded-lg bg-fg/[0.035]",
// Glides between rows; appears in place when the pointer first arrives.
hover.instant ? "transition-opacity" : "transition-[opacity,translate,height]",
"duration-150 ease-out-quart",
!hover.on && "opacity-0",
)}
style={{ translate: `0 ${hover.top}px`, height: hover.height }}
/>
<BaseTabs.Indicator
className={cn(
"pointer-events-none absolute inset-x-0 top-0 -z-10 h-(--active-tab-height) translate-y-(--active-tab-top) rounded-lg bg-fg/[0.07]",
"transition-[translate,height] duration-[240ms] ease-in-out-quart group-data-[nav=key]/vtabs:duration-0",
)}
/>
</BaseTabs.List>
</div>
<div className={narrow[collapse]}>
<Select.Root value={value} onValueChange={(v) => v != null && setValue(v as Value)}>
<Select.Trigger
aria-label={label}
className={cn(
"group/trigger relative flex h-9 w-full min-w-0 items-center gap-2 rounded-lg border border-line-2 bg-raised pr-2 pl-2.5 text-left text-[13px] text-fg shadow-[var(--shadow)] select-none",
"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3",
"transition-[background-color,border-color,scale] duration-150 ease-out active:scale-[0.985] active:duration-75",
"hover:border-fg-4 hover:bg-hover data-popup-open:border-fg-4 data-popup-open:bg-hover",
"pointer-coarse:h-11 pointer-coarse:text-[15px]",
)}
>
<Select.Value
render={(props) => {
const current = entries.find((e) => e.value === value);
return (
<span {...props} className="flex min-w-0 flex-1 items-center gap-2">
{current?.icon && <span className="flex shrink-0 text-fg-3 [&_svg]:size-4">{current.icon}</span>}
<span className="truncate">{current?.label ?? <span className="text-fg-4">Choose a section</span>}</span>
</span>
);
}}
/>
<Select.Icon className="flex shrink-0 text-fg-3 transition-colors duration-150 group-hover/trigger:text-fg-2 [&_svg]:size-4">
<ChevronsUpDown />
</Select.Icon>
</Select.Trigger>
<Select.Portal>
<Select.Positioner alignItemWithTrigger={false} sideOffset={6} collisionPadding={8} className="z-(--z-popover) outline-none select-none">
<Select.Popup
className={cn(
"relative min-w-(--anchor-width) max-w-(--available-width) origin-(--transform-origin) overflow-hidden rounded-xl border border-line-2 bg-raised text-fg shadow-pop outline-none",
"transition-[opacity,scale] duration-160 ease-out-expo data-ending-style:duration-100 data-ending-style:ease-out",
"data-starting-style:scale-[0.96] data-starting-style:opacity-0 data-ending-style:scale-[0.98] data-ending-style:opacity-0",
)}
>
<Select.List className="max-h-[min(var(--available-height),20rem)] scroll-py-1 overflow-y-auto overscroll-contain p-1 outline-none">
{entries.map((e) => (
<Select.Item
key={e.value}
value={e.value}
disabled={e.disabled}
className={cn(
"group/item flex h-8 cursor-default scroll-my-1 items-center gap-2 rounded-lg pr-2 pl-2 text-[13px] text-fg outline-none select-none pointer-coarse:h-11",
"data-highlighted:bg-fg/[0.07] data-disabled:text-fg-4",
)}
>
{e.icon && <span className="flex shrink-0 text-fg-3 group-data-disabled/item:text-fg-4 [&_svg]:size-4">{e.icon}</span>}
<Select.ItemText className="min-w-0 flex-1 truncate">{e.label}</Select.ItemText>
{e.hint != null && <span className="shrink-0 text-[12px] text-fg-3 tabular group-data-disabled/item:text-fg-4">{e.hint}</span>}
<span className="grid size-4 shrink-0 place-items-center">
<Select.ItemIndicator>
<Check size={14} />
</Select.ItemIndicator>
</span>
</Select.Item>
))}
</Select.List>
</Select.Popup>
</Select.Positioner>
</Select.Portal>
</Select.Root>
</div>
</>
);
}
export type VerticalTabsTabProps = Omit<BaseTabs.Tab.Props, "className" | "children" | "value"> & {
value: Value;
className?: string;
children: React.ReactNode;
/** 16px icon before the label. */
icon?: React.ReactNode;
/** Right-aligned meta: a count, a plan, a status word. */
hint?: React.ReactNode;
};
export function VerticalTabsTab({ value, icon, hint, disabled, className, children, ...rest }: VerticalTabsTabProps) {
const { registry } = useVT();
const ref = useRef<HTMLButtonElement>(null);
useLayoutEffect(() => {
if (ref.current) registry.set({ value, label: children, icon, hint, disabled, el: ref.current });
}, [registry, value, children, icon, hint, disabled]);
useLayoutEffect(() => () => registry.remove(value), [registry, value]);
return (
<BaseTabs.Tab
ref={ref}
value={value}
disabled={disabled}
className={cn(
"group/tab relative flex h-8 w-full min-w-0 select-none items-center gap-2.5 rounded-lg px-2.5 text-left text-[13px] outline-none",
"touch-manipulation [-webkit-tap-highlight-color:transparent]",
"text-fg-2 transition-colors duration-150 hover:text-fg data-active:text-fg data-active:font-medium",
"focus-visible:outline-solid focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-fg-3",
"data-disabled:pointer-events-none data-disabled:text-fg-4",
className,
)}
{...rest}
>
{icon && (
<span className="flex shrink-0 text-fg-3 transition-[color,scale] duration-150 ease-out-quart group-hover/tab:text-fg-2 group-active/tab:scale-[0.9] group-data-active/tab:text-fg [&_svg]:size-4">
{icon}
</span>
)}
{/* The label reserves its bold width, so becoming active never nudges the hint. */}
<span className="grid min-w-0 flex-1">
<span aria-hidden className="invisible col-start-1 row-start-1 truncate font-medium">
{children}
</span>
<span className="col-start-1 row-start-1 truncate">{children}</span>
</span>
{hint != null && <span className="shrink-0 text-[12px] font-normal text-fg-3 tabular group-data-disabled/tab:text-fg-4">{hint}</span>}
</BaseTabs.Tab>
);
}
export type VerticalTabsLabelProps = React.ComponentProps<"div">;
/** A quiet group heading between tabs ("Account", "Workspace"). Shown in the side list only. */
export function VerticalTabsLabel({ className, ...rest }: VerticalTabsLabelProps) {
return (
<div
aria-hidden
className={cn("px-2.5 pt-4 pb-1.5 font-mono text-2xs tracking-[0.08em] text-fg-4 uppercase select-none first:pt-1", className)}
{...rest}
/>
);
}
export type VerticalTabsPanelsProps = React.ComponentProps<"div">;
/** Stacks the panels in one cell so the outgoing one fades while the next arrives. */
export function VerticalTabsPanels({ className, ...rest }: VerticalTabsPanelsProps) {
return <div className={cn("relative grid min-w-0 flex-1 grid-cols-1 content-start", className)} {...rest} />;
}
export type VerticalTabsPanelProps = Omit<BaseTabs.Panel.Props, "className"> & { className?: string };
export function VerticalTabsPanel({ className, ...rest }: VerticalTabsPanelProps) {
return (
<BaseTabs.Panel
className={cn(
"col-start-1 row-start-1 min-w-0 rounded-md outline-none",
"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-[6px] focus-visible:outline-fg-3",
// Arrives from the direction of the section picked; leaves by fading out of the flow.
"transition-[opacity,translate] duration-[220ms] ease-out-expo",
"data-starting-style:opacity-0 data-starting-style:data-[activation-direction=down]:translate-y-1.5 data-starting-style:data-[activation-direction=up]:-translate-y-1.5",
"data-ending-style:pointer-events-none data-ending-style:absolute data-ending-style:inset-x-0 data-ending-style:top-0 data-ending-style:opacity-0 data-ending-style:duration-[120ms] data-ending-style:ease-out-quart",
"group-data-[nav=key]/vtabs:duration-0",
className,
)}
{...rest}
/>
);
}05Props
VerticalTabs
| Prop | Type | Default | Description |
|---|---|---|---|
| value | string | null | — | The open section, controlled. |
| defaultValue | string | null | null | The section open on first render. Without it nothing is open until one is picked. |
| onValueChange | (value: string | null) => void | — | Called when a section is picked from the list or the select. |
| collapseBelow | "sm" | "md" | "sm" | Container width where the list becomes a select: 480px, or 640px for long labels. |
VerticalTabsList
| Prop | Type | Default | Description |
|---|---|---|---|
| aria-label* | string | — | Names the list, and the select that stands in for it on narrow widths. |
| activateOnFocus | boolean | true | Arrow keys open the section they land on. |
VerticalTabsTab
| Prop | Type | Default | Description |
|---|---|---|---|
| value* | string | — | Matches the panel it opens. |
| icon | ReactNode | — | 16px icon before the label. Brightens with the row. |
| hint | ReactNode | — | Right-aligned meta: a member count, a plan, why it is locked. |
| disabled | boolean | false | Dimmed, including its hint and its option in the select. |
VerticalTabsLabel
| Prop | Type | Default | Description |
|---|---|---|---|
| children | ReactNode | — | A mono group heading between tabs. Hidden from screen readers and from the select. |
VerticalTabsPanel
| Prop | Type | Default | Description |
|---|---|---|---|
| value* | string | — | The tab that opens this panel. |
| keepMounted | boolean | false | Keep a hidden panel in the DOM, to preserve form state. |
06Notes
Behavior
- The layout reads its own width with a container query, not the viewport, so it folds into a select inside a narrow sheet on a wide screen too.
- Tabs register their label, icon and hint, so the select offers exactly the same sections in the same order without being described twice.
- The active label turns medium weight inside a box that already reserved the bold width, so hints never nudge sideways.
- The outgoing panel leaves the flow as it fades, so the content column never holds two panels' height at once.
Motion
- The active pill slides and resizes over 240ms on ease-in-out-quart; a fainter hover wash glides between rows in 150ms, and appears in place instead of sliding in from its last position.
- Panels arrive 6px from the direction of the section picked, in 220ms on ease-out-expo, and leave in 120ms.
- Keyboard switches are instant. Reduced motion keeps the fades and drops the travel.
Accessibility
- A vertical Base UI tablist: Up and Down move and open sections, Home and End jump, Tab moves into the panel.
- On narrow widths it is a Base UI select labeled with the list's aria-label, with the check on the current section.
- Group labels are presentational; the hover wash follows the mouse only, never touch.