Para ti24
Elegido a partir de lo que guardas y compartes.
Tabs mit flüssigem Tropfen
Der Indikator ist ein flüssiger Tropfen, der sich dehnt und eine Spur zieht, während er dem Zeiger von Tab zu Tab folgt.
Einstellungen
Code
import { useId, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
const GOO_TABS = [
{ label: "Für dich", count: "24", text: "Ausgewählt anhand dessen, was du speicherst und teilst." },
{ label: "Gefolgt", count: "48", text: "Das Neueste von Leuten und Studios, denen du folgst." },
{ label: "Beliebt", count: "12", text: "Was heute in Design und Produkt angesagt ist." },
{
label: "Gespeichert",
count: "126",
text: "Deine gespeicherten Elemente, die neuesten zuerst.",
},
];
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="Feed-Bereiche"
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 für KI
Füg ihn in ChatGPT, Claude oder Cursor ein und der Baustein wird mit diesen Einstellungen an dein Projekt angepasst, auch ohne React.
Mehr aus Navigation
Wofür du ihn nutzen kannst
In jedem Feed gibt es Tabs: „Für dich“, „Folge ich“, „Beliebt“, „Gespeichert“. Diese animierten Tabs in React ersetzen die übliche Unterstreichung durch einen Farbtropfen, der sich dehnt, hinter sich herzieht und wieder zusammenfließt. So wirkt der Wechsel zwischen Bereichen in einer Social App oder einem Portfolio-Filter verspielt, ohne zu stören.
Der Tropfen folgt nur der Maus, bei Touch fließt er einfach zum angetippten Tab. Die Viskosität bestimmt, wie lange die Spur bleibt, ein mittlerer Wert sieht meist am besten aus. Bleib bei drei bis fünf kurzen Labels, denn die Tabs teilen sich die Breite gleichmäßig.