Skip to content
ssntl/ui

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/tabs

Usage

tsx
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.

components/ui/tabs.md
## 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).
Open the full rules file (opens in a new tab)

Code

components/ui/tabs.tsx
"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 }