Navigation

Vertical Tabs

Vertical Radix tabs with a sliding left-edge indicator and content that crossfades in 150ms.

Install

npx shadcn@latest add @paragon/vertical-tabs

vertical-tabs.tsx

"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}
    />
  );
}