Para ti24
Elegido a partir de lo que guardas y compartes.
Onglets à goutte liquide
L’indicateur est une goutte qui s’étire et laisse une traînée en suivant le pointeur d’un onglet à l’autre.
Réglages
Code
import { useId, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
const GOO_TABS = [
{ label: "Pour vous", count: "24", text: "Sélectionné selon ce que vous enregistrez et partagez." },
{ label: "Abonnements", count: "48", text: "Les dernières nouveautés des personnes et studios que vous suivez." },
{ label: "Populaire", count: "12", text: "Ce qui fait parler aujourd’hui en design et produit." },
{
label: "Enregistré",
count: "126",
text: "Vos éléments enregistrés, du plus récent au plus ancien.",
},
];
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="Sections du fil"
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 pour l’IA
Collez-le dans ChatGPT, Claude ou Cursor : le bloc sera adapté à votre projet avec ces réglages, même sans React.
Plus dans Navigation
Où l’utiliser
Les onglets sont partout dans un fil d’actualité : « Pour vous », « Abonnements », « Populaire », « Enregistrés ». Ces onglets animés en React remplacent le soulignement habituel par une goutte de couleur qui s’étire, traîne derrière elle et se referme. Changer de rubrique dans un réseau social ou filtrer un portfolio devient un peu ludique.
La goutte ne suit que la souris ; au toucher, elle coule directement vers l’onglet choisi. La viscosité règle la durée de la traînée, et une valeur moyenne rend souvent le mieux. Limitez-vous à trois à cinq libellés courts, car les onglets se partagent la largeur à parts égales.