Tabs
Pill tabs whose selection nests in the track and slides with ease-in-out.
This week · 112 orders
Installation
$ pnpm dlx shadcn@latest add @ssntl/tabsUsage
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
<Tabs defaultValue="week">
<TabsList aria-label="Report period">
<TabsTrigger value="day">Day</TabsTrigger>
<TabsTrigger value="week">Week</TabsTrigger>
</TabsList>
<TabsContent value="week">…</TabsContent>
</Tabs>Rules
What this component expects from you, as it ships in components/ui/tabs.md next to the code. The full file adds when to use it, keyboard, the UI foundations it applies and every shared rule.
## Rules for Tabs
- **Tabs + inset selection.** Pill tabs 4px inside a pill track, so the selection's radius is the track's minus the padding.
- **Slow in, slow out.** The selection slides in 220ms on `--ease-in-out`, since it is already on screen; it jumps with reduced motion.
- The active tab also changes weight and color, so the state never depends on the motion.
- Tabs are 44px; arrow keys, Home and End move between them (Radix).Code
"use client"
import * as React from "react"
import { Tabs as TabsPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
// Tabs (Radix): arrow keys, Home and End move between tabs, and only the
// active one is in the tab order.
// - Nested corners: a pill track with 4px of padding around pill tabs, so
// the selection's curve is the track's curve minus the padding.
// - Slow in, slow out: the selection slides to the new tab in 220ms (--duration-menu) on
// --ease-in-out, since it is already on screen moving to a new place. It
// jumps under reduced motion. The active tab also changes color, so the
// state never depends on the motion.
const Tabs = TabsPrimitive.Root
function TabsList({
className,
children,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
const list = React.useRef<HTMLDivElement>(null)
const [box, setBox] = React.useState<{ left: number; width: number } | null>(
null
)
React.useLayoutEffect(() => {
const el = list.current
if (!el) return
const measure = () => {
const active = el.querySelector<HTMLElement>("[data-state=active]")
if (active) setBox({ left: active.offsetLeft, width: active.offsetWidth })
}
measure()
const resize = new ResizeObserver(measure)
resize.observe(el)
const mutate = new MutationObserver(measure)
mutate.observe(el, {
attributes: true,
subtree: true,
attributeFilter: ["data-state"],
})
return () => {
resize.disconnect()
mutate.disconnect()
}
}, [])
return (
<TabsPrimitive.List
ref={list}
data-slot="tabs-list"
className={cn(
"relative isolate inline-flex max-w-full [scrollbar-width:none] items-center gap-1 overflow-x-auto rounded-full bg-secondary p-1",
className
)}
{...props}
>
{box && (
<span
aria-hidden
className="absolute inset-y-1 left-0 -z-10 rounded-full bg-card elevation-1 transition-[translate,width] duration-(--duration-menu) ease-in-out motion-reduce:transition-none"
style={{ translate: `${box.left}px 0`, width: box.width }}
/>
)}
{children}
</TabsPrimitive.List>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"inline-flex h-11 shrink-0 cursor-pointer items-center justify-center gap-2 rounded-full px-4 text-label font-normal whitespace-nowrap text-muted-foreground",
"transition-[color] duration-(--duration-fast) ease-out hover:text-foreground data-[state=active]:font-medium data-[state=active]:text-foreground",
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-40",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn(
"rounded-xs pt-4 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring",
className
)}
{...props}
/>
)
}
export { Tabs, TabsContent, TabsList, TabsTrigger }