Sliding Tabs
SaveSegmented 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-tabsPre-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-runUsage
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
| Prop | Type | Default | Description |
|---|---|---|---|
| SlidingTabs.defaultValue | any | 0 | Value of the tab that is active initially, when uncontrolled. |
| SlidingTabs.value | any | — | 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.transition | Transition | { type: "spring", bounce: 0.2, duration: 0.4 } | Motion transition used when the pill slides. |
| SlidingTabsList.activateOnFocus | boolean | false | Activates a tab as soon as arrow keys focus it, instead of on Enter or Space. |
| SlidingTabsTab.value | any | — | Identifies the tab. Must match a SlidingTabsPanel value. |
| SlidingTabsTab.disabled | boolean | false | Skips the tab during keyboard navigation and ignores clicks. |
| SlidingTabsPanel.value | any | — | Shown while the tab with the same value is active. |
| SlidingTabsPanel.keepMounted | boolean | false | Keeps 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.tsxShowHide
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}
/>
);
}