Tabs
A set of layered sections of content, known as tab panels, that display one panel at a time.
npx shadcn@latest add @velaui/tabspnpm dlx velaui add @velaui/tabsyarn dlx shadcn@latest add @velaui/tabsbunx --bun shadcn@latest add @velaui/tabsveni add tabsInstalls the copy of vela-ui that veni-builder serves to Venizia apps; veni-builder serves the Radix UI copy only, so this tab exists for that base alone.
npx shadcn@latest add @velaui/tabspnpm dlx velaui add @velaui/tabsyarn dlx shadcn@latest add @velaui/tabsbunx --bun shadcn@latest add @velaui/tabsExamples
- Default
- Segment small
- Outline fullwidth
- Underline icons
- Line vertical
- Ghost small
Usage
Props
Tabs
| Prop | Type | Default |
|---|---|---|
orientation | "horizontal" | "vertical" | "horizontal" |
Takes these and every prop of TabsProps.
TabsList
| Prop | Type | Default |
|---|---|---|
fullWidth | boolean | false |
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
| Prop | Type | Default |
|---|---|---|
orientation | import("/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
| Prop | Type | Default |
|---|---|---|
fullWidth | boolean | false |
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 };