Gooey Tabs with Liquid Merging Active Tab

A four-tab recipe box where the active tab and the panel below are one continuous dark shape. Choosing another tab slides the tab block across, and an SVG goo filter makes it peel away from the panel, stretch and merge back in at its new position, with soft rounded joins where the shapes meet. The panel content, a short blurb and four linked recipes with times, swaps with a quick blur and rise. The preview steps through the tabs until a visitor chooses one. On phones the tabs stay in one row with smaller text and the panel grows slightly taller.

Code

// Adapted from Fancy Components (https://fancycomponents.dev/docs/components/filter/gooey-svg-filter)
// Copyright (c) 2024 Daniel Petho. MIT License: https://we.inc/components/licenses
import { useEffect, useRef, useState } from "react";
import type { KeyboardEvent } from "react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";

const tabs = [
  {
    label: "Weeknight",
    blurb: "On the table in half an hour, one pan where possible.",
    recipes: [
      { name: "Miso butter noodles with greens", time: "15 min" },
      { name: "Crisp gnocchi, tomatoes and basil", time: "20 min" },
      { name: "Harissa chickpeas on toast", time: "20 min" },
      { name: "Lemon chicken and orzo", time: "30 min" },
    ],
  },
  {
    label: "Weekend",
    blurb: "Slower cooking for when the afternoon is yours.",
    recipes: [
      { name: "Braised short rib with polenta", time: "3 hr" },
      { name: "Hand-rolled spinach ravioli", time: "1 hr 30" },
      { name: "Whole roast cauliflower, tahini", time: "1 hr 10" },
      { name: "Slow tomato and fennel ragu", time: "2 hr" },
    ],
  },
  {
    label: "Baking",
    blurb: "Weighed in grams and tested in an ordinary oven.",
    recipes: [
      { name: "Brown butter banana loaf", time: "1 hr" },
      { name: "Overnight focaccia", time: "14 hr" },
      { name: "Salted rye chocolate cookies", time: "35 min" },
      { name: "Plum and almond tart", time: "1 hr 20" },
    ],
  },
  {
    label: "Drinks",
    blurb: "Something cold, with and without the spirits.",
    recipes: [
      { name: "Rhubarb and ginger spritz", time: "5 min" },
      { name: "Cold brew tonic", time: "5 min" },
      { name: "Cucumber and mint cooler", time: "10 min" },
      { name: "Burnt orange old fashioned", time: "10 min" },
    ],
  },
];

const GOO = "#2b1a5a"; // colour of the tab and panel shape
const STRENGTH = 12; // blur radius of the filter: higher gives a thicker, slower-looking liquid
const DEMO_MS = 2400; // time on each tab while the preview plays on its own

