Fazekit

Code

"use client";
import * as React from "react";
import { AnimatePresence, motion, useIsPresent, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

export interface AnimatedTab {
  id: string;
  label: string;
  icon?: React.ReactNode;
  content: React.ReactNode;
  disabled?: boolean;
}

export interface AnimatedTabsProps {
  tabs: AnimatedTab[];
  /** Uncontrolled initial tab id. Defaults to the first enabled tab. */
  defaultValue?: string;
  /** Controlled tab id. */
  value?: string;
  onValueChange?: (id: string) => void;
  /** "pill" = filled sliding pill, "underline" = sliding bar under the label. */
  variant?: "pill" | "underline";
  /** Accessible name for the tab list. */
  label?: string;
  className?: string;
  listClassName?: string;
  panelClassName?: string;
}

const spring = { type: "spring", stiffness: 420, damping: 34, mass: 0.8 } as const;

export function AnimatedTabs({
  tabs,
  defaultValue,
  value,
  onValueChange,
  variant = "pill",
  label = "Tabs",
  className,
  listClassName,
  panelClassName,
}: AnimatedTabsProps) {
  const reduce = useReducedMotion();
  const uid = React.useId();
  const firstEnabled = tabs.find((t) => !t.disabled)?.id ?? "";
  const [inner, setInner] = React.useState(defaultValue ?? firstEnabled);
  const current = value ?? inner;
  const index = Math.max(0, tabs.findIndex((t) => t.id === current));
  const [direction, setDirection] = React.useState(0);
  const tabRefs = React.useRef<(HTMLButtonElement | null)[]>([]);

  const select = (id: string) => {
    const next = tabs.findIndex((t) => t.id === id);
    setDirection(next > index ? 1 : -1);
    if (value === undefined) setInner(id);
    onValueChange?.(id);
  };

  const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
    const enabled = tabs.map((t, i) => (t.disabled ? -1 : i)).filter((i) => i >= 0);
    const pos = enabled.indexOf(index);
    let target: number | undefined;
    if (e.key === "ArrowRight" || e.key === "ArrowDown") target = enabled[(pos + 1) % enabled.length];
    else if (e.key === "ArrowLeft" || e.key === "ArrowUp") target = enabled[(pos - 1 + enabled.length) % enabled.length];
    else if (e.key === "Home") target = enabled[0];
    else if (e.key === "End") target = enabled[enabled.length - 1];
    if (target === undefined) return;
    e.preventDefault();
    select(tabs[target].id);
    tabRefs.current[target]?.focus();
  };

  // The active panel reports its height so the container animates instead of jumping.
  const [height, setHeight] = React.useState<number | "auto">("auto");

  const active = tabs[index];
  const distance = reduce ? 0 : 24;

  return (
    <div className={cn("w-full", className)}>
      <div
        role="tablist"
        aria-label={label}
        onKeyDown={onKeyDown}
        className={cn(
          "relative flex w-full gap-1 overflow-x-auto [scrollbar-width:none]",
          variant === "pill" ? "rounded-full border bg-muted/60 p-1" : "border-b",
          listClassName,
        )}
      >
        {tabs.map((tab, i) => {
          const selected = i === index;
          return (
            <button
              key={tab.id}
              ref={(el) => {
                tabRefs.current[i] = el;
              }}
              role="tab"
              type="button"
              id={`${uid}-tab-${tab.id}`}
              aria-selected={selected}
              aria-controls={`${uid}-panel-${tab.id}`}
              tabIndex={selected ? 0 : -1}
              disabled={tab.disabled}
              onClick={() => select(tab.id)}
              className={cn(
                "relative flex flex-1 shrink-0 items-center justify-center gap-2 whitespace-nowrap px-2.5 py-2 sm:px-3 text-sm font-medium transition-colors outline-none",
                "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:opacity-40",
                variant === "pill" ? "rounded-full" : "rounded-md pb-3",
                selected ? "text-foreground" : "text-muted-foreground hover:text-foreground",
              )}
            >
              {selected && (
                <motion.span
                  layoutId={`${uid}-indicator`}
                  transition={reduce ? { duration: 0 } : spring}
                  className={cn(
                    "absolute",
                    variant === "pill"
                      ? "inset-0 rounded-full bg-background shadow-sm ring-1 ring-border"
                      : "inset-x-2 -bottom-px h-0.5 rounded-full bg-primary",
                  )}
                />
              )}
              <span className="relative z-10 flex items-center gap-2">
                {tab.icon && <span className="flex max-sm:hidden">{tab.icon}</span>}
                {tab.label}
              </span>
            </button>
          );
        })}
      </div>

      <motion.div
        animate={{ height }}
        transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 300, damping: 32 }}
        className="relative overflow-hidden"
      >
        <AnimatePresence initial={false} mode="popLayout" custom={direction}>
          <motion.div
            key={active?.id}
            role="tabpanel"
            id={`${uid}-panel-${active?.id}`}
            aria-labelledby={`${uid}-tab-${active?.id}`}
            tabIndex={0}
            custom={direction}
            variants={{
              enter: (d: number) => ({ opacity: 0, x: d * distance, filter: reduce ? "none" : "blur(4px)" }),
              center: { opacity: 1, x: 0, filter: "blur(0px)" },
              exit: (d: number) => ({ opacity: 0, x: d * -distance, filter: reduce ? "none" : "blur(4px)" }),
            }}
            initial="enter"
            animate="center"
            exit="exit"
            transition={{ duration: 0.28, ease: [0.22, 1, 0.36, 1] }}
            className={cn("rounded-xl pt-4 outline-none focus-visible:ring-2 focus-visible:ring-ring", panelClassName)}
          >
            <Measure onHeight={setHeight}>{active?.content}</Measure>
          </motion.div>
        </AnimatePresence>
      </motion.div>
    </div>
  );
}

function Measure({ onHeight, children }: { onHeight: (h: number) => void; children: React.ReactNode }) {
  const ref = React.useRef<HTMLDivElement>(null);
  const present = useIsPresent();
  const presentRef = React.useRef(present);
  React.useEffect(() => {
    presentRef.current = present;
  }, [present]);
  React.useEffect(() => {
    const el = ref.current;
    if (!el) return;
    const ro = new ResizeObserver(() => {
      // Exiting panels keep rendering during their exit animation; only the live one sets the height.
      if (presentRef.current && el.parentElement) onHeight(el.parentElement.offsetHeight);
    });
    ro.observe(el);
    return () => ro.disconnect();
  }, [onHeight]);
  return <div ref={ref}>{children}</div>;
}

More in Navigation

View all →