Vela UI Docsvenizia.ai

Tabs

A set of layered sections of content, known as tab panels, that display one panel at a time.

npx shadcn@latest add @velaui/tabs
npx shadcn@latest add @velaui/tabs

Examples

  • Default
  • Segment small
  • Outline fullwidth
  • Underline icons
  • Line vertical
  • Ghost small

View code

Tabs · Default

Installation

pnpm dlx velaui add @velaui/tabs
pnpm dlx velaui add @velaui/tabs

Code

tabs-default.tsx
import {
  Tabs,
  TabsList,
  TabsTrigger,
  TabsContent,
} from "@/components/ui/tabs";

export default function Example() {
  return (
    <Tabs defaultValue="overview">
      <TabsList>
        <TabsTrigger value="overview">Overview</TabsTrigger>
        <TabsTrigger value="analytics">Analytics</TabsTrigger>
        <TabsTrigger value="settings">Settings</TabsTrigger>
      </TabsList>
      <TabsContent value="overview">Overview content goes here.</TabsContent>
      <TabsContent value="analytics">Analytics content goes here.</TabsContent>
      <TabsContent value="settings">Settings content goes here.</TabsContent>
    </Tabs>
  );
}

View code

Tabs · Segment small

Installation

pnpm dlx velaui add @velaui/tabs
pnpm dlx velaui add @velaui/tabs

Code

tabs-segment-small.tsx
import {
  Tabs,
  TabsList,
  TabsTrigger,
  TabsContent,
} from "@/components/ui/tabs";

export default function Example() {
  return (
    <Tabs defaultValue="daily">
      <TabsList variant="segment" size="sm">
        <TabsTrigger value="daily">Daily</TabsTrigger>
        <TabsTrigger value="weekly">Weekly</TabsTrigger>
        <TabsTrigger value="monthly">Monthly</TabsTrigger>
      </TabsList>
      <TabsContent value="daily">Daily breakdown.</TabsContent>
      <TabsContent value="weekly">Weekly breakdown.</TabsContent>
      <TabsContent value="monthly">Monthly breakdown.</TabsContent>
    </Tabs>
  );
}

View code

Tabs · Outline fullwidth

Installation

pnpm dlx velaui add @velaui/tabs
pnpm dlx velaui add @velaui/tabs

Code

tabs-outline-fullwidth.tsx
import {
  Tabs,
  TabsList,
  TabsTrigger,
  TabsContent,
} from "@/components/ui/tabs";

export default function Example() {
  return (
    <Tabs defaultValue="profile">
      <TabsList variant="outline" fullWidth>
        <TabsTrigger value="profile">Profile</TabsTrigger>
        <TabsTrigger value="security">Security</TabsTrigger>
        <TabsTrigger value="billing">Billing</TabsTrigger>
      </TabsList>
      <TabsContent value="profile">Profile settings.</TabsContent>
      <TabsContent value="security">Security settings.</TabsContent>
      <TabsContent value="billing">Billing settings.</TabsContent>
    </Tabs>
  );
}

View code

Tabs · Underline icons

Installation

pnpm dlx velaui add @velaui/tabs
pnpm dlx velaui add @velaui/tabs

Code

tabs-underline-icons.tsx
import { Bell01 } from "@/components/icons/bell-01";
import { Grid01 } from "@/components/icons/grid-01";
import { UserProfile01 } from "@/components/icons/user-profile-01";
import {
  Tabs,
  TabsList,
  TabsTrigger,
  TabsContent,
} from "@/components/ui/tabs";

