Skip to content
Seamless UI

Sliding Tabs

Save

Segmented tabs whose active pill slides between options with a spring.

Category
Core
Version
0.1.1
Frameworks
Next.js, Vite + React
Styling
Tailwind CSS v4

Preview

Sliding Tabs preview
8,930 requests this week.

Installation

npx @seamlessui/cli add sliding-tabs

Pre-release@seamlessui/cli is not on npm yet. Install from a local build.

Review the plan first. A dry run lists every file and package without changing anything:

npx @seamlessui/cli add sliding-tabs --dry-run

Usage

sliding-tabs-example.tsx
import { SlidingTabs, SlidingTabsList, SlidingTabsPanel, SlidingTabsTab } from "@/components/seamless/ui/sliding-tabs";

export default function SlidingTabsExample() {
  return (
    <SlidingTabs defaultValue="week" className="w-full max-w-sm items-center">
      <SlidingTabsList aria-label="Time range">
        <SlidingTabsTab value="day">Day</SlidingTabsTab>
        <SlidingTabsTab value="week">Week</SlidingTabsTab>
        <SlidingTabsTab value="month">Month</SlidingTabsTab>
      </SlidingTabsList>
      <SlidingTabsPanel value="day" className="text-center tabular-nums">1,204 requests today.</SlidingTabsPanel>
      <SlidingTabsPanel value="week" className="text-center tabular-nums">8,930 requests this week.</SlidingTabsPanel>
      <SlidingTabsPanel value="month" className="text-center tabular-nums">36,512 requests this month.</SlidingTabsPanel>
    </SlidingTabs>
  );
}

API

PropTypeDefaultDescription
SlidingTabs.defaultValueany0Value of the tab that is active initially, when uncontrolled.
SlidingTabs.valueany—Value of the active tab, when controlled. Pair with onValueChange.
SlidingTabs.onValueChange(value: any, details) => void—Called when the active tab changes.
SlidingTabs.orientation"horizontal" | "vertical""horizontal"Lays the tabs out in a row or a column and sets which arrow keys move between them.
SlidingTabs.transitionTransition{ type: "spring", bounce: 0.2, duration: 0.4 }Motion transition used when the pill slides.
SlidingTabsList.activateOnFocusbooleanfalseActivates a tab as soon as arrow keys focus it, instead of on Enter or Space.
SlidingTabsTab.valueany—Identifies the tab. Must match a SlidingTabsPanel value.
SlidingTabsTab.disabledbooleanfalseSkips the tab during keyboard navigation and ignores clicks.
SlidingTabsPanel.valueany—Shown while the tab with the same value is active.
SlidingTabsPanel.keepMountedbooleanfalseKeeps the panel in the DOM, hidden, while another tab is active.

Accessibility

  • Base UI renders the tablist, tab, and tabpanel roles and links each tab to its panel with aria-controls and aria-labelledby.
  • Arrow keys move focus between tabs; Home and End jump to the first and last tab.
  • The active tab is marked with aria-selected and a raised pill behind its label, not only a color change.
  • The pill is decorative (aria-hidden) and sits behind the label, so it never changes the accessible name.
  • The tree is wrapped in MotionConfig reducedMotion="user": when reduced motion is preferred, the pill jumps to the new tab instead of sliding.
  • Give SlidingTabsList an aria-label that names the group.

Files

Installed into src/components/seamless, or components/seamless when the project has no src directory.

  • src/components/seamless/ui/sliding-tabs.tsxsliding-tabs
  • npm packages: @base-ui/react@^1.9.0, motion@^14.14.0React and Tailwind CSS v4 required

Source

The exact source the CLI installs, from /r/items/react/sliding-tabs/0.1.1.json.

ui/sliding-tabs.tsxShow
ui/sliding-tabs.tsx
"use client";

import { Tabs as BaseTabs } from "@base-ui/react/tabs";
import { MotionConfig, motion, type Transition } from "motion/react";
import { createContext, useContext, useId } from "react";

