Para ti24

Elegido a partir de lo que guardas y compartes.

React + MotionNavigation

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

8

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.