smooth-tab

PreviousNext
Docs
kokonutuicomponent

Preview

Loading preview…
/components/kokonutui/smooth-tab.tsx
"use client";

/**
 * @author: @dorianbaffier
 * @description: Smooth Tab
 * @version: 1.0.0
 * @date: 2025-06-26
 * @license: MIT
 * @website: https://kokonutui.com
 * @github: https://github.com/kokonut-labs/kokonutui
 */

import type { LucideIcon } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import * as React from "react";
import { cn } from "@/lib/utils";

interface TabItem {
  id: string;
  title: string;
  icon?: LucideIcon;
  content?: React.ReactNode;
  cardContent?: React.ReactNode;
  color: string;
}

const WaveformPath = () => (
  <motion.path
    animate={{
      x: [0, 10, 0],
      transition: {
        duration: 5,
        ease: "linear",
        repeat: Number.POSITIVE_INFINITY,
      },
    }}
    d="M0 50 
           C 20 40, 40 30, 60 50
           C 80 70, 100 60, 120 50
           C 140 40, 160 30, 180 50
           C 200 70, 220 60, 240 50
           C 260 40, 280 30, 300 50
           C 320 70, 340 60, 360 50
           C 380 40, 400 30, 420 50
           L 420 100 L 0 100 Z"
    initial={false}
  />
);