export default function GooeyTabsWithLiquidMergingActiveTab() {
  const reduce = useReducedMotion();
  const [active, setActive] = useState(0);
  const [demo, setDemo] = useState(true);
  const buttons = useRef<(HTMLButtonElement | null)[]>([]);

  useEffect(() => {
    if (!demo || reduce) return;
    const id = window.setInterval(() => setActive((a) => (a + 1) % tabs.length), DEMO_MS);
    return () => window.clearInterval(id);
  }, [demo, reduce]);

  const choose = (i: number) => {
    setDemo(false);
    setActive(i);
  };
  const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
    const step = e.key === "ArrowRight" ? 1 : e.key === "ArrowLeft" ? -1 : 0;
    if (!step) return;
    e.preventDefault();
    const next = (active + step + tabs.length) % tabs.length;
    choose(next);
    buttons.current[next]?.focus();
  };

  const tab = tabs[active];
  return (
    <section className="bg-[#ece8ff] px-6 py-16 text-[#2b1a5a] sm:py-20">
      {/* The filter blurs the shapes together, then sharpens the alpha channel so the blur reads as a liquid edge. */}
      <svg aria-hidden="true" className="absolute h-0 w-0">
        <defs>
          <filter id="gooey-tabs-filter">
            <feGaussianBlur in="SourceGraphic" stdDeviation={STRENGTH} result="blur" />
            <feColorMatrix in="blur" type="matrix" values="1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 19 -9" result="goo" />
            <feComposite in="SourceGraphic" in2="goo" operator="atop" />
          </filter>
        </defs>
      </svg>

      <div className="mx-auto max-w-3xl">
        <p className="font-mono text-xs uppercase tracking-[0.25em] text-violet-700">Second Helpings / Recipe box</p>
        <h2 className="mt-4 text-4xl font-bold tracking-tight sm:text-5xl">What are we cooking?</h2>

        <div className="relative mt-10">
          {/* Goo layer: one block for the active tab and one for the panel, merged by the filter. */}
          <div aria-hidden="true" className="absolute inset-0 flex flex-col" style={{ filter: "url(#gooey-tabs-filter)" }}>
            <div className="flex">
              {tabs.map((t, i) => (
                <div key={t.label} className="relative h-12 flex-1 sm:h-14">
                  {active === i && (
                    <motion.div layoutId="gooey-tabs-active" className="absolute inset-0" style={{ backgroundColor: GOO }} transition={reduce ? { duration: 0 } : { type: "spring", bounce: 0, duration: 0.5 }} />
                  )}
                </div>
              ))}
            </div>
            <div className="flex-1" style={{ backgroundColor: GOO }} />
          </div>

          {/* Content layer: sharp text and real controls, drawn above the goo with no filter. */}
          <div className="relative">
            <div role="tablist" aria-label="Recipe collections" className="flex" onKeyDown={onKeyDown}>
              {tabs.map((t, i) => (
                <button
                  key={t.label}
                  ref={(el) => {
                    buttons.current[i] = el;
                  }}
                  type="button"
                  role="tab"
                  id={`gooey-tab-${i}`}
                  aria-selected={active === i}
                  aria-controls="gooey-tab-panel"
                  tabIndex={active === i ? 0 : -1}
                  onClick={() => choose(i)}
                  className={`h-12 flex-1 text-sm font-semibold transition-colors duration-300 focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-violet-400 sm:h-14 sm:text-base ${active === i ? "text-white" : "text-[#2b1a5a] hover:text-violet-700"}`}
                >
                  {t.label}
                </button>
              ))}
            </div>

            <div id="gooey-tab-panel" role="tabpanel" aria-labelledby={`gooey-tab-${active}`} className="h-[380px] overflow-hidden px-6 py-8 text-white sm:h-[350px] sm:px-10 sm:py-10">
              <AnimatePresence mode="wait" initial={false}>
                <motion.div
                  key={active}
                  initial={reduce ? false : { opacity: 0, y: 28, filter: "blur(8px)" }}
                  animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
                  exit={reduce ? { opacity: 0 } : { opacity: 0, y: -28, filter: "blur(8px)" }}
                  transition={{ duration: reduce ? 0 : 0.22, ease: "easeOut" }}
                >
                  <p className="text-base text-violet-200">{tab.blurb}</p>
                  <ul className="mt-5">
                    {tab.recipes.map((r) => (
                      <li key={r.name} className="border-b border-white/20">
                        <a href="#" className="flex min-h-[52px] items-center justify-between gap-4 py-2 text-base font-medium transition-colors hover:text-violet-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-violet-300 sm:text-lg">
                          <span>{r.name}</span>
                          <span className="shrink-0 font-mono text-xs uppercase tracking-wide text-violet-200">{r.time}</span>
                        </a>
                      </li>
                    ))}
                  </ul>
                </motion.div>
              </AnimatePresence>
            </div>
          </div>
        </div>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/gooey-tabs-with-liquid-merging-active-tab.json

When to use it

How to customize it

Accessibility

Questions

How does the gooey effect work?

The filter first blurs the shapes, so nearby edges bleed into each other. A colour matrix then multiplies the alpha channel by 19 and subtracts 9, which turns the soft blurred edge back into a hard one. Where two blurs overlapped there is now a smooth bridge between the shapes.

Why is the text not inside the filtered element?

Anything inside a filtered element is blurred and thresholded too, which eats thin letter strokes. The component stacks two layers of the same size: plain coloured blocks with the filter underneath, and the real tabs and panel with transparent backgrounds on top.

Does it work in Safari?

Yes, though Safari renders SVG filters on the CPU, so a moving filtered layer can be less smooth there. Keeping the filtered layer to two plain blocks, as here, is what keeps it light. If you see stutter, lower `STRENGTH`.

What moves the active tab?

Only the active tab renders a block, and it has a `layoutId`. When the active index changes, framer-motion animates that block from the old tab's position to the new one.

Can I use the filter on other shapes?

Yes. Apply `filter: url(#gooey-tabs-filter)` to any wrapper whose children are solid shapes of one colour: a menu that splits into buttons, a row of dots, a toggle. Keep text out of the wrapper.

Adapted from Gooey SVG Filter in Fancy Components, used under its open-source licence. Licences and credits

Related: Animated Tabs with Sliding Highlight and Crossfade, Segmented Control with Spring Pill and Morphing Views, Underline Tabs with Icons and Three Panels.

More tabs and navigation or all free UI components. Start for free to have a full site built around it.

More in Navigation

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap