Vertical Radix tabs with a sliding left-edge indicator and content that crossfades in 150ms.
npx shadcn@latest add @paragon/vertical-tabs"use client";
import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "@/lib/utils";
interface VerticalTabsContextValue {
value: string | undefined;
isStatic: boolean;
}
const VerticalTabsContext =
React.createContext<VerticalTabsContextValue | null>(null);
function useVerticalTabs() {
const context = React.useContext(VerticalTabsContext);
if (!context) {
throw new Error("VerticalTabs parts must be used within <VerticalTabs>");
}
return context;
}
export interface VerticalTabsProps
extends Omit<
React.ComponentProps<typeof TabsPrimitive.Root>,
"orientation"
> {
/** Disables the sliding indicator motion; selection snaps instantly. */
static?: boolean;
}
/**
* Vertical tabs with a measured indicator that slides along the left edge
* (transform + height, retargets mid-flight) and content that fades in over
* 150ms via @starting-style. Arrow-key navigation comes from Radix's
* vertical orientation; first paint of the indicator is suppressed.
*/
export function VerticalTabs({
value: valueProp,
defaultValue,
onValueChange,
static: isStatic = false,
className,
...props
}: VerticalTabsProps) {
const [internalValue, setInternalValue] = React.useState(defaultValue);
const value = valueProp ?? internalValue;
const contextValue = React.useMemo(
() => ({ value, isStatic }),
[value, isStatic],
);
return (
<VerticalTabsContext.Provider value={contextValue}>
<TabsPrimitive.Root
data-slot="vertical-tabs"
orientation="vertical"
value={value}
onValueChange={(next) => {
setInternalValue(next);
onValueChange?.(next);
}}
className={cn("flex items-start gap-6", className)}
{...props}
/>
</VerticalTabsContext.Provider>
);
}
export function VerticalTabsList({
className,
children,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
const { value, isStatic } = useVerticalTabs();
const listRef = React.useRef<HTMLDivElement>(null);
const indicatorRef = React.useRef<HTMLSpanElement>(null);
const hasPositioned = React.useRef(false);
const position = React.useCallback((animate: boolean) => {
const list = listRef.current;
const indicator = indicatorRef.current;
if (!list || !indicator) return;
const active = list.querySelector<HTMLElement>(
'[role="tab"][aria-selected="true"]',
);
if (!active) {
indicator.style.opacity = "0";
return;
}
if (!animate) indicator.style.transitionProperty = "none";
indicator.style.opacity = "1";
indicator.style.height = `${active.offsetHeight}px`;
indicator.style.transform = `translateY(${active.offsetTop}px)`;
if (!animate) {
void indicator.offsetWidth;
indicator.style.transitionProperty = "";
}
}, []);
React.useLayoutEffect(() => {
position(hasPositioned.current && !isStatic);
hasPositioned.current = true;
}, [position, value, isStatic]);
React.useEffect(() => {
const list = listRef.current;
if (!list) return;
const observer = new ResizeObserver(() => position(false));
observer.observe(list);
return () => observer.disconnect();
}, [position]);
return (
<TabsPrimitive.List
ref={listRef}
data-slot="vertical-tabs-list"
className={cn(
"relative flex w-48 shrink-0 flex-col gap-0.5 border-l pl-3",
className,
)}
{...props}
>
<span
ref={indicatorRef}
aria-hidden
className="pointer-events-none absolute -left-px top-0 w-0.5 rounded-full bg-foreground opacity-0 [transition-property:transform,height] duration-[250ms] ease-out motion-reduce:transition-none"
/>
{children}
</TabsPrimitive.List>
);
}
export function VerticalTabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="vertical-tabs-trigger"
className={cn(
"relative inline-flex h-8 items-center justify-start gap-2 rounded-md px-2.5 text-left text-sm font-medium whitespace-nowrap text-muted-foreground transition-[color,background-color] duration-150 ease-out select-none after:absolute after:inset-x-0 after:-inset-y-0.5 hover:text-foreground disabled:pointer-events-none disabled:opacity-50 aria-selected:bg-accent/60 aria-selected:text-foreground [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
className,
)}
{...props}
/>
);
}
export function VerticalTabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="vertical-tabs-content"
className={cn(
"min-w-0 flex-1 transition-[opacity] duration-150 ease-out starting:opacity-0",
className,
)}
{...props}
/>
);
}