Sliding Tabs

Pill tabs whose label colour is clipped to a sliding pill, with the active icon fading in beside it.

Preview

Setup

Install with the shadcn CLI

$npx shadcn@latest add https://kinetikui.vercel.app/r/sliding-tabs.json

One command adds every file, the npm packages and any Kinetik components it builds on. Needs a components.json; the CLI offers to create one if your project has none.

Or install manually

  1. 1

    Run in your terminal:

    $npm install clsx tailwind-merge @radix-ui/react-slot class-variance-authority
  2. 2

    Copy lib/utils.ts from the Code tab. Skip if you already have cn().

  3. 3

    Copy styles/tokens.css from the Code tab and import it in your global CSS after Tailwind: @import "./styles/tokens.css";

  4. 4

    This component uses Button. Copy components/ui/button/ from the Code tab too, at the same path so the import resolves. Skip any you already have.

  5. 5

    Create a components/ui/sliding-tabs/ folder and copy every file from the Code tab into it. Each file does one job, so you can read or change a piece without the rest.

  6. 6

    Import and render:

    Example
    import { SlidingTabs } from "@/components/ui/sliding-tabs"
    
    <SlidingTabs tabs={[{ value: "one", label: "One" }, { value: "two", label: "Two" }]} />

Usage

Each tab has a muted label and an overlay copy in the active colour, clipped to wherever the pill sits. As the pill slides, the colour changes exactly at its edges. An icon shows only on the active tab: its slot opens and the icon fades in from small and blurred. Give tabs content and the panel below fades in when the tab changes.

import { LayoutDashboard, Settings } from "lucide-react"
import { SlidingTabs } from "@/components/ui/sliding-tabs"

export function Workspace() {
  return (
    <SlidingTabs
      label="Workspace"
      tabs={[
        { value: "overview", label: "Overview", icon: <LayoutDashboard />, content: <Overview /> },
        { value: "settings", label: "Settings", icon: <Settings />, content: <SettingsForm /> },
      ]}
    />
  )
}

Phones and tablets

When the labels don't fit, the tabs collapse to icons: every tab shows its icon, and the labels stay readable to screen readers and as tooltips. Try the Desktop, Tablet and Mobile buttons in the preview. It needs every tab to have an icon; set collapse="never" to keep labels, or collapse="always" for an icon bar.

Controlled

Leave out content to use the tab list on its own, for example to switch views you render yourself. Control it with value and onValueChange:

const [view, setView] = useState("grid")

<SlidingTabs
  label="View"
  value={view}
  onValueChange={setView}
  tabs={[{ value: "grid", label: "Grid" }, { value: "list", label: "List" }]}
/>

Props

PropTypeDefaultNotes
tabs{ value; label; icon?; content? }[]—One per tab. An icon shows only while its tab is active.
valuestring—Selected tab. Leave unset for uncontrolled use.
defaultValuestringFirst tabStarting tab when uncontrolled.
onValueChange(value: string) => void—Called when a tab is chosen.
labelstring"Tabs"Accessible name for the tab list.
collapse"auto" | "never" | "always""auto"Show icons instead of labels: when they don't fit, never, or always.
classNamestring—Applied to the wrapper.
panelClassNamestring—Applied to the panel.

Accessibility

  • A real tablist with tab and tabpanel roles, linked by aria-controls and aria-labelledby
  • One Tab stop: Left and Right arrows, Home and End move between tabs and select them
  • The active-colour label copy is hidden from screen readers, so each tab is read once
  • Collapsed to icons, each tab keeps its label for screen readers and shows it as a tooltip
  • With reduced motion, the pill, labels, icons and panel change instantly