Roving tab animation

Tab 1
Tab 2
Tab 3
typescript
export function AnimatedBackground({
  children,
  value,
  defaultValue = null,
  onValueChange,
  className,
  transition,
  enableHover = false,
}) {
  const [internalActiveId, setInternalActiveId] = useState<string | null>(defaultValue);

  const uniqueId = useId();

  const activeId = value !== undefined ? value : internalActiveId;

  const setActive = (id: string | null) => {
    if (value === undefined) {
      setInternalActiveId(id);
    }
    onValueChange?.(id);
  };

  const getChildProps = (child) => {
    const { className: childClassName, "data-id": id, ...rest } = child.props;
    const isActive = activeId === id;

    const interactionProps = enableHover
      ? {
          onMouseEnter: () => setActive(id),
          onMouseLeave: () => setActive(null),
        }
      : { onClick: () => setActive(id) };

    return {
      key: id,
      className: cn("relative inline-flex", childClassName),
      "data-active": isActive ? "true" : "false",
      isActive,
      ...interactionProps,
      ...rest,
    };
  };

  return (
    <>
      {Children.map(children, (child) => {
        const props = getChildProps(child);
        const { isActive, key, ...restProps } = props;

        return cloneElement(
          child,
          restProps,
          <>
            <AnimatePresence initial={false}>
              {isActive && (
                <motion.div
                  layoutId={`background-${uniqueId}`}
                  className={cn("absolute inset-0", className)}
                  transition={transition}
                  initial={{ opacity: 0 }}
                  animate={{ opacity: 1 }}
                  exit={{ opacity: 0 }}
                />
              )}
            </AnimatePresence>
            <div className="z-10">{child.props.children}</div>
          </>,
        );
      })}
    </>
  );
}

Usage example

typescript
const Tabs = () => {
  const tabs = [
    { id: "tab-1", label: "Tab 1" },
    { id: "tab-2", label: "Tab 2" },
    { id: "tab-3", label: "Tab 3" },
  ];

  const [activeTab, setActiveTab] = useState(tabs[0].id);

  return (
    <Box className="rounded-full border p-1">
      <AnimatedBackground
        value={activeTab}
        onValueChange={setActiveTab}
        className="rounded-full bg-background-surface-interactive"
        transition={{
          type: "spring",
          bounce: 0.2,
          duration: 0.3,
        }}
      >
        {tabs.map((tab) => (
          <Box
            key={tab.id}
            data-id={tab.id}
            className="inline-flex h-10 cursor-pointer items-center justify-center px-5 text-sm font-medium transition-colors data-[active=true]:text-foreground data-[active=false]:text-muted-foreground"
          >
            {tab.label}
          </Box>
        ))}
      </AnimatedBackground>
    </Box>
  );
};