SaaS & Ops

Saved Views Bar

A tab bar of saved filtered views with a sliding underline indicator, per-view counts, roving-tabindex arrow navigation and an add-view button.

Install

npx shadcn@latest add @paragon/saved-views-bar

saved-views-bar.tsx

"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>
  );
}