export default function Example() {
  return (
    <Tabs defaultValue="dashboard">
      <TabsList variant="underline">
        <TabsTrigger value="dashboard">
          <Grid01 data-icon="inline-start" />
          Dashboard
        </TabsTrigger>
        <TabsTrigger value="notifications">
          <Bell01 data-icon="inline-start" />
          Notifications
        </TabsTrigger>
        <TabsTrigger value="account">
          <UserProfile01 data-icon="inline-start" />
          Account
        </TabsTrigger>
      </TabsList>
      <TabsContent value="dashboard">Dashboard content.</TabsContent>
      <TabsContent value="notifications">Notifications content.</TabsContent>
      <TabsContent value="account">Account content.</TabsContent>
    </Tabs>
  );
}

View code

Tabs · Line vertical

Installation

pnpm dlx velaui add @velaui/tabs
pnpm dlx velaui add @velaui/tabs

Code

tabs-line-vertical.tsx
import {
  Tabs,
  TabsList,
  TabsTrigger,
  TabsContent,
} from "@/components/ui/tabs";

export default function Example() {
  return (
    <Tabs defaultValue="general" orientation="vertical">
      <TabsList variant="line">
        <TabsTrigger value="general">General</TabsTrigger>
        <TabsTrigger value="members">Members</TabsTrigger>
        <TabsTrigger value="integrations">Integrations</TabsTrigger>
      </TabsList>
      <TabsContent value="general">
        <div className="w-50">General settings.</div>
      </TabsContent>
      <TabsContent value="members">
        <div className="w-50">Manage members.</div>
      </TabsContent>
      <TabsContent value="integrations">
        <div className="w-50">Connected integrations.</div>
      </TabsContent>
    </Tabs>
  );
}

View code

Tabs · Ghost small

Installation

pnpm dlx velaui add @velaui/tabs
pnpm dlx velaui add @velaui/tabs

Code

tabs-ghost-small.tsx
import {
  Tabs,
  TabsList,
  TabsTrigger,
  TabsContent,
} from "@/components/ui/tabs";

export default function Example() {
  return (
    <Tabs defaultValue="all">
      <TabsList variant="ghost" size="sm">
        <TabsTrigger value="all">All</TabsTrigger>
        <TabsTrigger value="unread">Unread</TabsTrigger>
        <TabsTrigger value="archived">Archived</TabsTrigger>
      </TabsList>
      <TabsContent value="all">All messages.</TabsContent>
      <TabsContent value="unread">Unread messages.</TabsContent>
      <TabsContent value="archived">Archived messages.</TabsContent>
    </Tabs>
  );
}

Usage

import {
  Tabs,
  TabsList,
  TabsTrigger,
  TabsContent,
} from "@/components/ui/tabs";

export default function Example() {
  return (
    <Tabs defaultValue="overview">
      <TabsList>
        <TabsTrigger value="overview">Overview</TabsTrigger>
        <TabsTrigger value="analytics">Analytics</TabsTrigger>
        <TabsTrigger value="settings">Settings</TabsTrigger>
      </TabsList>
      <TabsContent value="overview">Overview content goes here.</TabsContent>
      <TabsContent value="analytics">Analytics content goes here.</TabsContent>
      <TabsContent value="settings">Settings content goes here.</TabsContent>
    </Tabs>
  );
}

Props

Tabs

PropTypeDefault
orientation"horizontal" | "vertical""horizontal"

Takes these and every prop of TabsProps.

TabsList

PropTypeDefault
fullWidthbooleanfalse
size"sm" | "md""md"
variant"underline" | "outline" | "line" | "ghost" | "pill" | "segment""pill"

Takes these and every prop of TabsListProps.

TabsTrigger

Takes every prop of TabsTriggerProps.

TabsContent

Takes every prop of TabsContentProps.

Tabs

PropTypeDefault
orientationimport("/app/node_modules/.bun/@base-ui+react@1.6.0+df7f6a806acd27ca/node_modules/@base-ui/react/index").Orientation"horizontal"

Takes these and every prop of TabsProps.

TabsList

PropTypeDefault
fullWidthbooleanfalse
size"sm" | "md""md"
variant"line" | "underline" | "outline" | "ghost" | "pill" | "segment""pill"

