Browse the library
Components / Navigation
Sliding tabs
FreeAccessible tabs with a spring animated sliding pill, arrow key navigation and a soft content crossfade.
View as Markdownmotion
components/facade/sliding-tabs.tsx
"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
export interface SlidingTab {
/** Unique value for the tab. */
value: string;
/** Label shown in the tab list. */
label: React.ReactNode;
/** Optional icon shown before the label. */
icon?: React.ReactNode;
/** Panel content. */
content?: React.ReactNode;
disabled?: boolean;
}
export interface SlidingTabsProps {
tabs: SlidingTab[];
/** Controlled value. */
value?: string;
/** Initial value when uncontrolled. Defaults to the first tab. */
defaultValue?: string;
onValueChange?: (value: string) => void;
/** Accessible label for the tab list. */
label?: string;
/** Stretch tabs to fill the width. */
fullWidth?: boolean;
className?: string;
listClassName?: string;
panelClassName?: string;
}
export function SlidingTabs({
tabs,
value,
defaultValue,
onValueChange,
label = "Tabs",
fullWidth = false,
className,
listClassName,
panelClassName,
}: SlidingTabsProps) {
const reduce = useReducedMotion();
const id = React.useId();
const [internal, setInternal] = React.useState(defaultValue ?? tabs[0]?.value ?? "");
const active = value ?? internal;
const refs = React.useRef<Map<string, HTMLButtonElement>>(new Map());
const select = (v: string) => {
if (value === undefined) setInternal(v);
onValueChange?.(v);
};
const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
const enabled = tabs.filter((t) => !t.disabled);
const idx = enabled.findIndex((t) => t.value === active);
let next: SlidingTab | undefined;
if (e.key === "ArrowRight") next = enabled[(idx + 1) % enabled.length];
else if (e.key === "ArrowLeft") next = enabled[(idx - 1 + enabled.length) % enabled.length];
else if (e.key === "Home") next = enabled[0];
else if (e.key === "End") next = enabled[enabled.length - 1];
if (!next) return;
e.preventDefault();
select(next.value);
refs.current.get(next.value)?.focus();
};
const current = tabs.find((t) => t.value === active);
return (
<div className={cn("w-full", className)}>
<div
role="tablist"
aria-label={label}
aria-orientation="horizontal"
onKeyDown={onKeyDown}
className={cn(
"relative inline-flex items-center gap-1 rounded-full border border-border bg-muted/70 p-1",
fullWidth && "flex w-full",
listClassName,
)}
>
{tabs.map((t) => {
const selected = t.value === active;
return (
<button
key={t.value}
ref={(el) => {
if (el) refs.current.set(t.value, el);
else refs.current.delete(t.value);
}}
type="button"
role="tab"
id={`${id}-tab-${t.value}`}
aria-selected={selected}
aria-controls={`${id}-panel-${t.value}`}
tabIndex={selected ? 0 : -1}
disabled={t.disabled}
onClick={() => select(t.value)}
className={cn(
"relative inline-flex h-9 items-center justify-center gap-1.5 rounded-full px-4 text-sm font-medium transition-colors duration-200",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-muted",
"disabled:pointer-events-none disabled:opacity-40 [&_svg]:size-4",
fullWidth && "flex-1",
selected ? "text-foreground" : "text-muted-foreground hover:text-foreground",
)}
>
{selected ? (
<motion.span
layoutId={`${id}-pill`}
aria-hidden
className="absolute inset-0 rounded-full bg-background shadow-[0_1px_2px_0_color-mix(in_oklch,var(--foreground)_10%,transparent),0_2px_8px_-2px_color-mix(in_oklch,var(--foreground)_10%,transparent)] ring-1 ring-border"
transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 420, damping: 36 }}
/>
) : null}
<span className="relative z-10 inline-flex items-center gap-1.5">
{t.icon}
{t.label}
</span>
</button>
);
})}
</div>
<div className={cn("relative mt-4", panelClassName)}>
<AnimatePresence mode="wait" initial={false}>
{current ? (
<motion.div
key={current.value}
role="tabpanel"
id={`${id}-panel-${current.value}`}
aria-labelledby={`${id}-tab-${current.value}`}
tabIndex={0}
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 6, filter: "blur(4px)" }}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: -4, filter: "blur(4px)" }}
transition={{ duration: 0.25, ease: [0.22, 1, 0.36, 1] }}
className="rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{current.content}
</motion.div>
) : null}
</AnimatePresence>
</div>
</div>
);
}
export default SlidingTabs;app/page.tsx
import { ArrowRight, Building2, Home, Key, MapPin, Search, TrendingUp } from "lucide-react";
import type { ReactNode } from "react";
import { SlidingTabs } from "@/components/facade/sliding-tabs";
function Field({ label, children }: { label: string; children: ReactNode }) {
return (
<label className="flex min-w-0 flex-1 flex-col gap-1 rounded-xl px-3 py-2 transition-colors hover:bg-muted/60">
<span className="text-[11px] font-medium uppercase tracking-[0.12em] text-muted-foreground">{label}</span>
{children}
</label>
);
}
const inputCls =
"w-full bg-transparent text-sm text-foreground placeholder:text-muted-foreground/70 focus:outline-none";
function SearchPane({ cta, priceLabel, pricePlaceholder }: { cta: string; priceLabel: string; pricePlaceholder: string }) {
return (
<div className="flex flex-col gap-1 rounded-2xl border border-border bg-card p-2 shadow-[0_1px_2px_0_color-mix(in_oklch,var(--foreground)_5%,transparent),0_16px_40px_-24px_color-mix(in_oklch,var(--foreground)_30%,transparent)] sm:flex-row sm:items-center">
<Field label="Location">
<span className="flex items-center gap-1.5">
<MapPin className="size-4 shrink-0 text-brand" />
<input className={inputCls} defaultValue="Austin, TX" aria-label="Location" />
</span>
</Field>
<div className="hidden h-10 w-px bg-border sm:block" />
<Field label={priceLabel}>
<input className={inputCls} placeholder={pricePlaceholder} aria-label={priceLabel} />
</Field>
<div className="hidden h-10 w-px bg-border sm:block" />
<Field label="Beds">
<input className={inputCls} placeholder="Any" aria-label="Beds" />
</Field>
<button
type="button"
className="inline-flex h-12 items-center justify-center gap-2 rounded-xl bg-brand px-5 text-sm font-medium text-brand-foreground transition-opacity hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card"
>
<Search className="size-4" />
{cta}
</button>
</div>
);
}
function SellPane() {
return (
<div className="flex flex-col gap-4 rounded-2xl border border-border bg-card p-5 shadow-[0_1px_2px_0_color-mix(in_oklch,var(--foreground)_5%,transparent),0_16px_40px_-24px_color-mix(in_oklch,var(--foreground)_30%,transparent)] sm:flex-row sm:items-center">
<span className="inline-flex size-11 shrink-0 items-center justify-center rounded-xl bg-success/10 text-success">
<TrendingUp className="size-5" />
</span>
<div className="flex-1">
<p className="text-sm font-semibold">Homes in 78704 sold for 3.2% over asking last quarter</p>
<p className="mt-0.5 text-sm text-muted-foreground">Get a free valuation from a local agent within 24 hours.</p>
</div>
<button
type="button"
className="group inline-flex h-11 items-center justify-center gap-2 rounded-xl bg-primary px-5 text-sm font-medium text-primary-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card"
>
Value my home
<ArrowRight className="size-4 transition-transform group-hover:translate-x-0.5" />
</button>
</div>
);
}
export default function Demo() {
return (
<div className="w-full max-w-2xl">
<h3 className="mb-5 font-display text-2xl font-semibold tracking-tight sm:text-3xl">Your next move, in Austin</h3>
<SlidingTabs
label="Search mode"
tabs={[
{
value: "buy",
label: "Buy",
icon: <Home />,
content: <SearchPane cta="Search homes" priceLabel="Max price" pricePlaceholder="$750,000" />,
},
{
value: "rent",
label: "Rent",
icon: <Key />,
content: <SearchPane cta="Find rentals" priceLabel="Monthly budget" pricePlaceholder="$2,800 / mo" />,
},
{ value: "sell", label: "Sell", icon: <Building2 />, content: <SellPane /> },
]}
/>
<p className="mt-4 text-xs text-muted-foreground">Tip: use the arrow keys to switch tabs.</p>
</div>
);
}Installation
$
npx shadcn@latest add https://facadeui.com/r/sliding-tabs.jsonUsage
import { SlidingTabs } from "@/components/facade/sliding-tabs";
export default function Page() {
return <SlidingTabs />;
}Props
SlidingTabsProps is exported, so you can extend or wrap it. Every prop has a default; <SlidingTabs /> renders the example above.
| Prop | Type | Description |
|---|---|---|
| tabs* | SlidingTab[] | |
| value | string | Controlled value. |
| defaultValue | string | Initial value when uncontrolled. Defaults to the first tab. |
| onValueChange | (value: string) => void | |
| label | string | Accessible label for the tab list. |
| fullWidth | boolean | Stretch tabs to fill the width. |
| className | string | |
| listClassName | string | |
| panelClassName | string |
* required
Ask your agent
With the Facade MCP server connected, this prompt is enough:
Add the Facade sliding tabs to this page and adapt the copy to our business.