const DEFAULT_TABS: TabItem[] = [
  {
    id: "Models",
    title: "Models",
    color: "bg-blue-500 hover:bg-blue-600",
    cardContent: (
      <div className="relative h-full">
        <div className="absolute inset-0 overflow-hidden">
          <svg
            aria-hidden="true"
            className="absolute bottom-0 h-32 w-full"
            preserveAspectRatio="none"
            role="presentation"
            viewBox="0 0 420 100"
          >
            <motion.g
              animate={{ opacity: 0.15 }}
              className="fill-blue-500 stroke-blue-500"
              initial={{ opacity: 0 }}
              style={{ strokeWidth: 1 }}
              transition={{ duration: 0.5 }}
            >
              <WaveformPath />
            </motion.g>
            <motion.g
              animate={{ opacity: 0.1 }}
              className="fill-blue-500 stroke-blue-500"
              initial={{ opacity: 0 }}
              style={{
                strokeWidth: 1,
                transform: "translateY(10px)",
              }}
              transition={{ duration: 0.5 }}
            >
              <WaveformPath />
            </motion.g>
          </svg>
        </div>
        <div className="relative flex h-full flex-col p-6">
          <div className="space-y-2">
            <h3 className="bg-gradient-to-r from-foreground via-foreground/90 to-foreground/70 font-semibold text-2xl tracking-tight [text-shadow:_0_1px_1px_rgb(0_0_0_/_10%)]">
              Models
            </h3>
            <p className="max-w-[90%] text-black/50 text-sm leading-relaxed dark:text-white/50">
              Choose the model you want to use
            </p>
          </div>
        </div>
      </div>
    ),
  },
  {
    id: "MCPs",
    title: "MCPs",
    color: "bg-purple-500 hover:bg-purple-600",
    cardContent: (
      <div className="relative h-full">
        <div className="absolute inset-0 overflow-hidden">
          <svg
            aria-hidden="true"
            className="absolute bottom-0 h-32 w-full"
            preserveAspectRatio="none"
            role="presentation"
            viewBox="0 0 420 100"
          >
            <motion.g
              animate={{ opacity: 0.15 }}
              className="fill-purple-500 stroke-purple-500"
              initial={{ opacity: 0 }}
              style={{ strokeWidth: 1 }}
              transition={{ duration: 0.5 }}
            >
              <WaveformPath />
            </motion.g>
            <motion.g
              animate={{ opacity: 0.1 }}
              className="fill-purple-500 stroke-purple-500"
              initial={{ opacity: 0 }}
              style={{
                strokeWidth: 1,
                transform: "translateY(10px)",
              }}
              transition={{ duration: 0.5 }}
            >
              <WaveformPath />
            </motion.g>
          </svg>
        </div>
        <div className="relative flex h-full flex-col p-6">
          <div className="space-y-2">
            <h3 className="bg-gradient-to-r from-foreground via-foreground/90 to-foreground/70 font-semibold text-xl tracking-tight [text-shadow:_0_1px_1px_rgb(0_0_0_/_10%)]">
              MCPs
            </h3>
            <p className="max-w-[90%] text-black/50 text-sm leading-relaxed dark:text-white/50">
              Choose the MCP you want to use
            </p>
          </div>
        </div>
      </div>
    ),
  },
  {
    id: "Agents",
    title: "Agents",
    color: "bg-emerald-500 hover:bg-emerald-600",
    cardContent: (
      <div className="relative h-full">
        <div className="absolute inset-0 overflow-hidden">
          <svg
            aria-hidden="true"
            className="absolute bottom-0 h-32 w-full"
            preserveAspectRatio="none"
            role="presentation"
            viewBox="0 0 420 100"
          >
            <motion.g
              animate={{ opacity: 0.15 }}
              className="fill-emerald-500 stroke-emerald-500"
              initial={{ opacity: 0 }}
              style={{ strokeWidth: 1 }}
              transition={{ duration: 0.5 }}
            >
              <WaveformPath />
            </motion.g>
            <motion.g
              animate={{ opacity: 0.1 }}
              className="fill-emerald-500 stroke-emerald-500"
              initial={{ opacity: 0 }}
              style={{
                strokeWidth: 1,
                transform: "translateY(10px)",
              }}
              transition={{ duration: 0.5 }}
            >
              <WaveformPath />
            </motion.g>
          </svg>
        </div>
        <div className="relative flex h-full flex-col p-6">
          <div className="space-y-2">
            <h3 className="bg-gradient-to-r from-foreground via-foreground/90 to-foreground/70 font-semibold text-2xl tracking-tight [text-shadow:_0_1px_1px_rgb(0_0_0_/_10%)]">
              Agents
            </h3>
            <p className="max-w-[90%] text-black/50 text-sm leading-relaxed dark:text-white/50">
              Choose the agent you want to use
            </p>
          </div>
        </div>
      </div>
    ),
  },
  {
    id: "Users",
    title: "Users",
    color: "bg-amber-500 hover:bg-amber-600",
    cardContent: (
      <div className="relative h-full">
        <div className="absolute inset-0 overflow-hidden">
          <svg
            aria-hidden="true"
            className="absolute bottom-0 h-32 w-full"
            preserveAspectRatio="none"
            role="presentation"
            viewBox="0 0 420 100"
          >
            <motion.g
              animate={{ opacity: 0.15 }}
              className="fill-amber-500 stroke-amber-500"
              initial={{ opacity: 0 }}
              style={{ strokeWidth: 1 }}
              transition={{ duration: 0.5 }}
            >
              <WaveformPath />
            </motion.g>
            <motion.g
              animate={{ opacity: 0.1 }}
              className="fill-amber-500 stroke-amber-500"
              initial={{ opacity: 0 }}
              style={{
                strokeWidth: 1,
                transform: "translateY(10px)",
              }}
              transition={{ duration: 0.5 }}
            >
              <WaveformPath />
            </motion.g>
          </svg>
        </div>
        <div className="relative flex h-full flex-col p-6">
          <div className="space-y-2">
            <h3 className="bg-gradient-to-r from-foreground via-foreground/90 to-foreground/70 font-semibold text-2xl tracking-tight [text-shadow:_0_1px_1px_rgb(0_0_0_/_10%)]">
              Users
            </h3>
            <p className="max-w-[90%] text-black/50 text-sm leading-relaxed dark:text-white/50">
              Choose the user you want to use
            </p>
          </div>
        </div>
      </div>
    ),
  },
];

interface SmoothTabProps {
  items?: TabItem[];
  defaultTabId?: string;
  className?: string;
  activeColor?: string;
  onChange?: (tabId: string) => void;
}

const slideVariants = {
  enter: (direction: number) => ({
    x: direction > 0 ? "100%" : "-100%",
    opacity: 0,
    filter: "blur(8px)",
    scale: 0.95,
    position: "absolute" as const,
  }),
  center: {
    x: 0,
    opacity: 1,
    filter: "blur(0px)",
    scale: 1,
    position: "absolute" as const,
  },
  exit: (direction: number) => ({
    x: direction < 0 ? "100%" : "-100%",
    opacity: 0,
    filter: "blur(8px)",
    scale: 0.95,
    position: "absolute" as const,
  }),
};

const transition = {
  duration: 0.4,
  ease: [0.32, 0.72, 0, 1],
};

export default function SmoothTab({
  items = DEFAULT_TABS,
  defaultTabId = DEFAULT_TABS[0].id,
  className,
  activeColor = "bg-[#1F9CFE]",
  onChange,
}: SmoothTabProps) {
  const [selected, setSelected] = React.useState<string>(defaultTabId);
  const [direction, setDirection] = React.useState(0);
  const [dimensions, setDimensions] = React.useState({ width: 0, left: 0 });

  // Reference for the selected button
  const buttonRefs = React.useRef<Map<string, HTMLButtonElement>>(new Map());
  const containerRef = React.useRef<HTMLDivElement>(null);

  // Update dimensions whenever selected tab changes or on mount
  React.useLayoutEffect(() => {
    const updateDimensions = () => {
      const selectedButton = buttonRefs.current.get(selected);
      const container = containerRef.current;

      if (selectedButton && container) {
        const rect = selectedButton.getBoundingClientRect();
        const containerRect = container.getBoundingClientRect();

        setDimensions({
          width: rect.width,
          left: rect.left - containerRect.left,
        });
      }
    };

    // Initial update
    requestAnimationFrame(() => {
      updateDimensions();
    });

    // Update on resize
    window.addEventListener("resize", updateDimensions);
    return () => window.removeEventListener("resize", updateDimensions);
  }, [selected]);

  const handleTabClick = (tabId: string) => {
    const currentIndex = items.findIndex((item) => item.id === selected);
    const newIndex = items.findIndex((item) => item.id === tabId);
    setDirection(newIndex > currentIndex ? 1 : -1);
    setSelected(tabId);
    onChange?.(tabId);
  };

  const handleKeyDown = (
    e: React.KeyboardEvent<HTMLButtonElement>,
    tabId: string
  ) => {
    if (e.key === "Enter" || e.key === " ") {
      e.preventDefault();
      handleTabClick(tabId);
    }
  };

  const selectedItem = items.find((item) => item.id === selected);

  return (
    <div className="flex h-full flex-col">
      {/* Card Content Area */}
      <div className="relative mb-4 flex-1">
        <div className="relative h-[200px] w-full rounded-lg border bg-card">
          <div className="absolute inset-0 overflow-hidden rounded-lg">
            <AnimatePresence
              custom={direction}
              initial={false}
              mode="popLayout"
            >
              <motion.div
                animate="center"
                className="absolute inset-0 h-full w-full bg-card will-change-transform"
                custom={direction}
                exit="exit"
                initial="enter"
                key={`card-${selected}`}
                style={{
                  backfaceVisibility: "hidden",
                  WebkitBackfaceVisibility: "hidden",
                }}
                transition={transition as any}
                variants={slideVariants as any}
              >
                {selectedItem?.cardContent}
              </motion.div>
            </AnimatePresence>
          </div>
        </div>
      </div>

      {/* Bottom Toolbar */}
      <div
        aria-label="Smooth tabs"
        className={cn(
          "relative mt-auto flex items-center justify-between gap-1 py-1",
          "mx-auto w-[400px] bg-background",
          "rounded-xl border",
          "transition-all duration-200",
          className
        )}
        ref={containerRef}
        role="tablist"
      >
        {/* Sliding Background */}
        <motion.div
          animate={{
            width: dimensions.width - 8,
            x: dimensions.left + 4,
            opacity: 1,
          }}
          className={cn(
            "absolute z-[1] rounded-lg",
            selectedItem?.color || activeColor
          )}
          initial={false}
          style={{ height: "calc(100% - 8px)", top: "4px" }}
          transition={{
            type: "spring",
            stiffness: 400,
            damping: 30,
          }}
        />

        <div className="relative z-[2] grid w-full grid-cols-4 gap-1">
          {items.map((item) => {
            const isSelected = selected === item.id;
            return (
              <motion.button
                aria-controls={`panel-${item.id}`}
                aria-selected={isSelected}
                className={cn(
                  "relative flex items-center justify-center gap-0.5 rounded-lg px-2 py-1.5",
                  "font-medium text-sm transition-all duration-300",
                  "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
                  "truncate",
                  isSelected
                    ? "text-white"
                    : "text-muted-foreground hover:bg-muted/50 hover:text-foreground"
                )}
                id={`tab-${item.id}`}
                key={item.id}
                onClick={() => handleTabClick(item.id)}
                onKeyDown={(e) => handleKeyDown(e, item.id)}
                ref={(el) => {
                  if (el) buttonRefs.current.set(item.id, el);
                  else buttonRefs.current.delete(item.id);
                }}
                role="tab"
                tabIndex={isSelected ? 0 : -1}
                type="button"
              >
                <span className="truncate">{item.title}</span>
              </motion.button>
            );
          })}
        </div>
      </div>
    </div>
  );
}

Installation

npx shadcn@latest add @kokonutui/smooth-tab

Usage

import { SmoothTab } from "@/components/smooth-tab"
<SmoothTab />