Takes these and every prop of TabsListProps.

TabsTrigger

Takes every prop of TabsTriggerProps.

TabsContent

Takes every prop of TabsContentProps.

Dependencies

npm
class-variance-authority@0.7.1radix-ui@1.6.0
Installs with it
nothing else
npm
@base-ui/react@1.6.0class-variance-authority@0.7.1
Installs with it
nothing else

Source

components/ui/tabs.tsx
/* eslint-disable react-refresh/only-export-components */
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { Tabs as TabsPrimitive } from "radix-ui";

import { cn } from "@/lib/utils";

type TabsListVariant =
  | "pill"
  | "segment"
  | "outline"
  | "underline"
  | "line"
  | "ghost";
type TabsListSize = "sm" | "md";

type TabsListContextType = {
  variant: TabsListVariant;
  size: TabsListSize;
  fullWidth: boolean;
};

// The list owns variant/size/fullWidth; triggers read them instead of having
// every call site repeat the props.
const TabsListContext = React.createContext<TabsListContextType>({
  variant: "pill",
  size: "md",
  fullWidth: false,
});

function useTabsList() {
  return React.useContext(TabsListContext);
}

export type TabsProps = React.ComponentProps<typeof TabsPrimitive.Root>;

function Tabs({
  className,
  orientation = "horizontal",
  ...props
}: TabsProps) {
  return (
    <TabsPrimitive.Root
      data-slot="tabs"
      orientation={orientation}
      data-orientation={orientation}
      className={cn("group/tabs flex gap-2 data-horizontal:flex-col", className)}
      {...props}
    />
  );
}

const tabsListVariants = cva(
  "group/tabs-list inline-flex items-center justify-center group-data-vertical/tabs:w-full group-data-vertical/tabs:flex-col group-data-vertical/tabs:items-stretch",
  {
    variants: {
      variant: {
        pill: "gap-1 rounded-xl",
        segment: "gap-1 rounded-xl",
        outline: "gap-2 group-data-vertical/tabs:gap-1",
        underline: "gap-6 border-b border-border-default",
        line: "gap-2 group-data-vertical/tabs:gap-1 group-data-vertical/tabs:border-l group-data-vertical/tabs:border-border-default",
        ghost: "gap-1",
      },
      size: {
        sm: "",
        md: "",
      },
      fullWidth: {
        true: "w-full",
        false: "w-fit",
      },
    },
    compoundVariants: [
      // Only the pill list draws a surface, so only it carries padding.
      { variant: "pill", size: "sm", class: "p-1 group-data-vertical/tabs:p-1.5" },
      { variant: "pill", size: "md", class: "p-1.5" },
      { variant: "pill", size: "sm", fullWidth: true, class: "p-1.5" },
      // Stretched underline tabs close the gap right up.
      { variant: "underline", fullWidth: true, class: "gap-1.5" },
    ],
    defaultVariants: {
      variant: "pill",
      size: "md",
      fullWidth: false,
    },
  },
);

export type TabsListProps = React.ComponentProps<typeof TabsPrimitive.List> &
  VariantProps<typeof tabsListVariants> & {
    variant?: TabsListVariant;
    size?: TabsListSize;
    fullWidth?: boolean;
  };

function TabsList({
  className,
  variant = "pill",
  size = "md",
  fullWidth = false,
  ...props
}: TabsListProps) {
  const context = React.useMemo(
    () => ({ variant, size, fullWidth }),
    [variant, size, fullWidth],
  );

  return (
    <TabsListContext value={context}>
      <TabsPrimitive.List
        data-slot="tabs-list"
        data-variant={variant}
        data-size={size}
        data-full-width={fullWidth}
        className={cn(tabsListVariants({ variant, size, fullWidth }), className)}
        {...props}
      />
    </TabsListContext>
  );
}

