Split
import { useId, useRef, useState } from 'react';
const TABS = [
{ label: 'Day', body: 'Six meetings, two of them back to back after lunch.' },
{ label: 'Week', body: 'A lighter week: Thursday is clear from noon onwards.' },
{ label: 'Month', body: 'Three trips and the quarterly review on the 24th.' },
{ label: 'Year', body: 'Twelve releases planned, four already shipped.' },
];
const R = 12;
const GAP = 3;
const EASE = '0.45s cubic-bezier(0.3, 1.25, 0.5, 1)';
const css = `
.tabs-split { width: 264px; }
.tabs-split__list { display: grid; grid-template-columns: repeat(${TABS.length}, 1fr); height: 38px; }
.tabs-split__tab { position: relative; border: 0; padding: 0; background: none; font: inherit; font-size: 13px; font-weight: 500; color: light-dark(#50535b, #c6c8cd); cursor: pointer; outline: none; transition: color 0.3s; }
.tabs-split__tab:hover { color: light-dark(#373a40, #f1f2f3); }
.tabs-split__seg {
position: absolute;
top: 0;
bottom: 0;
background: light-dark(#ebedf0, #232529);
transition: left ${EASE}, right ${EASE}, top ${EASE}, bottom ${EASE}, border-radius ${EASE}, background-color 0.3s, box-shadow 0.3s;
}
.tabs-split__tab[aria-selected='true'] { color: light-dark(#fbfcfd, #121417); }
.tabs-split__tab[aria-selected='true'] .tabs-split__seg {
top: -2px;
bottom: -2px;
background: light-dark(#373a40, #f1f2f3);
box-shadow: 0 2px 6px light-dark(rgba(0, 0, 0, 0.14), rgba(0, 0, 0, 0.45));
}
.tabs-split__tab:focus-visible .tabs-split__seg { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 2px; }
.tabs-split__label { position: relative; }
.tabs-split__panel { padding: 16px 4px 0; min-height: calc(3.1em + 16px); color: light-dark(#50535b, #c6c8cd); font-size: 13px; line-height: 1.55; }
@media (prefers-reduced-motion: reduce) {
.tabs-split__seg { transition: background-color 0.2s; }
}
`;
export function SplitTabs() {
const id = useId();
const [sel, setSel] = useState(1);
const tabs = useRef<(HTMLButtonElement | null)[]>([]);
const n = TABS.length;
const onKeyDown = (e: React.KeyboardEvent) => {
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-split">
<div className="tabs-split__list" role="tablist" aria-label="Calendar range" onKeyDown={onKeyDown}>
{TABS.map((t, i) => {
// Square, touching edges make one bar; any edge next to the active tab pulls back and rounds off.
const open = (j: number) => j === sel || j < 0 || j >= n;
const l = i === sel || i - 1 === sel ? GAP : 0;
const r = i === sel || i + 1 === sel ? GAP : 0;
const left = open(i - 1) || i === sel ? R : 0;
const right = open(i + 1) || i === sel ? R : 0;
return (
<button
key={t.label}
ref={(el) => {
tabs.current[i] = el;
}}
className="tabs-split__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)}
>
<span className="tabs-split__seg" style={{ left: l, right: r, borderRadius: `${left}px ${right}px ${right}px ${left}px` }} aria-hidden="true" />
<span className="tabs-split__label">{t.label}</span>
</button>
);
})}
</div>
{TABS.map((t, i) => (
<div key={t.label} className="tabs-split__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 initTabsSplit(el) on the mounted element instead.
<div class="tabs-split">
<div class="tabs-split__list" role="tablist" aria-label="Calendar range">
<button class="tabs-split__tab" type="button" role="tab" id="tabs-split-tab-0" aria-selected="false" aria-controls="tabs-split-panel-0" tabindex="-1">
<span class="tabs-split__seg" style="left:0;right:3px;border-radius:12px 12px 12px 12px" aria-hidden="true"></span>
<span class="tabs-split__label">Day</span>
</button>
<button class="tabs-split__tab" type="button" role="tab" id="tabs-split-tab-1" aria-selected="true" aria-controls="tabs-split-panel-1" tabindex="0">
<span class="tabs-split__seg" style="left:3px;right:3px;border-radius:12px 12px 12px 12px" aria-hidden="true"></span>
<span class="tabs-split__label">Week</span>
</button>
<button class="tabs-split__tab" type="button" role="tab" id="tabs-split-tab-2" aria-selected="false" aria-controls="tabs-split-panel-2" tabindex="-1">
<span class="tabs-split__seg" style="left:3px;right:0;border-radius:12px 0px 0px 12px" aria-hidden="true"></span>
<span class="tabs-split__label">Month</span>
</button>
<button class="tabs-split__tab" type="button" role="tab" id="tabs-split-tab-3" aria-selected="false" aria-controls="tabs-split-panel-3" tabindex="-1">
<span class="tabs-split__seg" style="left:0;right:0;border-radius:0px 12px 12px 0px" aria-hidden="true"></span>
<span class="tabs-split__label">Year</span>
</button>
</div>
<div class="tabs-split__panel" role="tabpanel" id="tabs-split-panel-0" aria-labelledby="tabs-split-tab-0" hidden="">Six meetings, two of them back to back after lunch.</div>
<div class="tabs-split__panel" role="tabpanel" id="tabs-split-panel-1" aria-labelledby="tabs-split-tab-1">A lighter week: Thursday is clear from noon onwards.</div>
<div class="tabs-split__panel" role="tabpanel" id="tabs-split-panel-2" aria-labelledby="tabs-split-tab-2" hidden="">Three trips and the quarterly review on the 24th.</div>
<div class="tabs-split__panel" role="tabpanel" id="tabs-split-panel-3" aria-labelledby="tabs-split-tab-3" hidden="">Twelve releases planned, four already shipped.</div>
</div>.tabs-split { width: 264px; }
.tabs-split__list { display: grid; grid-template-columns: repeat(4, 1fr); height: 38px; }
.tabs-split__tab { position: relative; border: 0; padding: 0; background: none; font: inherit; font-size: 13px; font-weight: 500; color: light-dark(#50535b, #c6c8cd); cursor: pointer; outline: none; transition: color 0.3s; }
.tabs-split__tab:hover { color: light-dark(#373a40, #f1f2f3); }
.tabs-split__seg {
position: absolute;
top: 0;
bottom: 0;
background: light-dark(#ebedf0, #232529);
transition: left 0.45s cubic-bezier(0.3, 1.25, 0.5, 1), right 0.45s cubic-bezier(0.3, 1.25, 0.5, 1), top 0.45s cubic-bezier(0.3, 1.25, 0.5, 1), bottom 0.45s cubic-bezier(0.3, 1.25, 0.5, 1), border-radius 0.45s cubic-bezier(0.3, 1.25, 0.5, 1), background-color 0.3s, box-shadow 0.3s;
}
.tabs-split__tab[aria-selected='true'] { color: light-dark(#fbfcfd, #121417); }
.tabs-split__tab[aria-selected='true'] .tabs-split__seg {
top: -2px;
bottom: -2px;
background: light-dark(#373a40, #f1f2f3);
box-shadow: 0 2px 6px light-dark(rgba(0, 0, 0, 0.14), rgba(0, 0, 0, 0.45));
}
.tabs-split__tab:focus-visible .tabs-split__seg { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 2px; }
.tabs-split__label { position: relative; }
.tabs-split__panel { padding: 16px 4px 0; min-height: calc(3.1em + 16px); color: light-dark(#50535b, #c6c8cd); font-size: 13px; line-height: 1.55; }
@media (prefers-reduced-motion: reduce) {
.tabs-split__seg { transition: background-color 0.2s; }
}function initTabsSplit(root) {
var R = 12;
var GAP = 3;
var list = root.querySelector('[role="tablist"]');
var tabs = Array.prototype.slice.call(list.querySelectorAll('[role="tab"]'));
var panels = root.querySelectorAll('[role="tabpanel"]');
var n = tabs.length;
var sel = tabs.findIndex(function (tab) { return tab.getAttribute('aria-selected') === 'true'; });
function render() {
tabs.forEach(function (tab, i) {
// Square, touching edges make one bar; any edge next to the active tab pulls back and rounds off.
var open = function (j) { return j === sel || j < 0 || j >= n; };
var left = open(i - 1) || i === sel ? R : 0;
var right = open(i + 1) || i === sel ? R : 0;
var seg = tab.querySelector('.tabs-split__seg');
seg.style.left = (i === sel || i - 1 === sel ? GAP : 0) + 'px';
seg.style.right = (i === sel || i + 1 === sel ? GAP : 0) + 'px';
seg.style.borderRadius = left + 'px ' + right + 'px ' + right + 'px ' + left + 'px';
tab.setAttribute('aria-selected', String(i === sel));
tab.tabIndex = i === sel ? 0 : -1;
});
panels.forEach(function (panel, i) { panel.hidden = i !== sel; });
}
function onClick(e) {
var tab = e.target.closest('[role="tab"]');
if (!tab) return;
sel = tabs.indexOf(tab);
render();
}
function onKeyDown(e) {
var next = { ArrowRight: (sel + 1) % n, ArrowLeft: (sel + n - 1) % n, Home: 0, End: n - 1 }[e.key];
if (next === undefined) return;
e.preventDefault();
sel = next;
render();
tabs[next].focus();
}
list.addEventListener('click', onClick);
list.addEventListener('keydown', onKeyDown);
return function () {
list.removeEventListener('click', onClick);
list.removeEventListener('keydown', onKeyDown);
};
}
document.querySelectorAll('.tabs-split').forEach(initTabsSplit);Goal: Create a "Split" tab set.
Visual behavior: A segmented control that is one continuous bar until you pick: the active tab parts from it, with gaps opening either side and its corners rounding off into a pill of its own.
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.
- Each tab holds a background segment with left and right insets and four corner radii. Touching, square edges make one bar; the active segment and the edges of its neighbours that face it pull back 3px and round off, and the active one grows slightly taller.
- All of that is transitioned, with a little overshoot, so the bar closes up behind the tab you left as it splits around the new one.
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 bar splits and joins without moving.
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 "Split" tab set.
Visual behavior: A segmented control that is one continuous bar until you pick: the active tab parts from it, with gaps opening either side and its corners rounding off into a pill of its own.
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.
- Each tab holds a background segment with left and right insets and four corner radii. Touching, square edges make one bar; the active segment and the edges of its neighbours that face it pull back 3px and round off, and the active one grows slightly taller.
- All of that is transitioned, with a little overshoot, so the bar closes up behind the tab you left as it splits around the new one.
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 bar splits and joins without moving.
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 that is one continuous bar until you pick: the active tab parts from it, with gaps opening either side and its corners rounding off into a pill of its own.
- Dependencies
- None
- Difficulty
- Beginner
- Triggers
- click, state change, morph, spring
- Techniques
- css transition, border radius
- 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 bar splits and joins without moving.