Goo
import { useId, useRef, useState } from 'react';
const TABS = [
{ label: 'Daily', body: '4,210 visits today, 8% more than the same day last week.' },
{ label: 'Weekly', body: '27,940 visits this week, with Tuesday the busiest day.' },
{ label: 'Monthly', body: '112,300 visits in October, a new record for the site.' },
];
const css = `
.tabs-goo { width: 260px; }
.tabs-goo__list { position: relative; display: grid; grid-template-columns: repeat(3, 1fr); padding: 4px; border-radius: 20px; background: light-dark(#f3f4f7, #1a1c1f); box-shadow: inset 0 0 0 1px light-dark(#ebedf0, #232529); }
.tabs-goo__liquid { position: absolute; inset: 4px; pointer-events: none; }
.tabs-goo__blob {
position: absolute;
top: 0;
bottom: 0;
left: calc(var(--tabs-goo-i) * 100% / 3);
width: calc(100% / 3);
border-radius: 16px;
background: light-dark(#373a40, #f1f2f3);
transition: left 0.6s cubic-bezier(0.5, 0, 0.15, 1);
}
/* Two thinner drops trail the pill; the filter joins all three into one stretching body. */
.tabs-goo__blob:nth-child(2) { top: 4px; bottom: 4px; transition-delay: 0.07s; }
.tabs-goo__blob:nth-child(3) { top: 8px; bottom: 8px; transition-delay: 0.14s; }
.tabs-goo__tab { position: relative; height: 32px; border: 0; border-radius: 16px; background: none; color: light-dark(#5f626a, #a7abb2); font: inherit; font-size: 13px; font-weight: 500; cursor: pointer; transition: color 0.2s; }
.tabs-goo__tab:hover { color: light-dark(#42454c, #e5e6e9); }
.tabs-goo__tab[aria-selected='true'] { color: light-dark(#fbfcfd, #121417); transition-delay: 0.18s; }
.tabs-goo__tab:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 2px; }
.tabs-goo__panel { padding: 14px 6px 0; min-height: calc(3.1em + 14px); color: light-dark(#50535b, #c6c8cd); font-size: 13px; line-height: 1.55; }
@media (prefers-reduced-motion: reduce) {
.tabs-goo__blob, .tabs-goo__tab { transition: none !important; }
}
`;
export function GooTabs() {
const id = useId();
const filter = `tabs-goo-${id.replace(/[^a-zA-Z0-9]/g, '')}`;
const [sel, setSel] = useState(0);
const tabs = useRef<(HTMLButtonElement | null)[]>([]);
const onKeyDown = (e: React.KeyboardEvent) => {
const n = TABS.length;
const next = { ArrowRight: (sel + 1) % n, ArrowLeft: (sel + n - 1) % n, Home: 0, End: n - 1 }[e.key];
if (next === undefined) return;
e.preventDefault();
setSel(next);
tabs.current[next]?.focus();
};
return (
<>
<style>
{css}
</style>
<div className="tabs-goo" style={{ '--tabs-goo-i': sel } as React.CSSProperties}>
<svg width="0" height="0" style={{ position: 'absolute' }} aria-hidden="true">
<filter id={filter} x="-20%" y="-60%" width="140%" height="220%">
<feGaussianBlur in="SourceGraphic" stdDeviation="5" result="blur" />
<feColorMatrix in="blur" values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 20 -8" />
</filter>
</svg>
<div className="tabs-goo__list" role="tablist" aria-label="Visits" onKeyDown={onKeyDown}>
<span className="tabs-goo__liquid" style={{ filter: `url(#${filter})` }} aria-hidden="true">
<span className="tabs-goo__blob" />
<span className="tabs-goo__blob" />
<span className="tabs-goo__blob" />
</span>
{TABS.map((t, i) => (
<button
key={t.label}
ref={(el) => {
tabs.current[i] = el;
}}
className="tabs-goo__tab"
type="button"
role="tab"
id={`${id}-tab-${i}`}
aria-selected={sel === i}
aria-controls={`${id}-panel-${i}`}
tabIndex={sel === i ? 0 : -1}
onClick={() => setSel(i)}
>
{t.label}
</button>
))}
</div>
{TABS.map((t, i) => (
<div key={t.label} className="tabs-goo__panel" role="tabpanel" id={`${id}-panel-${i}`} aria-labelledby={`${id}-tab-${i}`} hidden={sel !== i}>
{t.body}
</div>
))}
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initTabsGoo(el) on the mounted element instead.
<div class="tabs-goo" style="--tabs-goo-i:0">
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<filter id="tabs-goo-filter" x="-20%" y="-60%" width="140%" height="220%">
<feGaussianBlur in="SourceGraphic" stdDeviation="5" result="blur"></feGaussianBlur>
<feColorMatrix in="blur" values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 20 -8"></feColorMatrix>
</filter>
</svg>
<div class="tabs-goo__list" role="tablist" aria-label="Visits">
<span class="tabs-goo__liquid" style="filter:url(#tabs-goo-filter)" aria-hidden="true">
<span class="tabs-goo__blob"></span>
<span class="tabs-goo__blob"></span>
<span class="tabs-goo__blob"></span>
</span>
<button class="tabs-goo__tab" type="button" role="tab" id="tabs-goo-tab-0" aria-selected="true" aria-controls="tabs-goo-panel-0" tabindex="0">Daily</button>
<button class="tabs-goo__tab" type="button" role="tab" id="tabs-goo-tab-1" aria-selected="false" aria-controls="tabs-goo-panel-1" tabindex="-1">Weekly</button>
<button class="tabs-goo__tab" type="button" role="tab" id="tabs-goo-tab-2" aria-selected="false" aria-controls="tabs-goo-panel-2" tabindex="-1">Monthly</button>
</div>
<div class="tabs-goo__panel" role="tabpanel" id="tabs-goo-panel-0" aria-labelledby="tabs-goo-tab-0">4,210 visits today, 8% more than the same day last week.</div>
<div class="tabs-goo__panel" role="tabpanel" id="tabs-goo-panel-1" aria-labelledby="tabs-goo-tab-1" hidden="">27,940 visits this week, with Tuesday the busiest day.</div>
<div class="tabs-goo__panel" role="tabpanel" id="tabs-goo-panel-2" aria-labelledby="tabs-goo-tab-2" hidden="">112,300 visits in October, a new record for the site.</div>
</div>.tabs-goo { width: 260px; }
.tabs-goo__list { position: relative; display: grid; grid-template-columns: repeat(3, 1fr); padding: 4px; border-radius: 20px; background: light-dark(#f3f4f7, #1a1c1f); box-shadow: inset 0 0 0 1px light-dark(#ebedf0, #232529); }
.tabs-goo__liquid { position: absolute; inset: 4px; pointer-events: none; }
.tabs-goo__blob {
position: absolute;
top: 0;
bottom: 0;
left: calc(var(--tabs-goo-i) * 100% / 3);
width: calc(100% / 3);
border-radius: 16px;
background: light-dark(#373a40, #f1f2f3);
transition: left 0.6s cubic-bezier(0.5, 0, 0.15, 1);
}
/* Two thinner drops trail the pill; the filter joins all three into one stretching body. */
.tabs-goo__blob:nth-child(2) { top: 4px; bottom: 4px; transition-delay: 0.07s; }
.tabs-goo__blob:nth-child(3) { top: 8px; bottom: 8px; transition-delay: 0.14s; }
.tabs-goo__tab { position: relative; height: 32px; border: 0; border-radius: 16px; background: none; color: light-dark(#5f626a, #a7abb2); font: inherit; font-size: 13px; font-weight: 500; cursor: pointer; transition: color 0.2s; }
.tabs-goo__tab:hover { color: light-dark(#42454c, #e5e6e9); }
.tabs-goo__tab[aria-selected='true'] { color: light-dark(#fbfcfd, #121417); transition-delay: 0.18s; }
.tabs-goo__tab:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 2px; }
.tabs-goo__panel { padding: 14px 6px 0; min-height: calc(3.1em + 14px); color: light-dark(#50535b, #c6c8cd); font-size: 13px; line-height: 1.55; }
@media (prefers-reduced-motion: reduce) {
.tabs-goo__blob, .tabs-goo__tab { transition: none !important; }
}function initTabsGoo(root) {
var list = root.querySelector('[role="tablist"]');
var tabs = Array.prototype.slice.call(list.querySelectorAll('[role="tab"]'));
var panels = root.querySelectorAll('[role="tabpanel"]');
var sel = tabs.findIndex(function (tab) { return tab.getAttribute('aria-selected') === 'true'; });
function select(i) {
if (i === sel) return;
sel = i;
// The pill and its two trailing drops all head for the new index; the filter joins them on the way.
root.style.setProperty('--tabs-goo-i', i);
tabs.forEach(function (tab, k) {
tab.setAttribute('aria-selected', String(k === i));
tab.tabIndex = k === i ? 0 : -1;
});
panels.forEach(function (panel, k) { panel.hidden = k !== i; });
}
function onClick(e) {
var tab = e.target.closest('[role="tab"]');
if (tab) select(tabs.indexOf(tab));
}
function onKeyDown(e) {
var n = tabs.length;
var next = { ArrowRight: (sel + 1) % n, ArrowLeft: (sel + n - 1) % n, Home: 0, End: n - 1 }[e.key];
if (next === undefined) return;
e.preventDefault();
select(next);
tabs[next].focus();
}
list.addEventListener('click', onClick);
list.addEventListener('keydown', onKeyDown);
return function () {
list.removeEventListener('click', onClick);
list.removeEventListener('keydown', onKeyDown);
};
}
document.querySelectorAll('.tabs-goo').forEach(initTabsGoo);Goal: Create a "Goo" tab set.
Visual behavior: A segmented control whose pill is liquid: it pours from tab to tab, stretching a thinning bridge behind it before it pulls back into one drop.
Technique:
- The ARIA tabs pattern: a role="tablist" with an aria-label, buttons with role="tab", aria-selected and aria-controls, and role="tabpanel" panels labelled by their tab. Only the selected tab is in the Tab order (roving tabindex); the arrow keys, Home and End move between tabs and select as they go.
- The pill and two thinner drops all move to the selected index, the drops a beat behind. An SVG goo filter (a blur, then a steep alpha curve) on their layer melts them into one body that stretches and pinches as it travels.
- The labels sit outside the filter so they stay crisp, and the selected label switches to the inverse colour as the pill arrives.
Accessibility: Real ARIA tabs: arrow keys, Home and End move between tabs, only the selected tab is in the Tab order, and each panel is labelled by its tab. Decoration is aria-hidden. Under prefers-reduced-motion the pill moves at once.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Goo" tab set.
Visual behavior: A segmented control whose pill is liquid: it pours from tab to tab, stretching a thinning bridge behind it before it pulls back into one drop.
Technique:
- The ARIA tabs pattern: a role="tablist" with an aria-label, buttons with role="tab", aria-selected and aria-controls, and role="tabpanel" panels labelled by their tab. Only the selected tab is in the Tab order (roving tabindex); the arrow keys, Home and End move between tabs and select as they go.
- The pill and two thinner drops all move to the selected index, the drops a beat behind. An SVG goo filter (a blur, then a steep alpha curve) on their layer melts them into one body that stretches and pinches as it travels.
- The labels sit outside the filter so they stay crisp, and the selected label switches to the inverse colour as the pill arrives.
Accessibility: Real ARIA tabs: arrow keys, Home and End move between tabs, only the selected tab is in the Tab order, and each panel is labelled by its tab. Decoration is aria-hidden. Under prefers-reduced-motion the pill moves at once.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
About this component
A segmented control whose pill is liquid: it pours from tab to tab, stretching a thinning bridge behind it before it pulls back into one drop.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- click, state change, morph
- Techniques
- svg filter, css variables, css transition
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
Real ARIA tabs: arrow keys, Home and End move between tabs, only the selected tab is in the Tab order, and each panel is labelled by its tab. Decoration is aria-hidden. Under prefers-reduced-motion the pill moves at once.