const tabsTriggerVariants = cva(
  "group/tabs-trigger relative inline-flex items-center justify-center font-semibold whitespace-nowrap transition-all outline-none group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start disabled:pointer-events-none disabled:text-text-disabled [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
  {
    variants: {
      variant: {
        pill: "rounded-md bg-bg-surface-1 text-text-strong focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring-neutral-outline data-active:bg-primary-default data-active:text-text-inverse data-active:hover:bg-primary-hover data-active:hover:text-text-inverse",
        segment:
          "rounded-md bg-bg-surface-1 text-text-strong hover:bg-bg-surface-2 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring-neutral-outline data-active:bg-bg-foreground data-active:text-text-inverse data-active:hover:bg-bg-foreground",
        // The selected outline tab draws a 1px stroke — rendered as an inset
        // outline so entering/leaving the state never reflows the row.
        outline:
          "rounded-md text-text-note hover:bg-bg-surface-1 hover:text-text-strong focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring-neutral-outline data-active:bg-bg-surface-1 data-active:text-text-strong data-active:outline-1 data-active:-outline-offset-1 data-active:outline-border-default data-active:hover:bg-bg-surface-2 data-active:hover:outline-border-hover data-active:focus-visible:outline-2 data-active:focus-visible:-outline-offset-2 data-active:focus-visible:outline-ring-neutral-outline",
        // Underline and line carry their whole state vocabulary on one edge and
        // draw no focus ring at all — reserve the 2px so hover never reflows.
        underline:
          "rounded-none border-b-2 border-b-transparent text-text-note hover:border-b-border-hover hover:text-text-strong data-active:border-b-border-active data-active:text-text-strong data-active:hover:border-b-border-active",
        line: "rounded-none border-l-2 border-l-transparent text-text-note hover:border-l-border-hover hover:text-text-strong data-active:border-l-border-active data-active:text-text-strong data-active:hover:border-l-border-active",
        ghost:
          "rounded-md text-text-note hover:bg-bg-surface-1 hover:text-text-strong focus-visible:ring-3 focus-visible:ring-ring-neutral-shadow data-active:text-text-strong",
      },
      size: {
        sm: "gap-1.5 px-3 py-1.5 text-sm leading-[1.4] has-data-[icon=inline-end]:pr-2.5 has-data-[icon=inline-start]:pl-2.5",
        md: "gap-2 px-3.5 py-2 text-base leading-[1.4] has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
      },
      fullWidth: {
        true: "flex-1",
        false: "",
      },
    },
    compoundVariants: [
      {
        variant: "underline",
        size: "sm",
        class:
          "px-0 pt-1.5 pb-2.5 has-data-[icon=inline-end]:pr-0 has-data-[icon=inline-start]:pl-0",
      },
      {
        variant: "underline",
        size: "md",
        class:
          "px-0 pt-2 pb-2.5 has-data-[icon=inline-end]:pr-0 has-data-[icon=inline-start]:pl-0",
      },
      // The reserved 2px left edge is part of the design's 14px inset.
      { variant: "line", size: "md", class: "pl-3" },
    ],
    defaultVariants: {
      variant: "pill",
      size: "md",
      fullWidth: false,
    },
  },
);

export type TabsTriggerProps = React.ComponentProps<typeof TabsPrimitive.Trigger>;

function TabsTrigger({
  className,
  ...props
}: TabsTriggerProps) {
  const { variant, size, fullWidth } = useTabsList();

  return (
    <TabsPrimitive.Trigger
      data-slot="tabs-trigger"
      className={cn(
        tabsTriggerVariants({ variant, size, fullWidth }),
        className,
      )}
      {...props}
    />
  );
}

export type TabsContentProps = React.ComponentProps<typeof TabsPrimitive.Content>;

function TabsContent({
  className,
  ...props
}: TabsContentProps) {
  return (
    <TabsPrimitive.Content
      data-slot="tabs-content"
      className={cn("flex-1 text-sm outline-none", className)}
      {...props}
    />
  );
}

