Old pines and a path that goes soft underfoot. Light arrives late here and leaves early, so the whole floor stays green.

Gooey Tabs

Tabs that pour: the active pill and panel share one SVG goo filter, so they fuse into a liquid neck that stretches and snaps as the pill slides.

A free, copy-paste navigation menus component for React, Next.js — built with Tailwind CSS, installable with the shadcn CLI.

Configuration

Tweak these live from theConfigurecontrol in the preview.

glasson / off — default off.
gooeyness0–16 — default 8.
tabHeight34–72px — default 48px.
effectspring · smooth · snappy · elastic — default "spring".
speed0.5–2× — default 1×.
surfacecolour — default #f2f6fa.
framecolour — default #5d8bb3.

Interaction Type

  • Click a tab — the pill pours across and fuses with the panel
  • Hovering an inactive tab lifts a soft plate behind it
  • Arrow keys move between tabs

Dependency

No dependencies — plain React. Just drop the file in.

Point the CLI here once

A namespace is a local alias, so the CLI has to be told what @harshdev-ui resolves to. One command, once per project:

npx shadcn registry add "@harshdev-ui=https://ui.harshpandav.dev/r/{name}.json"

After that every component installs by name. To skip it entirely, pass the URL straight to the CLI — shadcn add https://ui.harshpandav.dev/r/gooey-tabs.json — or copy the file from Source code below.

[ shadcn CLI 3.0 registry ]…or add the component in one command

Install via CLI

pnpm dlx shadcn add @harshdev-ui/gooey-tabs

How to use

Give Gooey Tabs a list of tabs — a label and whatever content each should show — and it renders a strip with the panel beneath. Click a tab, or move with the arrow keys; the pill pours across and the panel comes with it.

The whole effect is two plain rectangles — the pill and the panel — sharing one SVG goo filter. Where they meet the filter welds them into a neck that stretches as the pill travels and snaps once it arrives, and the same filter rounds every corner, so no border-radius is declared anywhere. Sliding is one CSS transform transition, so nothing animates in JavaScript.

Basic usage

import { GooeyTabs } from "@/components/harsh-ui/gooey-tabs";

const tabs = [
  { label: "Forest", content: "Old pines and a soft path underfoot.", image: "/tabs/forest.jpg" },
  { label: "Ocean", content: "Cold water and a long swell.", image: "/tabs/ocean.jpg" },
  { label: "Desert", content: "Dunes that rewrite themselves overnight.", image: "/tabs/desert.jpg" },
];

const Demo = () => <GooeyTabs tabs={tabs} className="w-full max-w-xl" />;

Thicker goo, slower pour

// a longer neck that takes its time before snapping
<GooeyTabs tabs={tabs} gooeyness={13} effect="elastic" speed={0.7} tabHeight={56} />

Your palette

<GooeyTabs
  tabs={tabs}
  surface="#ffffff"
  frame="#0d0d0f"
  accent="#0d0d0f"
/>

Demo

import { GooeyTabsDemo } from "@/components/demos/gooey-tabs-demo";

const Demo = () => (
  <div className="h-screen w-full">
    <GooeyTabsDemo />
  </div>
);

Props

tabs{ label, content?, image? }[] — the strip, the copy each tab shows, and an optional image rendered beside it. Required.
defaultIndexWhich tab is open on first render. Default 0.
gooeynessBlur fed to the goo ramp — how far the neck stretches before it snaps. 0 turns the fusing off entirely. Default 8.
tabHeightHeight of the tab strip in px, which is also where the panel starts. Default 48.
effect / speed"spring" (default), "smooth", "snappy" or "elastic" — each carries its own curve, timing and how hard the pill stretches in flight — with speed as a 0.5×–2× multiplier on that timing.
surface / frame / accentThe pill and panel colour, the plate behind everything, and the active label colour.
glassFrost the plate and drop the goo to a translucent white, so the tabs read as liquid glass over whatever sits behind them. Default false.
autoPlayStep through the tabs on a timer — for previews and hands-off displays.

