Switches between panels in the same context. The active indicator slides between tabs instead of jumping.
pnpm dlx shadcn@latest add @latheui/tabsFirst time? Run npx latheui init once to add the @latheui registry. The CLI then writes the source into your project and installs what it needs. Or add it by hand:
Install the npm dependencies.
pnpm add radix-ui motionAdd the Lathe items this component imports.
lib/motion.tshooks/use-controllable-state.tsCopy the source into your project.
"use client"
import * as React from "react"
import { motion } from "motion/react"
import { Tabs as TabsPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { useControllableState } from "@/hooks/use-controllable-state"
import { spring, useMotionSafe } from "@/lib/motion"
type Variant = "underline" | "pill"
const TabsContext = React.createContext<{
value: string
layoutId: string
variant: Variant
}>({ value: "", layoutId: "", variant: "underline" })
export type TabsProps = Omit<
React.ComponentProps<typeof TabsPrimitive.Root>,
"value" | "defaultValue" | "onValueChange"
> & {
value?: string
defaultValue?: string
onValueChange?: (value: string) => void
/** `underline` for page-level sections, `pill` for compact view switches. */
variant?: Variant
}
function Tabs({
value: valueProp,
defaultValue = "",
onValueChange,
variant = "underline",
className,
...props
}: TabsProps) {
const [value, setValue] = useControllableState({
value: valueProp,
defaultValue,
onChange: onValueChange,
})
const layoutId = React.useId()
return (
<TabsContext.Provider value={{ value, layoutId, variant }}>
<TabsPrimitive.Root
data-slot="tabs"
value={value}
onValueChange={setValue}
className={cn("flex flex-col gap-4", className)}
{...props}
/>
</TabsContext.Provider>
)
}
function TabsList({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.List>) {
const { variant } = React.useContext(TabsContext)
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
// Scrolls sideways instead of wrapping when the tabs do not fit.
"flex max-w-full shrink-0 items-center overflow-x-auto [scrollbar-width:none]",
variant === "underline"
? "gap-5 border-b border-border"
: "w-fit gap-0.5 rounded-lg bg-muted p-0.5",
className
)}
{...props}
/>
)
}
function TabsTrigger({
className,
children,
value,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
const context = React.useContext(TabsContext)
const { safe } = useMotionSafe()
const active = context.value === value
const underline = context.variant === "underline"
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
value={value}
className={cn(
"relative inline-flex shrink-0 cursor-pointer items-center justify-center gap-1.5 text-sm font-medium whitespace-nowrap text-muted-foreground outline-none select-none",
"transition-colors duration-150 hover:text-foreground data-[state=active]:text-foreground",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
"disabled:pointer-events-none disabled:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0",
underline ? "h-10 rounded-sm" : "h-8 rounded-md px-3",
className
)}
{...props}
>
{active && (
<motion.span
layoutId={context.layoutId}
aria-hidden="true"
transition={safe(spring.hover)}
className={
underline
? "absolute inset-x-0 bottom-0 h-0.5 bg-foreground"
: "absolute inset-0 rounded-md bg-background shadow-sm ring-1 ring-border"
}
/>
)}
<span className="relative inline-flex items-center gap-1.5">{children}</span>
</TabsPrimitive.Trigger>
)
}
function TabsContent({
className,
children,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn(
"rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
className
)}
{...props}
>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.2 }}
>
{children}
</motion.div>
</TabsPrimitive.Content>
)
}
export { Tabs, TabsContent, TabsList, TabsTrigger }Depends on
Also accepts Radix Tabs.Root props such as orientation and activationMode.
| Prop | Type | Default | Description |
|---|---|---|---|
| value | string | — | Controlled active tab. |
| defaultValue | string | — | Initial active tab when uncontrolled. Set one, or no tab starts selected. |
| onValueChange | (value: string) => void | — | Called when the active tab changes. |
| variant | "underline" | "pill" | "underline" | Indicator style, applied to the list and triggers. |
Wraps the triggers. Give it an aria-label.
| Prop | Type | Default | Description |
|---|---|---|---|
| value* (required) | string | — | Matches the value of a TabsContent. |
| disabled | boolean | false | Skipped by pointer and arrow keys. |
| Prop | Type | Default | Description |
|---|---|---|---|
| value* (required) | string | — | The tab this panel belongs to. |
The indicator is the motion.span inside TabsTrigger. Change its classes there.
className={
underline
? "absolute inset-x-0 bottom-0 h-0.5 bg-chart-1"
: "absolute inset-0 rounded-md bg-background shadow-sm ring-1 ring-border"
}