export {
  Tabs,
  TabsList,
  TabsTrigger,
  TabsContent,
  tabsListVariants,
  tabsTriggerVariants,
};
export type { TabsListVariant, TabsListSize };
components/ui/tabs.tsx
/* eslint-disable react-refresh/only-export-components */
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs";

import { cn } from "@/lib/utils";

type TabsListVariant =
  | "pill"
  | "segment"
  | "outline"
  | "underline"
  | "line"
  | "ghost";
type TabsListSize = "sm" | "md";

type TabsListContextType = {
  variant: TabsListVariant;
  size: TabsListSize;
  fullWidth: boolean;
};

// The list owns variant/size/fullWidth; triggers read them instead of having
// every call site repeat the props.
const TabsListContext = React.createContext<TabsListContextType>({
  variant: "pill",
  size: "md",
  fullWidth: false,
});

function useTabsList() {
  return React.useContext(TabsListContext);
}

export type TabsProps = React.ComponentProps<typeof TabsPrimitive.Root>;

function Tabs({
  className,
  orientation = "horizontal",
  ...props
}: TabsProps) {
  return (
    <TabsPrimitive.Root
      data-slot="tabs"
      orientation={orientation}
      data-orientation={orientation}
      className={cn("group/tabs flex gap-2 data-horizontal:flex-col", className)}
      {...props}
    />
  );
}

const tabsListVariants = cva(
  "group/tabs-list inline-flex items-center justify-center group-data-vertical/tabs:w-full group-data-vertical/tabs:flex-col group-data-vertical/tabs:items-stretch",
  {
    variants: {
      variant: {
        pill: "gap-1 rounded-xl",
        segment: "gap-1 rounded-xl",
        outline: "gap-2 group-data-vertical/tabs:gap-1",
        underline: "gap-6 border-b border-border-default",
        line: "gap-2 group-data-vertical/tabs:gap-1 group-data-vertical/tabs:border-l group-data-vertical/tabs:border-border-default",
        ghost: "gap-1",
      },
      size: {
        sm: "",
        md: "",
      },
      fullWidth: {
        true: "w-full",
        false: "w-fit",
      },
    },
    compoundVariants: [
      // Only the pill list draws a surface, so only it carries padding.
      { variant: "pill", size: "sm", class: "p-1 group-data-vertical/tabs:p-1.5" },
      { variant: "pill", size: "md", class: "p-1.5" },
      { variant: "pill", size: "sm", fullWidth: true, class: "p-1.5" },
      // Stretched underline tabs close the gap right up.
      { variant: "underline", fullWidth: true, class: "gap-1.5" },
    ],
    defaultVariants: {
      variant: "pill",
      size: "md",
      fullWidth: false,
    },
  },
);

export type TabsListProps = React.ComponentProps<typeof TabsPrimitive.List> &
  VariantProps<typeof tabsListVariants> & {
    variant?: TabsListVariant;
    size?: TabsListSize;
    fullWidth?: boolean;
  };

function TabsList({
  className,
  variant = "pill",
  size = "md",
  fullWidth = false,
  ...props
}: TabsListProps) {
  const context = React.useMemo(
    () => ({ variant, size, fullWidth }),
    [variant, size, fullWidth],
  );

  return (
    <TabsListContext value={context}>
      <TabsPrimitive.List
        data-slot="tabs-list"
        data-variant={variant}
        data-size={size}
        data-full-width={fullWidth}
        className={cn(tabsListVariants({ variant, size, fullWidth }), className)}
        {...props}
      />
    </TabsListContext>
  );
}

