Tabs
tabs ui Horizontal tab navigation with content panels — pick one panel at a time. Underline or pills variants. Built on Radix UI with full keyboard navigation.
Also available for Vue ->Installation
$ pnpm dlx shadcn@latest add https://uipkge.dev/r/react/tabs.json $ npx shadcn@latest add https://uipkge.dev/r/react/tabs.json $ yarn dlx shadcn@latest add https://uipkge.dev/r/react/tabs.json $ bunx shadcn@latest add https://uipkge.dev/r/react/tabs.json Named registry:
npx shadcn@latest add @uipkge-react/tabs Installs to: components/ui/tabs/ Examples
Loading interactive previews…
Loading interactive previews…
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
variant | | segmented | optional |
orientation | 'horizontal''vertical' | horizontal | optional |
npm dependencies
Files installed (3)
-
components/ui/tabs/tabs.tsx 7.9 kB
'use client' import * as React from 'react' import * as TabsPrimitive from '@radix-ui/react-tabs' import { cn } from '@/lib/utils' import { tabsListVariants, tabsTriggerVariants } from './tabs.variants' // Make orientation reachable from descendants without each consumer having to // pass it manually. TabsList / TabsTrigger read this to apply variant CSS. const TabsOrientationContext = React.createContext<'horizontal' | 'vertical'>('horizontal') export interface TabsProps extends React.ComponentPropsWithoutRef<typeof TabsPrimitive.Root> { orientation?: 'horizontal' | 'vertical' } const Tabs = React.forwardRef<React.ElementRef<typeof TabsPrimitive.Root>, TabsProps>( ({ className, orientation = 'horizontal', ...props }, ref) => ( <TabsOrientationContext.Provider value={orientation}> <TabsPrimitive.Root ref={ref} data-uipkge="" data-slot="tabs" data-orientation={orientation} orientation={orientation} className={cn('flex w-full', orientation === 'vertical' ? 'flex-row gap-4' : 'flex-col gap-2', className)} {...props} /> </TabsOrientationContext.Provider> ), ) Tabs.displayName = 'Tabs' export interface TabsListProps extends React.ComponentPropsWithoutRef<typeof TabsPrimitive.List> { variant?: 'segmented' | 'pill' | 'underline' orientation?: 'horizontal' | 'vertical' /** Enable sliding active indicator (default true). When false, active surface paints on the trigger. */ animated?: boolean } const TabsList = React.forwardRef<React.ElementRef<typeof TabsPrimitive.List>, TabsListProps>( ({ className, variant = 'segmented', orientation, animated = true, children, ...props }, ref) => { const inherited = React.useContext(TabsOrientationContext) const effectiveOrientation = orientation ?? inherited const listRef = React.useRef<HTMLDivElement | null>(null) const firstPosition = React.useRef(true) const [indicatorStyle, setIndicatorStyle] = React.useState<React.CSSProperties>({ opacity: 0 }) const setRefs = React.useCallback( (node: HTMLDivElement | null) => { listRef.current = node if (typeof ref === 'function') ref(node) else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = node }, [ref], ) const motionSafeTransition = React.useCallback(() => { if (typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches) { return 'none' } return firstPosition.current ? 'none' : 'transform 220ms cubic-bezier(0.22, 1, 0.36, 1), width 220ms cubic-bezier(0.22, 1, 0.36, 1), height 220ms cubic-bezier(0.22, 1, 0.36, 1)' }, []) const updateIndicator = React.useCallback(() => { if (!animated) return const root = listRef.current if (!root) return const active = root.querySelector<HTMLElement>('[data-slot="tabs-trigger"][data-state="active"]') if (!active) { setIndicatorStyle({ opacity: 0 }) return } const listRect = root.getBoundingClientRect() const activeRect = active.getBoundingClientRect() const left = activeRect.left - listRect.left + root.scrollLeft const top = activeRect.top - listRect.top + root.scrollTop const transition = motionSafeTransition() if (variant === 'underline') { const thickness = 2 if (effectiveOrientation === 'vertical') { setIndicatorStyle({ width: thickness, height: activeRect.height, transform: `translate3d(${listRect.width - thickness}px, ${top}px, 0)`, opacity: 1, transition, }) } else { setIndicatorStyle({ width: activeRect.width, height: thickness, transform: `translate3d(${left}px, ${listRect.height - thickness}px, 0)`, opacity: 1, transition, }) } } else { setIndicatorStyle({ width: activeRect.width, height: activeRect.height, transform: `translate3d(${left}px, ${top}px, 0)`, opacity: 1, transition, }) } firstPosition.current = false }, [animated, effectiveOrientation, motionSafeTransition, variant]) // Bind observers once per animated/variant/orientation — NOT on every children identity // change (controlled tabs re-render parents constantly and would kill the slide). React.useLayoutEffect(() => { firstPosition.current = true if (!animated) { setIndicatorStyle({ opacity: 0 }) return } updateIndicator() const root = listRef.current if (!root) return const ro = new ResizeObserver(() => updateIndicator()) ro.observe(root) root.querySelectorAll('[data-slot="tabs-trigger"]').forEach((el) => ro.observe(el)) const mo = new MutationObserver((mutations) => { for (const m of mutations) { if (m.type === 'childList') { root.querySelectorAll('[data-slot="tabs-trigger"]').forEach((el) => ro.observe(el)) } } updateIndicator() }) mo.observe(root, { attributes: true, attributeFilter: ['data-state'], subtree: true, childList: true, }) return () => { ro.disconnect() mo.disconnect() } }, [animated, effectiveOrientation, updateIndicator, variant]) const indicatorClass = variant === 'pill' ? 'pointer-events-none absolute top-0 left-0 z-0 rounded-full bg-primary shadow-xs will-change-transform' : variant === 'underline' ? 'pointer-events-none absolute top-0 left-0 z-0 bg-foreground will-change-transform' : 'pointer-events-none absolute top-0 left-0 z-0 rounded-sm bg-background shadow-xs will-change-transform' return ( <TabsPrimitive.List ref={setRefs} data-uipkge="" data-slot="tabs-list" data-animated={animated ? 'true' : 'false'} className={cn( 'group/list relative', tabsListVariants({ variant, orientation: effectiveOrientation }), className, )} {...props} > {animated && ( <span data-slot="tabs-indicator" aria-hidden="true" className={indicatorClass} style={indicatorStyle} /> )} {children} </TabsPrimitive.List> ) }, ) TabsList.displayName = 'TabsList' export interface TabsTriggerProps extends React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger> { size?: 'default' | 'sm' | 'lg' variant?: 'segmented' | 'pill' | 'underline' orientation?: 'horizontal' | 'vertical' } const TabsTrigger = React.forwardRef<React.ElementRef<typeof TabsPrimitive.Trigger>, TabsTriggerProps>( ({ className, size, variant, orientation, ...props }, ref) => { const inherited = React.useContext(TabsOrientationContext) const effectiveOrientation = orientation ?? inherited return ( <TabsPrimitive.Trigger ref={ref} data-uipkge="" data-slot="tabs-trigger" className={cn(tabsTriggerVariants({ size, variant, orientation: effectiveOrientation }), className)} {...props} /> ) }, ) TabsTrigger.displayName = 'TabsTrigger' const TabsContent = React.forwardRef< React.ElementRef<typeof TabsPrimitive.Content>, React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content> >(({ className, ...props }, ref) => ( <TabsPrimitive.Content ref={ref} data-uipkge="" data-slot="tabs-content" className={cn( 'ring-offset-background focus-visible:border-ring focus-visible:ring-ring/50 motion-safe:data-[state=active]:animate-in motion-safe:data-[state=active]:fade-in-0 motion-safe:data-[state=active]:duration-200 flex-1 focus-visible:ring-2 focus-visible:ring-[3px] focus-visible:outline-none', className, )} {...props} /> )) TabsContent.displayName = 'TabsContent' export { Tabs, TabsList, TabsTrigger, TabsContent } -
components/ui/tabs/tabs.variants.ts 3.5 kB
import type { VariantProps } from 'class-variance-authority' import { cva } from 'class-variance-authority' /** * Variant definitions live in their own file (rather than the package * `index.ts`) so consuming Vue SFCs can import without creating a circular * dependency through the index. See card.variants.ts for the canonical * example + the SSR symptom that motivated the split. */ export const tabsListVariants = cva('inline-flex items-stretch text-muted-foreground', { variants: { variant: { // Solid muted track with rounded inset triggers — the default look. segmented: 'gap-1 rounded-md bg-muted p-1', // Transparent track with rounded-full pill triggers. pill: 'gap-2 bg-transparent p-0', // Bottom-border bar (horizontal) or right-border bar (vertical), with // an underline on the active trigger. underline: 'gap-0 bg-transparent p-0', }, orientation: { horizontal: 'flex-row', vertical: 'h-auto flex-col items-stretch', }, }, compoundVariants: [ { variant: 'underline', orientation: 'horizontal', class: 'w-full justify-start border-b border-border', }, { variant: 'underline', orientation: 'vertical', class: 'border-r border-border', }, ], defaultVariants: { variant: 'segmented', orientation: 'horizontal', }, }) export const tabsTriggerVariants = cva( // z-10 keeps label above the sliding indicator; active surface paints on the indicator // when the parent list has data-animated="true". Static active chrome restores when // data-animated="false" (see group-data variants below). 'relative z-10 inline-flex items-center justify-center gap-1.5 whitespace-nowrap font-medium ring-offset-background transition-colors duration-200 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50', { variants: { variant: { // Active bg/shadow live on TabsList indicator so it can slide between triggers. segmented: 'rounded-sm data-[state=active]:text-foreground group-data-[animated=false]/list:data-[state=active]:bg-background group-data-[animated=false]/list:data-[state=active]:shadow-xs', pill: 'rounded-full border border-border bg-transparent data-[state=active]:border-transparent data-[state=active]:text-primary-foreground group-data-[animated=false]/list:data-[state=active]:bg-primary group-data-[animated=false]/list:data-[state=active]:border-primary', // Underline bar slides on TabsList indicator; keep transparent border for layout stability. underline: 'rounded-none border-b-2 border-transparent -mb-px data-[state=active]:text-foreground group-data-[animated=false]/list:data-[state=active]:border-foreground', }, size: { default: 'h-9 px-3 text-sm', sm: 'h-8 px-2.5 text-xs', lg: 'h-10 px-4 text-sm', }, orientation: { horizontal: '', vertical: 'w-full justify-start', }, }, compoundVariants: [ { variant: 'underline', orientation: 'vertical', class: 'border-b-0 border-r-2 -mr-px group-data-[animated=false]/list:data-[state=active]:border-foreground', }, ], defaultVariants: { variant: 'segmented', size: 'default', orientation: 'horizontal', }, }, ) export type TabsListVariants = VariantProps<typeof tabsListVariants> export type TabsTriggerVariants = VariantProps<typeof tabsTriggerVariants> -
components/ui/tabs/index.ts 0.4 kB
export { Tabs, TabsList, TabsTrigger, TabsContent, type TabsProps, type TabsListProps, type TabsTriggerProps, } from './tabs' // Re-export variant API from the sibling file (kept separate to avoid the // component <-> index.ts circular import that broke dev SSR for Card). export { tabsListVariants, tabsTriggerVariants, type TabsListVariants, type TabsTriggerVariants } from './tabs.variants'
Raw manifest: https://uipkge.dev/r/react/tabs.json