Shared Layout Tabs

Tabs whose indicator moves via a shared layout transition.

Working demo
npx shadcn@latest add https://noechague-site.vercel.app/r/shared-layout-tabs.json
shared-layout-tabs.tsx
"use client"

import { useId, useState } from "react"
import { motion, useReducedMotion } from "motion/react"
import { Tabs } from "@base-ui/react/tabs"
import { cn } from "@/lib/utils"

export function SharedLayoutTabs({
  tabs,
  defaultTab,
  panelId,
  onChange,
}: {
  tabs: string[]
  defaultTab?: string
  /** id of the controlled panel. Without it `aria-controls` is omitted rather than left empty. */
  panelId?: string
  onChange?: (tab: string) => void
}) {
  const [active, setActive] = useState(defaultTab ?? tabs[0])
  const layoutId = useId()
  const reduced = useReducedMotion() ?? false

  return (
    <Tabs.Root
      onValueChange={(value: string) => {
        setActive(value)
        onChange?.(value)
      }}
      value={active}
    >
      {/* The tablist role, roving tabindex, wrapping arrow keys and Home/End
          all come from Base UI, which is tested against real assistive
          technology. This is not a hand-rolled reimplementation.
          `activateOnFocus`: by default the primitive only moves focus and waits
          for Enter or Space. We want an arrow key to select the tab outright. */}
      <Tabs.List
        activateOnFocus
        className="flex items-center gap-1 rounded-full bg-neutral-100 p-1"
      >
        {tabs.map((tab) => {
          const selected = tab === active
          return (
            <Tabs.Tab
              aria-controls={panelId}
              // Stock Tailwind classes only: this file is installed into other
              // people's projects, where the site's own scale (gray-1000,
              // preview-bg, shadow-custom) and the `hover-hover` variant do not
              // exist. They generate no CSS there, so the indicator would lose
              // its background and the selected tab its colour, silently.
              className={cn(
                "relative flex h-9 cursor-pointer items-center justify-center rounded-full px-4",
                "font-medium text-sm transition-colors duration-200 ease-out",
                selected ? "text-neutral-900" : "text-neutral-600 hover:text-neutral-900",
              )}
              key={tab}
              value={tab}
            >
              {selected && (
                <motion.span
                  className="absolute inset-0 rounded-full bg-white shadow-sm"
                  layoutId={layoutId}
                  transition={
                    reduced ? { duration: 0 } : { type: "spring", duration: 0.4, bounce: 0.18 }
                  }
                />
              )}
              <span className="relative z-10">{tab}</span>
            </Tabs.Tab>
          )
        })}
      </Tabs.List>
    </Tabs.Root>
  )
}