const tabsTriggerVariants = cva(
  "group/tabs-trigger relative inline-flex items-center justify-center font-semibold whitespace-nowrap transition-all outline-none group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start disabled:pointer-events-none disabled:text-text-disabled [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
  {
    variants: {
      variant: {
        pill: "rounded-md bg-bg-surface-1 text-text-strong focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring-neutral-outline data-active:bg-primary-default data-active:text-text-inverse data-active:hover:bg-primary-hover data-active:hover:text-text-inverse",
        segment:
          "rounded-md bg-bg-surface-1 text-text-strong hover:bg-bg-surface-2 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring-neutral-outline data-active:bg-bg-foreground data-active:text-text-inverse data-active:hover:bg-bg-foreground",
        // The selected outline tab draws a 1px stroke — rendered as an inset
        // outline so entering/leaving the state never reflows the row.
        outline:
          "rounded-md text-text-note hover:bg-bg-surface-1 hover:text-text-strong focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring-neutral-outline data-active:bg-bg-surface-1 data-active:text-text-strong data-active:outline-1 data-active:-outline-offset-1 data-active:outline-border-default data-active:hover:bg-bg-surface-2 data-active:hover:outline-border-hover data-active:focus-visible:outline-2 data-active:focus-visible:-outline-offset-2 data-active:focus-visible:outline-ring-neutral-outline",
        // Underline and line carry their whole state vocabulary on one edge and
        // draw no focus ring at all — reserve the 2px so hover never reflows.
        underline:
          "rounded-none border-b-2 border-b-transparent text-text-note hover:border-b-border-hover hover:text-text-strong data-active:border-b-border-active data-active:text-text-strong data-active:hover:border-b-border-active",
        line: "rounded-none border-l-2 border-l-transparent text-text-note hover:border-l-border-hover hover:text-text-strong data-active:border-l-border-active data-active:text-text-strong data-active:hover:border-l-border-active",
        ghost:
          "rounded-md text-text-note hover:bg-bg-surface-1 hover:text-text-strong focus-visible:ring-3 focus-visible:ring-ring-neutral-shadow data-active:text-text-strong",
      },
      size: {
        sm: "gap-1.5 px-3 py-1.5 text-sm leading-[1.4] has-data-[icon=inline-end]:pr-2.5 has-data-[icon=inline-start]:pl-2.5",
        md: "gap-2 px-3.5 py-2 text-base leading-[1.4] has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
      },
      fullWidth: {
        true: "flex-1",
        false: "",
      },
    },
    compoundVariants: [
      {
        variant: "underline",
        size: "sm",
        class:
          "px-0 pt-1.5 pb-2.5 has-data-[icon=inline-end]:pr-0 has-data-[icon=inline-start]:pl-0",
      },
      {
        variant: "underline",
        size: "md",
        class:
          "px-0 pt-2 pb-2.5 has-data-[icon=inline-end]:pr-0 has-data-[icon=inline-start]:pl-0",
      },
      // The reserved 2px left edge is part of the design's 14px inset.
      { variant: "line", size: "md", class: "pl-3" },
    ],
    defaultVariants: {
      variant: "pill",
      size: "md",
      fullWidth: false,
    },
  },
);

export type TabsTriggerProps = React.ComponentProps<typeof TabsPrimitive.Tab>;

function TabsTrigger({
  className,
  ...props
}: TabsTriggerProps) {
  const { variant, size, fullWidth } = useTabsList();

  return (
    <TabsPrimitive.Tab
      data-slot="tabs-trigger"
      className={cn(
        tabsTriggerVariants({ variant, size, fullWidth }),
        className,
      )}
      {...props}
    />
  );
}

export type TabsContentProps = React.ComponentProps<typeof TabsPrimitive.Panel>;

function TabsContent({
  className,
  ...props
}: TabsContentProps) {
  return (
    <TabsPrimitive.Panel
      data-slot="tabs-content"
      className={cn("flex-1 text-sm outline-none", className)}
      {...props}
    />
  );
}

export {
  Tabs,
  TabsList,
  TabsTrigger,
  TabsContent,
  tabsListVariants,
  tabsTriggerVariants,
};
export type { TabsListVariant, TabsListSize };