Notes

  • The pill and the panel are two untouched rectangles — no border-radius anywhere. Blurring their alpha and slamming it through a steep feColorMatrix ramp rounds every corner and welds the two shapes wherever they overlap, which is the whole effect.
  • feComposite with operator="atop" paints the original crisp rectangles back over the gooey result, so the pill and panel keep hard edges while only the join between them stays liquid.
  • The pill stretches while it travels — a scaleX kick that settles on arrival — which is what drags the goo into a visible neck rather than sliding cleanly under it. Both that and the slide are one composited CSS transform; nothing animates in JavaScript.
  • The filter id comes from useId(), so two sets of tabs on one page can't both answer to the same #goo and cancel each other out.
  • Proper tablist semantics rather than the radio-input trick the effect is usually built on: role=tablist/tab/tabpanel, aria-selected, roving tabindex and arrow-key navigation.
  • The goo ramp binarises alpha, so a translucent fill would just be pushed back to solid. Glass mode instead frosts the plate with backdrop-filter and drops the opacity of the filtered result, which keeps the gooey silhouette while letting the background through.
  • Only the weld is filtered. The goo runs in a shallow band across the tab strip while the rest of the panel is a plain rectangle of the same colour, so sliding never re-rasterises the whole card — which is what an SVG filter over a full-height panel with an image in it would otherwise cost every frame.
  • Inactive labels pick their own colour from the frame's lightness, so a pale frame gets dark labels and a dark one gets light labels without another prop.
  • Under prefers-reduced-motion the pill jumps straight to the tab instead of pouring.
  • The goo technique is a long-standing CSS trick — the tab arrangement here follows a CodePen by lecomtejeanbaptiste. The React port, the tablist semantics, the configurable geometry and the palette props are this version's.

Source code

The complete gooey-tabs.tsx — toggle TSX / JSX to copy it in either language.

Source code

"use client";
import { useCallback, useEffect, useId, useRef, useState, type ReactNode } from "react";

/**
 * Harsh Dev UI · Developed by Harsh Pandav
 * https://harshpandav.dev · hello@harshpandav.dev
 *
 * GooeyTabs
 * Tabs that pour. The active pill and the panel beneath it are two plain
 * rectangles living inside one SVG goo filter, so where they meet the filter
 * fuses them into a liquid neck that stretches and snaps as the pill slides —
 * and the same filter is what rounds their corners, so no radius is declared
 * anywhere. Sliding is a single CSS transform transition: no animation loop,
 * no per-frame work, nothing for React to re-render but the panel copy.
 */

export type GooeyTabsEffect = "spring" | "smooth" | "snappy" | "elastic";

/** each effect carries its own curve, base timing and how hard the pill stretches */
const EFFECTS: Record<GooeyTabsEffect, { ease: string; ms: number; pull: number }> = {
  spring: { ease: "cubic-bezier(.34,1.32,.5,1)", ms: 420, pull: 1.16 },
  smooth: { ease: "cubic-bezier(.22,1,.36,1)", ms: 380, pull: 1.07 },
  snappy: { ease: "cubic-bezier(.5,0,.2,1)", ms: 230, pull: 1.22 },
  elastic: { ease: "cubic-bezier(.5,1.9,.4,1)", ms: 560, pull: 1.3 },
};

export type GooeyTab = {
  label: string;
  content?: ReactNode;
  image?: string;
};

export type GooeyTabsProps = {
  tabs: GooeyTab[];
  defaultIndex?: number;
  gooeyness?: number;
  tabHeight?: number;
  surface?: string;
  frame?: string;
  accent?: string;
  glass?: boolean;
  effect?: GooeyTabsEffect;
  /** 0.5\u00d7\u20132\u00d7 multiplier on the effect\u0027s own timing */
  speed?: number;
  autoPlay?: boolean;
  className?: string;
};

