A tab bar of saved filtered views with a sliding underline indicator, per-view counts, roving-tabindex arrow navigation and an add-view button.
npx shadcn@latest add @paragon/saved-views-bar"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Plus } from "lucide-react";
import { cn } from "@/lib/utils";
export interface SavedView {
id: string;
label: string;
/** Optional item count shown as a trailing figure. */
count?: number;
}
export interface SavedViewsBarProps
extends Omit<React.ComponentProps<"div">, "onChange"> {
views?: SavedView[];
value?: string;
defaultValue?: string;
onChange?: (id: string) => void;
/** Called when the add-view "+" is pressed. */
onAddView?: () => void;
/** Drop the sliding underline + layout motion; the indicator jumps. */
static?: boolean;
}
const DEFAULTS: SavedView[] = [
{ id: "all", label: "All issues", count: 128 },
{ id: "mine", label: "Assigned to me", count: 12 },
{ id: "active", label: "Active", count: 34 },
{ id: "backlog", label: "Backlog", count: 61 },
{ id: "recent", label: "Recently updated" },
];
/**
* A row of saved filtered views as tabs. The active-tab underline is a single
* shared element that glides between tabs via motion's `layoutId`, so it
* retargets mid-slide rather than jumping. Tabs animate in/out with the layout
* engine as views are added or removed, and a trailing add-view button sits
* outside the scroll region. Arrow keys move between tabs (roving tabindex).
* Reduced motion (or `static`) snaps the indicator without the slide.
* Overflows scroll horizontally.
*/
export function SavedViewsBar({
views = DEFAULTS,
value,
defaultValue,
onChange,
onAddView,
static: isStatic = false,
className,
...props
}: SavedViewsBarProps) {
const reducedMotion = useReducedMotion();
const animate = !isStatic && !reducedMotion;
const [internal, setInternal] = React.useState(
defaultValue ?? views[0]?.id,
);
const activeId = value ?? internal;
const activeIndex = Math.max(
views.findIndex((v) => v.id === activeId),
0,
);
const tabRefs = React.useRef<(HTMLButtonElement | null)[]>([]);
const select = (id: string) => {
if (value === undefined) setInternal(id);
onChange?.(id);
};
const onKeyDown = (event: React.KeyboardEvent) => {
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
event.preventDefault();
const delta = event.key === "ArrowRight" ? 1 : -1;
const next = (activeIndex + delta + views.length) % views.length;
select(views[next].id);
tabRefs.current[next]?.focus();
};
return (
<div
data-slot="saved-views-bar"
className={cn(
"flex items-center gap-1 border-b border-border",
className,
)}
{...props}
>
<div
role="tablist"
aria-label="Saved views"
onKeyDown={onKeyDown}
className="relative flex flex-1 items-center gap-0.5 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
>
<AnimatePresence initial={false} mode="popLayout">
{views.map((view, index) => {
const active = view.id === activeId;
return (
<motion.button
key={view.id}
ref={(node) => {
tabRefs.current[index] = node;
}}
layout={animate ? "position" : false}
initial={animate ? { opacity: 0, filter: "blur(4px)" } : false}
animate={{ opacity: 1, filter: "blur(0px)" }}
exit={
animate
? { opacity: 0, filter: "blur(4px)" }
: { opacity: 0 }
}
transition={{
layout: { type: "spring", duration: 0.35, bounce: 0 },
opacity: { duration: 0.15, ease: [0.22, 1, 0.36, 1] },
filter: { duration: 0.15, ease: [0.22, 1, 0.36, 1] },
}}
role="tab"
aria-selected={active}
tabIndex={active ? 0 : -1}
onClick={() => select(view.id)}
className={cn(
"relative flex shrink-0 items-center gap-1.5 rounded-md px-2.5 py-2 text-sm outline-none",
"transition-colors duration-(--duration-fast) ease-(--ease-out)",
"focus-visible:ring-2 focus-visible:ring-ring",
active
? "font-medium text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
<span className="max-w-40 truncate">{view.label}</span>
{typeof view.count === "number" && (
<span
className={cn(
"rounded-full px-1.5 py-0.5 text-[11px] font-medium tabular-nums",
active
? "bg-secondary text-secondary-foreground"
: "bg-secondary/60 text-muted-foreground",
)}
>
{view.count}
</span>
)}
{active && (
<motion.span
aria-hidden
layoutId="saved-views-underline"
layout={animate ? true : false}
transition={{ type: "spring", duration: 0.35, bounce: 0 }}
className="pointer-events-none absolute inset-x-2.5 -bottom-px h-0.5 rounded-full bg-primary"
/>
)}
</motion.button>
);
})}
</AnimatePresence>
</div>
<button
type="button"
aria-label="Add view"
onClick={onAddView}
className={cn(
"pressable relative mb-1 flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground outline-none",
"transition-colors duration-(--duration-fast) ease-(--ease-out) hover:bg-accent hover:text-foreground",
"focus-visible:ring-2 focus-visible:ring-ring",
"after:absolute after:top-1/2 after:left-1/2 after:size-10 after:-translate-1/2",
)}
>
<Plus className="size-4" aria-hidden />
</button>
</div>
);
}