Para ti24

Elegido a partir de lo que guardas y compartes.

React + MotionNavegación

Tabs con gota líquida

El indicador es una gota que se estira y deja estela al seguir al puntero de una pestaña a otra.

Ajustes

8

Código

import { useId, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";

const GOO_TABS = [
  { label: "Para ti", count: "24", text: "Elegido a partir de lo que guardas y compartes." },
  { label: "Siguiendo", count: "48", text: "Lo último de las personas y estudios que sigues." },
  { label: "Popular", count: "12", text: "Lo que más se mueve hoy en diseño y producto." },
  {
    label: "Guardado",
    count: "126",
    text: "Tus elementos guardados, del más nuevo al más antiguo.",
  },
];

interface GooeyTabsProps {
  color?: string;
  goo?: number;
  followPointer?: boolean;
}

export default function GooeyTabs({
  color = "#ff4f7b",
  goo = 8,
  followPointer = true,
}: GooeyTabsProps) {
  const [active, setActive] = useState(0);
  const [hover, setHover] = useState<number | null>(null);
  const reduce = useReducedMotion();
  const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);
  const uid = useId().replace(/[^a-zA-Z0-9]/g, "");
  const filterId = "goo-" + uid;
  const n = GOO_TABS.length;
  const target = followPointer && hover !== null ? hover : active;
  const current = GOO_TABS[active]!;

  const select = (i: number) => {
    const k = (i + n) % n;
    setActive(k);
    tabRefs.current[k]?.focus();
  };

  const blob = (inset: string, scaleY: number, stiffness: number, damping: number) => (
    <motion.div
      className={"absolute left-0 rounded-full " + inset}
      style={{ width: 100 / n + "%", backgroundColor: color }}
      initial={false}
      animate={{ x: target * 100 + "%", scaleY }}
      transition={reduce ? { duration: 0 } : { type: "spring", stiffness, damping }}
    />
  );

  return (
    <div className="w-80 max-w-full text-[#1d1d1f] dark:text-[#f5f5f7]">
      <svg aria-hidden className="absolute h-0 w-0">
        <defs>
          <filter id={filterId}>
            <feGaussianBlur in="SourceGraphic" stdDeviation={goo} result="blur" />
            <feColorMatrix
              in="blur"
              mode="matrix"
              values="1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 24 -10"
            />
          </filter>
        </defs>
      </svg>

      <div
        role="tablist"
        aria-label="Secciones del feed"
        onPointerLeave={() => setHover(null)}
        onKeyDown={(e) => {
          const keys: Record<string, number> = {
            ArrowRight: active + 1,
            ArrowLeft: active - 1,
            Home: 0,
            End: n - 1,
          };
          const to = keys[e.key];
          if (to === undefined) return;
          e.preventDefault();
          select(to);
        }}
        className="relative grid grid-cols-4 rounded-full bg-white p-1 shadow-[0_1px_2px_rgba(0,0,0,0.04),0_10px_32px_rgba(0,0,0,0.07)] ring-1 ring-black/[0.06] dark:bg-[#1c1c1e] dark:shadow-[0_10px_32px_rgba(0,0,0,0.4)] dark:ring-white/10"
      >
        <div
          aria-hidden
          className="pointer-events-none absolute inset-1"
          style={{ filter: "url(#" + filterId + ")" }}
        >
          {blob("inset-y-0", 1, 340, 30)}
          {blob("inset-y-0.5", 0.85, 110, 15)}
          {blob("inset-y-1.5", 0.7, 65, 12)}
        </div>
        {GOO_TABS.map((tab, i) => (
          <button
            key={tab.label}
            ref={(el) => {
              tabRefs.current[i] = el;
            }}
            type="button"
            role="tab"
            id={uid + "-tab-" + i}
            aria-selected={active === i}
            aria-controls={uid + "-panel"}
            tabIndex={active === i ? 0 : -1}
            onClick={() => setActive(i)}
            onPointerEnter={(e) => {
              if (e.pointerType === "mouse") setHover(i);
            }}
            className={
              "relative z-10 h-10 min-w-0 truncate rounded-full px-1 text-[13px] font-medium outline-none transition-colors duration-300 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/70 active:scale-[0.97] " +
              (target === i
                ? "text-white"
                : "text-[#86868b] hover:text-[#1d1d1f] dark:hover:text-[#f5f5f7]")
            }
          >
            {tab.label}
          </button>
        ))}
      </div>

      <div
        role="tabpanel"
        id={uid + "-panel"}
        aria-labelledby={uid + "-tab-" + active}
        className="relative mt-3 h-[84px] overflow-hidden rounded-[20px] bg-white px-5 py-4 shadow-[0_1px_2px_rgba(0,0,0,0.04),0_10px_32px_rgba(0,0,0,0.07)] ring-1 ring-black/[0.06] dark:bg-[#1c1c1e] dark:shadow-[0_10px_32px_rgba(0,0,0,0.4)] dark:ring-white/10"
      >
        <AnimatePresence mode="popLayout" initial={false}>
          <motion.div
            key={active}
            initial={{ opacity: 0, y: 10, filter: "blur(4px)" }}
            animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
            exit={{ opacity: 0, y: -10, filter: "blur(4px)" }}
            transition={{ type: "spring", stiffness: 380, damping: 34 }}
          >
            <p className="flex items-baseline gap-2">
              <span className="text-[15px] font-semibold tracking-[-0.01em]">{current.label}</span>
              <span className="text-[12px] font-medium tabular-nums" style={{ color }}>
                {current.count}
              </span>
            </p>
            <p className="mt-1 text-[13px] leading-snug text-[#86868b]">{current.text}</p>
          </motion.div>
        </AnimatePresence>
      </div>
    </div>
  );
}

Prompt para IA

Pégalo en ChatGPT, Claude o Cursor y adaptará el bloque a tu proyecto con estos ajustes, aunque no uses React.

Más en Navegación

Dónde usarlo

Las pestañas están por todas partes en un feed: «Para ti», «Siguiendo», «Popular», «Guardado». Estas tabs animadas en React cambian el subrayado de siempre por una gota de color que se estira, se queda atrás y se vuelve a juntar, así que cambiar de sección en una red social o en un filtro de portfolio tiene su gracia sin estorbar.

La gota solo sigue al ratón; en táctil fluye directamente a la pestaña que tocas. La viscosidad decide cuánto dura la estela, y un valor intermedio suele quedar mejor. Quédate en tres a cinco etiquetas cortas, porque las pestañas se reparten el ancho a partes iguales.