Glrk UI

Tabs

A wrapper for Base UI Tabs with sliding indicator animation, line variant, vertical orientation, controlled state, and activation mode control.

Variants
Default — pill with sliding indicator animation
Manage your profile information.
Line — sliding underline indicator
Manage your profile information.
Icons
Icon + label in trigger
Profile content.
Controlled
value + onValueChange — driven by external state
Manage your profile information.

Active: profile

Orientation
orientation: vertical — list on left, content to right
Manage your profile information.
Activation mode
activateOnFocus — arrow keys activate immediately vs manual Enter/Space

Manual (default) — Enter/Space to activate

Manage your profile information.

activateOnFocus — arrow keys activate immediately

Manage your profile information.
Disabled
tab.disabled — tab cannot be selected, focus skips it
Profile content.

Installation

npx shadcn@latest add @glrk-ui/tabs

If you haven't set up the prerequisites yet, check out Prerequest section.

Copy and paste the following code into your project.

ui/tabs.tsx
'use client'

import { cva, type VariantProps } from 'class-variance-authority'
import { Tabs as TabsPrimitive } from '@base-ui/react/tabs'

import { cn } from '@/lib/utils'

function Tabs({ className, orientation = 'horizontal', ...props }: TabsPrimitive.Root.Props) {
  return (
    <TabsPrimitive.Root
      data-slot="tabs"
      data-orientation={orientation}
      data-horizontal={orientation !== 'vertical' ? '' : undefined}
      data-vertical={orientation === 'vertical' ? '' : undefined}
      orientation={orientation}
      className={cn('group/tabs flex gap-2 flex-col data-vertical:flex-row', className)}
      {...props}
    />
  )
}

const tabsListVariants = cva(
  'relative group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none',
  {
    variants: {
      variant: {
        default: 'bg-muted',
        line: 'gap-1 bg-transparent',
      },
    },
    defaultVariants: {
      variant: 'default',
    },
  },
)

function TabsIndicator({ className, ...props }: TabsPrimitive.Indicator.Props) {
  return (
    <TabsPrimitive.Indicator
      data-slot="tabs-indicator"
      className={cn(
        'absolute z-0 pointer-events-none transition-[left,width,top,height] duration-200',
        'group-data-[variant=default]/tabs-list:rounded-md group-data-[variant=default]/tabs-list:bg-background group-data-[variant=default]/tabs-list:shadow-sm dark:group-data-[variant=default]/tabs-list:bg-input/30 dark:group-data-[variant=default]/tabs-list:border dark:group-data-[variant=default]/tabs-list:border-input',
        'group-data-[variant=default]/tabs-list:group-data-horizontal/tabs:inset-y-[3px] group-data-[variant=default]/tabs-list:group-data-horizontal/tabs:left-[var(--active-tab-left)] group-data-[variant=default]/tabs-list:group-data-horizontal/tabs:w-[var(--active-tab-width)]',
        'group-data-[variant=default]/tabs-list:group-data-vertical/tabs:inset-x-[3px] group-data-[variant=default]/tabs-list:group-data-vertical/tabs:top-[var(--active-tab-top)] group-data-[variant=default]/tabs-list:group-data-vertical/tabs:h-[var(--active-tab-height)]',
        'group-data-[variant=line]/tabs-list:bg-foreground',
        'group-data-[variant=line]/tabs-list:group-data-horizontal/tabs:bottom-0 group-data-[variant=line]/tabs-list:group-data-horizontal/tabs:h-0.5 group-data-[variant=line]/tabs-list:group-data-horizontal/tabs:left-[var(--active-tab-left)] group-data-[variant=line]/tabs-list:group-data-horizontal/tabs:w-[var(--active-tab-width)]',
        'group-data-[variant=line]/tabs-list:group-data-vertical/tabs:right-0 group-data-[variant=line]/tabs-list:group-data-vertical/tabs:w-0.5 group-data-[variant=line]/tabs-list:group-data-vertical/tabs:top-[var(--active-tab-top)] group-data-[variant=line]/tabs-list:group-data-vertical/tabs:h-[var(--active-tab-height)]',
        className,
      )}
      {...props}
    />
  )
}

function TabsList({
  className,
  variant = 'default',
  children,
  ...props
}: TabsPrimitive.List.Props & VariantProps<typeof tabsListVariants>) {
  return (
    <TabsPrimitive.List
      data-slot="tabs-list"
      data-variant={variant}
      className={cn(tabsListVariants({ variant }), className)}
      {...props}
    >
      <TabsIndicator />
      {children}
    </TabsPrimitive.List>
  )
}