const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));

/** rough perceptual lightness, for picking a legible label colour on any frame */
const luma = (hex: string) => {
  const h = hex.replace("#", "");
  const f = h.length === 3 ? h.split("").map((c) => c + c).join("") : h;
  const n = parseInt(f, 16);
  if (Number.isNaN(n) || f.length !== 6) return 0;
  return (0.299 * ((n >> 16) & 255) + 0.587 * ((n >> 8) & 255) + 0.114 * (n & 255)) / 255;
};

const alpha = (hex: string, a: number) => {
  const h = hex.replace("#", "");
  const f = h.length === 3 ? h.split("").map((c) => c + c).join("") : h;
  const n = parseInt(f, 16);
  if (Number.isNaN(n) || f.length !== 6) return hex;
  return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${a})`;
};

export function GooeyTabs({
  tabs,
  defaultIndex = 0,
  gooeyness = 8,
  tabHeight = 48,
  surface = "#f2f6fa",
  frame = "#5d8bb3",
  accent = "#17364f",
  glass = false,
  effect = "spring",
  speed = 1,
  autoPlay = false,
  className = "",
}: GooeyTabsProps) {
  const N = tabs.length;
  const fx = EFFECTS[effect] ?? EFFECTS.spring;
  const duration = Math.round(fx.ms / clamp(speed, 0.4, 2.4));
  // how far past the strip the filter has to reach for the weld to resolve
  const weld = Math.round(gooeyness * 2);
  const band = tabHeight + Math.round(gooeyness * 4);
  const radius = Math.round(gooeyness * 1.5);
  const idle = luma(frame) > 0.58 ? alpha(accent, 0.5) : "rgba(255,255,255,0.85)";
  const [active, setActive] = useState(clamp(defaultIndex, 0, N - 1));
  const [moving, setMoving] = useState(false);
  const [reduce, setReduce] = useState(false);
  const gooId = `goo-${useId().replace(/:/g, "")}`;
  const stripRef = useRef<HTMLDivElement>(null);
  const stretch = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);

  useEffect(() => {
    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
    const update = () => setReduce(mq.matches);
    update();
    mq.addEventListener("change", update);
    return () => mq.removeEventListener("change", update);
  }, []);

  useEffect(() => () => clearTimeout(stretch.current), []);
  const bump = useCallback(() => {
    if (reduce) return;
    setMoving(true);
    clearTimeout(stretch.current);
    stretch.current = setTimeout(() => setMoving(false), duration * 0.5);
  }, [duration, reduce]);

  const select = useCallback(
    (i: number) => {
      setActive(i);
      bump();
    },
    [bump],
  );

  useEffect(() => {
    if (!autoPlay) return;
    const id = setInterval(() => {
      setActive((i) => (i + 1) % N);
      bump();
    }, 2200);
    return () => clearInterval(id);
  }, [autoPlay, N, bump]);

  const onKey = useCallback(
    (e: React.KeyboardEvent) => {
      const d = e.key === "ArrowRight" ? 1 : e.key === "ArrowLeft" ? -1 : 0;
      if (!d) return;
      e.preventDefault();
      const next = (active + d + N) % N;
      select(next);
      stripRef.current?.querySelectorAll("button")[next]?.focus();
    },
    [active, N, select],
  );

  const slide = reduce
    ? "none"
    : `translate ${duration}ms ${fx.ease}, scale ${Math.round(duration * 0.42)}ms cubic-bezier(.3,0,.2,1)`;

  return (
    <div className={`relative isolate ${className}`} style={{ color: accent }}>
      <svg aria-hidden className="absolute h-0 w-0">
        <defs>
          <filter id={gooId} colorInterpolationFilters="sRGB">
            <feGaussianBlur in="SourceGraphic" stdDeviation={gooeyness} result="b" />
            <feColorMatrix
              in="b"
              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>

      <span
        aria-hidden
        className="pointer-events-none absolute -inset-4 rounded-3xl"
        style={
          glass
            ? {
                background: alpha(frame, 0.28),
                backdropFilter: "blur(22px) saturate(1.7)",
                WebkitBackdropFilter: "blur(22px) saturate(1.7)",
                boxShadow:
                  "inset 0 1px 0 rgba(255,255,255,0.35), inset 0 0 0 1px rgba(255,255,255,0.2), 0 24px 60px -24px rgba(0,0,0,0.55)",
              }
            : { background: frame }
        }
      />

      <span aria-hidden className="pointer-events-none absolute inset-0" style={{ opacity: glass ? 0.46 : 1 }}>
        {/* Only the weld needs filtering. Keeping the goo to a shallow band keeps
            the blur off the panel — and off any image in it — so sliding never
            re-rasterises the whole card. */}
        <span
          className="absolute inset-x-0 top-0 overflow-hidden"
          style={{ height: band, filter: `url(#${gooId})`, color: surface }}
        >
          <span className="absolute inset-x-0 bottom-0 bg-current" style={{ top: tabHeight }} />
          <span
            className="absolute left-0 top-0 bg-current"
            style={{
              width: `${100 / N}%`,
              height: tabHeight,
              // separate CSS properties: resetting the stretch must never
              // restart the in-flight slide, which is what made it jolt
              translate: `${active * 100}% 0`,
              scale: `${moving ? fx.pull : 1} 1`,
              transition: slide,
              willChange: "translate, scale",
            }}
          />
        </span>
        <span
          className="absolute inset-x-0 bottom-0 bg-current"
          style={{ top: tabHeight + weld, color: surface, borderRadius: `0 0 ${radius}px ${radius}px` }}
        />
      </span>

      <div
        ref={stripRef}
        role="tablist"
        onKeyDown={onKey}
        className="relative z-10 flex items-stretch"
        style={{ height: tabHeight }}
      >
        {tabs.map((t, i) => (
          <button
            key={t.label}
            type="button"
            role="tab"
            aria-selected={i === active}
            tabIndex={i === active ? 0 : -1}
            onClick={() => select(i)}
            className="group relative flex-1 cursor-pointer text-sm font-medium transition-colors sm:text-base"
            style={{ color: i === active ? accent : idle }}
          >
            <span
              aria-hidden
              className="pointer-events-none absolute inset-1 rounded-xl bg-black/10 opacity-0 transition-opacity group-hover:opacity-100 group-aria-selected:opacity-0"
            />
            <span className="relative">{t.label}</span>
          </button>
        ))}
      </div>

      <div
        role="tabpanel"
        key={active}
        className="relative z-10 p-4 sm:p-5"
        style={{ animation: reduce ? undefined : `goo-in ${duration + 120}ms cubic-bezier(.22,1,.36,1) both` }}
      >
        <p className="text-sm leading-relaxed sm:text-[0.95rem]">{tabs[active]?.content}</p>
        {tabs[active]?.image && (
          <img
            src={tabs[active].image}
            alt=""
            className="mt-4 h-44 w-full rounded-2xl object-cover sm:h-60"
          />
        )}
      </div>

      <style>{`@keyframes goo-in{from{opacity:0;transform:translateY(9px)}to{opacity:1;transform:none}}`}</style>
    </div>
  );
}

Keep in mind

Some components here are recreations of the best interactions out there. I don't claim to be the original creator — this is my attempt to study, replicate, and often add a few extra features. I've tried to credit everyone; if I missed something, let me know.

Contact

Found a bug or an issue? Drop a mail — hello@harshpandav.dev

License & Usage

  • Free to use and modify in personal and commercial projects.
  • Attribution to Harsh Dev UI is appreciated but not required.
  • MIT licensed.

Command Palette

Search for a command to run...