Para ti24
Elegido a partir de lo que guardas y compartes.
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
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.