function cx(...classes: Array<string | false | null | undefined>) {
  return classes.filter(Boolean).join(" ");
}

const defaultTransition: Transition = { type: "spring", bounce: 0.2, duration: 0.4 };

// Each SlidingTabs instance gets its own layoutId so pills never travel between separate tab groups.
const PillContext = createContext<{ layoutId: string; transition: Transition }>({
  layoutId: "seamless-sliding-tab-pill",
  transition: defaultTransition,
});

export interface SlidingTabsProps extends Omit<BaseTabs.Root.Props, "className"> {
  className?: string;
  /** Motion transition for the sliding pill. Defaults to a soft spring. */
  transition?: Transition;
}

/**
 * Tabs whose active pill slides between tabs with a spring.
 * With reduced motion preferred, the pill moves without animating.
 */
export function SlidingTabs({ className, transition = defaultTransition, ...props }: SlidingTabsProps) {
  const layoutId = `sliding-tab-pill-${useId()}`;

  return (
    <MotionConfig reducedMotion="user">
      <PillContext.Provider value={{ layoutId, transition }}>
        <BaseTabs.Root
          className={cx("flex flex-col gap-4 data-[orientation=vertical]:flex-row", className)}
          {...props}
        />
      </PillContext.Provider>
    </MotionConfig>
  );
}

export interface SlidingTabsListProps extends Omit<BaseTabs.List.Props, "className"> {
  className?: string;
}

/** The track that holds the tabs and the sliding pill. */
export function SlidingTabsList({ className, ...props }: SlidingTabsListProps) {
  return (
    <BaseTabs.List
      className={cx(
        "inline-flex w-fit gap-1 rounded-lg bg-zinc-100 p-1 dark:bg-zinc-900",
        "data-[orientation=vertical]:flex-col",
        className,
      )}
      {...props}
    />
  );
}

export interface SlidingTabsTabProps extends Omit<BaseTabs.Tab.Props, "className" | "render"> {
  className?: string;
}

/** A single tab. Its `value` must match a `SlidingTabsPanel`'s `value`. */
export function SlidingTabsTab({ className, ...props }: SlidingTabsTabProps) {
  const { layoutId, transition } = useContext(PillContext);

  return (
    <BaseTabs.Tab
      className={cx(
        "relative inline-flex h-8 cursor-pointer items-center justify-center gap-2 rounded-md px-3 text-sm font-medium whitespace-nowrap select-none",
        "text-zinc-600 transition-colors duration-150 motion-reduce:transition-none",
        "hover:text-zinc-900 data-active:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100 dark:data-active:text-white",
        "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-500",
        "data-disabled:pointer-events-none data-disabled:opacity-50",
        "data-[orientation=vertical]:justify-start [&_svg]:size-4 [&_svg]:shrink-0",
        className,
      )}
      render={(renderProps, state) => (
        <button {...renderProps}>
          {state.active ? (
            <motion.span
              aria-hidden="true"
              layoutId={layoutId}
              transition={transition}
              className="absolute inset-0 rounded-md bg-white shadow-sm ring-1 ring-zinc-900/10 dark:bg-zinc-700 dark:ring-white/10"
            />
          ) : null}
          <span className="relative inline-flex items-center gap-2">{renderProps.children}</span>
        </button>
      )}
      {...props}
    />
  );
}

export interface SlidingTabsPanelProps extends Omit<BaseTabs.Panel.Props, "className"> {
  className?: string;
}

/** Content shown while the tab with the same `value` is active. */
export function SlidingTabsPanel({ className, ...props }: SlidingTabsPanelProps) {
  return (
    <BaseTabs.Panel
      className={cx(
        "rounded-md text-sm text-zinc-700 dark:text-zinc-300",
        "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-500",
        className,
      )}
      {...props}
    />
  );
}