function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
  return (
    <TabsPrimitive.Tab
      data-slot="tabs-trigger"
      className={cn(
        'relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all',
        'group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start',
        'hover:text-foreground',
        'focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring',
        'disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50',
        'dark:text-muted-foreground dark:hover:text-foreground',
        'data-active:text-foreground dark:data-active:text-foreground',
        'group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent',
        "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
        className,
      )}
      {...props}
    />
  )
}

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

type tabItemT = {
  value: string
  trigger: React.ReactNode
  content: React.ReactNode
  disabled?: boolean
  triggerCls?: string
  contentCls?: string
}

type tabItemsT = tabItemT[]

type tabsWrapperProps = {
  tabs: tabItemsT
  variant?: VariantProps<typeof tabsListVariants>['variant']
  listCls?: string
  triggerCls?: string
  contentCls?: string
  activateOnFocus?: boolean
  loopFocus?: boolean
}

function TabsWrapper({
  tabs,
  variant,
  listCls,
  triggerCls,
  contentCls,
  activateOnFocus,
  loopFocus,
  ...props
}: React.ComponentProps<typeof TabsPrimitive.Root> & tabsWrapperProps) {
  return (
    <Tabs {...props}>
      <TabsList className={cn(listCls)} variant={variant} activateOnFocus={activateOnFocus} loopFocus={loopFocus}>
        {tabs.map((tab) => (
          <TabsTrigger
            key={tab.value}
            value={tab.value}
            disabled={tab.disabled}
            className={cn(triggerCls, tab.triggerCls)}
          >
            {tab.trigger}
          </TabsTrigger>
        ))}
      </TabsList>

      {tabs.map((tab) => (
        <TabsContent key={tab.value} value={tab.value} className={cn(contentCls, tab.contentCls)}>
          {tab.content}
        </TabsContent>
      ))}
    </Tabs>
  )
}

export {
  Tabs,
  TabsList,
  TabsTrigger,
  TabsContent,
  TabsIndicator,
  tabsListVariants,
  TabsWrapper,
  type tabItemT,
  type tabItemsT,
}

Usage

Basic

import { TabsWrapper } from "@/components/ui/tabs"

<TabsWrapper
  defaultValue="profile"
  tabs={[
    { value: "profile", trigger: "Profile", content: "Manage your profile." },
    { value: "account", trigger: "Account", content: "Update account settings." },
    { value: "security", trigger: "Security", content: "Change your password." },
  ]}
/>

Variants

default uses a sliding pill indicator. line uses a sliding underline:

<TabsWrapper tabs={tabs} defaultValue="profile" variant="default" />
<TabsWrapper tabs={tabs} defaultValue="profile" variant="line" />

Icons in trigger

<TabsWrapper
  tabs={[
    { value: "profile",  trigger: <><User /> Profile</>,   content: "..." },
    { value: "settings", trigger: <><Settings /> Settings</>, content: "..." },
  ]}
  defaultValue="profile"
/>

Controlled

const [value, setValue] = useState("profile")

<TabsWrapper
  tabs={tabs}
  value={value}
  onValueChange={setValue}
/>

Orientation

<TabsWrapper tabs={tabs} defaultValue="profile" orientation="vertical" />

Vertical layout places the tab list on the left and content to the right. Arrow key navigation adjusts automatically.

Activation mode

By default, arrow keys move focus and Enter/Space activates the tab. activateOnFocus activates on arrow key press immediately — no Enter needed:

<TabsWrapper tabs={tabs} defaultValue="profile" activateOnFocus />

Disabled tab

<TabsWrapper
  tabs={[
    { value: "profile",  trigger: "Profile",  content: "..." },
    { value: "account",  trigger: "Account",  content: "...", disabled: true },
    { value: "security", trigger: "Security", content: "..." },
  ]}
  defaultValue="profile"
/>

CSS classes

<TabsWrapper
  tabs={tabs}
  defaultValue="profile"
  listCls="w-full"
  triggerCls="flex-1"
  contentCls="mt-4"
/>

Per-tab override with triggerCls / contentCls on each item:

<TabsWrapper
  tabs={[
    { value: "a", trigger: "Normal",  content: "...", },
    { value: "b", trigger: "Special", content: "...", triggerCls: "text-primary", contentCls: "bg-muted p-4 rounded" },
  ]}
  defaultValue="a"
/>

Reference

tabItemT

Prop

Type

TabsWrapper

Prop

Type