Notch
import { useEffect, useId, useRef, useState } from 'react';
const ICONS = {
home: <path d="M4 10.5 12 4l8 6.5V19a1 1 0 0 1-1 1h-4.5v-5.5h-5V20H5a1 1 0 0 1-1-1z" />,
explore: (
<>
<circle cx="12" cy="12" r="8.5" />
<path d="m15.5 8.5-2 5-5 2 2-5z" />
</>
),
likes: <path d="M12 19.5s-7-4.3-7-9.6A3.9 3.9 0 0 1 12 7.6a3.9 3.9 0 0 1 7 2.3c0 5.3-7 9.6-7 9.6z" />,
profile: (
<>
<circle cx="12" cy="8.5" r="3.5" />
<path d="M5 20c1.2-3.5 4-5 7-5s5.8 1.5 7 5" />
</>
),
};
const TABS = [
{ label: 'Home', icon: ICONS.home, body: 'Fresh work from the people you follow.' },
{ label: 'Explore', icon: ICONS.explore, body: 'Trending tags and new creators this week.' },
{ label: 'Likes', icon: ICONS.likes, body: 'Everything you liked, newest first.' },
{ label: 'Profile', icon: ICONS.profile, body: 'Your posts, followers and settings.' },
];
const W = 260;
const H = 56;
const INSET = 24;
const centre = (i: number) => INSET + ((W - INSET * 2) / TABS.length) * (i + 0.5);
/** The bar, with a smooth dip under the active icon. */
const bar = (x: number) => {
const c = Math.max(centre(0), Math.min(centre(TABS.length - 1), x));
return `M0 16A16 16 0 0 1 16 0H${c - 34}C${c - 20} 0 ${c - 23} 28 ${c} 28S${c + 20} 0 ${c + 34} 0H${W - 16}A16 16 0 0 1 ${W} 16V${H - 16}A16 16 0 0 1 ${W - 16} ${H}H16A16 16 0 0 1 0 ${H - 16}Z`;
};
const css = `
.tabs-notch { width: ${W}px; }
.tabs-notch__panel { min-height: calc(3.1em + 8px); padding: 0 6px 8px; color: light-dark(#50535b, #c6c8cd); font-size: 13px; line-height: 1.55; text-align: center; }
.tabs-notch__bar { position: relative; height: ${H}px; margin-top: 18px; }
.tabs-notch__svg { position: absolute; inset: 0; width: ${W}px; height: ${H}px; overflow: visible; }
.tabs-notch__shape { fill: light-dark(#fbfcfd, #232529); filter: drop-shadow(0 4px 12px light-dark(rgba(0, 0, 0, 0.1), rgba(0, 0, 0, 0.35))); }
.tabs-notch__orb {
position: absolute;
top: -17px;
left: -20px;
width: 40px;
height: 40px;
border-radius: 50%;
background: light-dark(#373a40, #f1f2f3);
color: light-dark(#fbfcfd, #121417);
box-shadow: 0 4px 10px light-dark(rgba(0, 0, 0, 0.16), rgba(0, 0, 0, 0.45));
}
.tabs-notch__orb svg { position: absolute; left: 10px; top: 10px; opacity: 0; transform: scale(0.5); transition: opacity 0.2s, transform 0.35s cubic-bezier(0.3, 1.6, 0.5, 1); }
.tabs-notch__orb svg.tabs-notch__icon--on { opacity: 1; transform: none; transition-delay: 0.12s; }
.tabs-notch__list { position: absolute; inset: 0 ${INSET}px; display: grid; grid-template-columns: repeat(${TABS.length}, 1fr); }
.tabs-notch__tab { position: relative; border: 0; border-radius: 12px; background: none; color: light-dark(#5f626a, #a7abb2); font: inherit; cursor: pointer; transition: color 0.2s; }
.tabs-notch__tab:hover { color: light-dark(#42454c, #e5e6e9); }
.tabs-notch__tab:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: -4px; }
.tabs-notch__icon { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
.tabs-notch__tab .tabs-notch__icon { position: absolute; left: 50%; top: 18px; margin-left: -10px; transition: opacity 0.2s, transform 0.3s cubic-bezier(0.3, 1.5, 0.5, 1); }
.tabs-notch__tab[aria-selected='true'] .tabs-notch__icon { opacity: 0; transform: translateY(-10px) scale(0.6); }
.tabs-notch__name { position: absolute; left: 0; right: 0; bottom: 7px; font-size: 12px; font-weight: 600; color: light-dark(#373a40, #f1f2f3); opacity: 0; transform: translateY(4px); transition: opacity 0.2s, transform 0.3s; }
.tabs-notch__tab[aria-selected='true'] .tabs-notch__name { opacity: 1; transform: none; transition-delay: 0.15s; }
@media (prefers-reduced-motion: reduce) {
.tabs-notch__orb svg, .tabs-notch__tab .tabs-notch__icon, .tabs-notch__name { transition: none !important; }
}
`;
export function NotchTabs() {
const id = useId();
const [sel, setSel] = useState(0);
const [x, setX] = useState(centre(0));
const tabs = useRef<(HTMLButtonElement | null)[]>([]);
const sim = useRef({ x: centre(0), v: 0, raf: 0 });
// The notch and its orb travel on a spring, overshooting a touch before they settle.
useEffect(() => {
const m = sim.current;
const target = centre(sel);
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
m.x = target;
setX(target);
return;
}
let last = performance.now();
const step = (now: number) => {
const dt = Math.min(0.032, (now - last) / 1000);
last = now;
m.v += (-190 * (m.x - target) - 21 * m.v) * dt;
m.x += m.v * dt;
if (Math.abs(m.x - target) < 0.05 && Math.abs(m.v) < 0.5) {
m.x = target;
setX(target);
return;
}
setX(m.x);
m.raf = requestAnimationFrame(step);
};
cancelAnimationFrame(m.raf);
m.raf = requestAnimationFrame(step);
return () => cancelAnimationFrame(m.raf);
}, [sel]);
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-notch">
{TABS.map((t, i) => (
<div key={t.label} className="tabs-notch__panel" role="tabpanel" id={`${id}-panel-${i}`} aria-labelledby={`${id}-tab-${i}`} hidden={sel !== i}>
{t.body}
</div>
))}
<div className="tabs-notch__bar">
<svg className="tabs-notch__svg" aria-hidden="true">
<path className="tabs-notch__shape" d={bar(x)} />
</svg>
<span className="tabs-notch__orb" style={{ transform: `translateX(${x}px)` }} aria-hidden="true">
{TABS.map((t, i) => (
<svg key={t.label} className={`tabs-notch__icon${sel === i ? ' tabs-notch__icon--on' : ''}`} viewBox="0 0 24 24">
{t.icon}
</svg>
))}
</span>
<div className="tabs-notch__list" role="tablist" aria-label="App" onKeyDown={onKeyDown}>
{TABS.map((t, i) => (
<button
key={t.label}
ref={(el) => {
tabs.current[i] = el;
}}
className="tabs-notch__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)}
>
<svg className="tabs-notch__icon" viewBox="0 0 24 24" aria-hidden="true">
{t.icon}
</svg>
<span className="tabs-notch__name">{t.label}</span>
</button>
))}
</div>
</div>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initTabsNotch(el) on the mounted element instead.
<div class="tabs-notch">
<div class="tabs-notch__panel" role="tabpanel" id="tabs-notch-panel-0" aria-labelledby="tabs-notch-tab-0">Fresh work from the people you follow.</div>
<div class="tabs-notch__panel" role="tabpanel" id="tabs-notch-panel-1" aria-labelledby="tabs-notch-tab-1" hidden="">Trending tags and new creators this week.</div>
<div class="tabs-notch__panel" role="tabpanel" id="tabs-notch-panel-2" aria-labelledby="tabs-notch-tab-2" hidden="">Everything you liked, newest first.</div>
<div class="tabs-notch__panel" role="tabpanel" id="tabs-notch-panel-3" aria-labelledby="tabs-notch-tab-3" hidden="">Your posts, followers and settings.</div>
<div class="tabs-notch__bar">
<svg class="tabs-notch__svg" aria-hidden="true">
<path class="tabs-notch__shape" d="M0 16A16 16 0 0 1 16 0H16.5C30.5 0 27.5 28 50.5 28S70.5 0 84.5 0H244A16 16 0 0 1 260 16V40A16 16 0 0 1 244 56H16A16 16 0 0 1 0 40Z"></path>
</svg>
<span class="tabs-notch__orb" style="transform:translateX(50.5px)" aria-hidden="true">
<svg class="tabs-notch__icon tabs-notch__icon--on" viewBox="0 0 24 24">
<path d="M4 10.5 12 4l8 6.5V19a1 1 0 0 1-1 1h-4.5v-5.5h-5V20H5a1 1 0 0 1-1-1z"></path>
</svg>
<svg class="tabs-notch__icon" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="8.5"></circle>
<path d="m15.5 8.5-2 5-5 2 2-5z"></path>
</svg>
<svg class="tabs-notch__icon" viewBox="0 0 24 24">
<path d="M12 19.5s-7-4.3-7-9.6A3.9 3.9 0 0 1 12 7.6a3.9 3.9 0 0 1 7 2.3c0 5.3-7 9.6-7 9.6z"></path>
</svg>
<svg class="tabs-notch__icon" viewBox="0 0 24 24">
<circle cx="12" cy="8.5" r="3.5"></circle>
<path d="M5 20c1.2-3.5 4-5 7-5s5.8 1.5 7 5"></path>
</svg>
</span>
<div class="tabs-notch__list" role="tablist" aria-label="App">
<button class="tabs-notch__tab" type="button" role="tab" id="tabs-notch-tab-0" aria-selected="true" aria-controls="tabs-notch-panel-0" tabindex="0">
<svg class="tabs-notch__icon" viewBox="0 0 24 24" aria-hidden="true">
<path d="M4 10.5 12 4l8 6.5V19a1 1 0 0 1-1 1h-4.5v-5.5h-5V20H5a1 1 0 0 1-1-1z"></path>
</svg>
<span class="tabs-notch__name">Home</span>
</button>
<button class="tabs-notch__tab" type="button" role="tab" id="tabs-notch-tab-1" aria-selected="false" aria-controls="tabs-notch-panel-1" tabindex="-1">
<svg class="tabs-notch__icon" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="8.5"></circle>
<path d="m15.5 8.5-2 5-5 2 2-5z"></path>
</svg>
<span class="tabs-notch__name">Explore</span>
</button>
<button class="tabs-notch__tab" type="button" role="tab" id="tabs-notch-tab-2" aria-selected="false" aria-controls="tabs-notch-panel-2" tabindex="-1">
<svg class="tabs-notch__icon" viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 19.5s-7-4.3-7-9.6A3.9 3.9 0 0 1 12 7.6a3.9 3.9 0 0 1 7 2.3c0 5.3-7 9.6-7 9.6z"></path>
</svg>
<span class="tabs-notch__name">Likes</span>
</button>
<button class="tabs-notch__tab" type="button" role="tab" id="tabs-notch-tab-3" aria-selected="false" aria-controls="tabs-notch-panel-3" tabindex="-1">
<svg class="tabs-notch__icon" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="8.5" r="3.5"></circle>
<path d="M5 20c1.2-3.5 4-5 7-5s5.8 1.5 7 5"></path>
</svg>
<span class="tabs-notch__name">Profile</span>
</button>
</div>
</div>
</div>.tabs-notch { width: 260px; }
.tabs-notch__panel { min-height: calc(3.1em + 8px); padding: 0 6px 8px; color: light-dark(#50535b, #c6c8cd); font-size: 13px; line-height: 1.55; text-align: center; }
.tabs-notch__bar { position: relative; height: 56px; margin-top: 18px; }
.tabs-notch__svg { position: absolute; inset: 0; width: 260px; height: 56px; overflow: visible; }
.tabs-notch__shape { fill: light-dark(#fbfcfd, #232529); filter: drop-shadow(0 4px 12px light-dark(rgba(0, 0, 0, 0.1), rgba(0, 0, 0, 0.35))); }
.tabs-notch__orb {
position: absolute;
top: -17px;
left: -20px;
width: 40px;
height: 40px;
border-radius: 50%;
background: light-dark(#373a40, #f1f2f3);
color: light-dark(#fbfcfd, #121417);
box-shadow: 0 4px 10px light-dark(rgba(0, 0, 0, 0.16), rgba(0, 0, 0, 0.45));
}
.tabs-notch__orb svg { position: absolute; left: 10px; top: 10px; opacity: 0; transform: scale(0.5); transition: opacity 0.2s, transform 0.35s cubic-bezier(0.3, 1.6, 0.5, 1); }
.tabs-notch__orb svg.tabs-notch__icon--on { opacity: 1; transform: none; transition-delay: 0.12s; }
.tabs-notch__list { position: absolute; inset: 0 24px; display: grid; grid-template-columns: repeat(4, 1fr); }
.tabs-notch__tab { position: relative; border: 0; border-radius: 12px; background: none; color: light-dark(#5f626a, #a7abb2); font: inherit; cursor: pointer; transition: color 0.2s; }
.tabs-notch__tab:hover { color: light-dark(#42454c, #e5e6e9); }
.tabs-notch__tab:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: -4px; }
.tabs-notch__icon { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
.tabs-notch__tab .tabs-notch__icon { position: absolute; left: 50%; top: 18px; margin-left: -10px; transition: opacity 0.2s, transform 0.3s cubic-bezier(0.3, 1.5, 0.5, 1); }
.tabs-notch__tab[aria-selected='true'] .tabs-notch__icon { opacity: 0; transform: translateY(-10px) scale(0.6); }
.tabs-notch__name { position: absolute; left: 0; right: 0; bottom: 7px; font-size: 12px; font-weight: 600; color: light-dark(#373a40, #f1f2f3); opacity: 0; transform: translateY(4px); transition: opacity 0.2s, transform 0.3s; }
.tabs-notch__tab[aria-selected='true'] .tabs-notch__name { opacity: 1; transform: none; transition-delay: 0.15s; }
@media (prefers-reduced-motion: reduce) {
.tabs-notch__orb svg, .tabs-notch__tab .tabs-notch__icon, .tabs-notch__name { transition: none !important; }
}function initTabsNotch(root) {
var W = 260, H = 56, INSET = 24;
var list = root.querySelector('[role="tablist"]');
var tabs = Array.prototype.slice.call(list.querySelectorAll('[role="tab"]'));
var panels = root.querySelectorAll('[role="tabpanel"]');
var shape = root.querySelector('.tabs-notch__shape');
var orb = root.querySelector('.tabs-notch__orb');
var orbIcons = orb.querySelectorAll('svg');
var sel = tabs.findIndex(function (tab) { return tab.getAttribute('aria-selected') === 'true'; });
function centre(i) { return INSET + ((W - INSET * 2) / tabs.length) * (i + 0.5); }
var m = { x: centre(sel), v: 0, raf: 0 };
/* The bar, with a smooth dip under the active icon. */
function bar(x) {
var c = Math.max(centre(0), Math.min(centre(tabs.length - 1), x));
return 'M0 16A16 16 0 0 1 16 0H' + (c - 34) + 'C' + (c - 20) + ' 0 ' + (c - 23) + ' 28 ' + c + ' 28S' + (c + 20) + ' 0 ' + (c + 34) + ' 0H' + (W - 16) +
'A16 16 0 0 1 ' + W + ' 16V' + (H - 16) + 'A16 16 0 0 1 ' + (W - 16) + ' ' + H + 'H16A16 16 0 0 1 0 ' + (H - 16) + 'Z';
}
function draw() {
shape.setAttribute('d', bar(m.x));
orb.style.transform = 'translateX(' + m.x + 'px)';
}
// The notch and its orb travel on a spring, overshooting a touch before they settle.
function travel() {
var target = centre(sel);
cancelAnimationFrame(m.raf);
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
m.x = target;
draw();
return;
}
var last = performance.now();
function step(now) {
var dt = Math.min(0.032, (now - last) / 1000);
last = now;
m.v += (-190 * (m.x - target) - 21 * m.v) * dt;
m.x += m.v * dt;
if (Math.abs(m.x - target) < 0.05 && Math.abs(m.v) < 0.5) {
m.x = target;
draw();
return;
}
draw();
m.raf = requestAnimationFrame(step);
}
m.raf = requestAnimationFrame(step);
}
function select(i) {
if (i === sel) return;
sel = 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; });
orbIcons.forEach(function (icon, k) { icon.classList.toggle('tabs-notch__icon--on', k === i); });
travel();
}
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 () {
cancelAnimationFrame(m.raf);
list.removeEventListener('click', onClick);
list.removeEventListener('keydown', onKeyDown);
};
}
document.querySelectorAll('.tabs-notch').forEach(initTabsNotch);Goal: Create a "Notch" tab set.
Visual behavior: A bottom bar with a notch cut under the active icon, which rises into a floating circle above the bar; pick another and the notch flows along to it.
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 bar is one SVG path with a smooth dip made of two mirrored curves, rebuilt every frame as a spring moves the dip's centre; the circle rides the same value.
- In the bar, the active icon shrinks away and its name appears under the circle, while the circle swaps to the matching icon.
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 notch moves straight to the new tab.
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 "Notch" tab set.
Visual behavior: A bottom bar with a notch cut under the active icon, which rises into a floating circle above the bar; pick another and the notch flows along to it.
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 bar is one SVG path with a smooth dip made of two mirrored curves, rebuilt every frame as a spring moves the dip's centre; the circle rides the same value.
- In the bar, the active icon shrinks away and its name appears under the circle, while the circle swaps to the matching icon.
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 notch moves straight to the new tab.
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 bottom bar with a notch cut under the active icon, which rises into a floating circle above the bar; pick another and the notch flows along to it.
- Dependencies
- None
- Difficulty
- Advanced
- Triggers
- click, state change, spring, icon
- Techniques
- svg, spring, raf
- 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 notch moves straight to the new tab.