Ribbon
import { useId, useRef, useState } from 'react';
const TABS = [
{ label: 'Saved', body: 'Twenty-three recipes you marked to cook later.' },
{ label: 'Cooked', body: 'Nine you made, with your own notes on each.' },
{ label: 'Shared', body: 'Four you sent to friends this month.' },
];
const css = `
.tabs-ribbon { width: 260px; }
.tabs-ribbon__list { display: grid; grid-template-columns: repeat(3, 1fr); border-top: 2px solid light-dark(#cbced3, #393c41); }
.tabs-ribbon__tab { position: relative; overflow: hidden; padding: 30px 0 8px; border: 0; border-radius: 0 0 8px 8px; background: none; color: light-dark(#5f626a, #a7abb2); font: inherit; font-size: 13px; font-weight: 600; cursor: pointer; transition: color 0.25s; }
.tabs-ribbon__tab:hover { color: light-dark(#42454c, #e5e6e9); }
.tabs-ribbon__tab[aria-selected='true'] { color: light-dark(#373a40, #f1f2f3); }
.tabs-ribbon__tab:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: -2px; }
.tabs-ribbon__mark {
position: absolute;
left: 50%;
top: 0;
width: 14px;
height: 24px;
margin-left: -7px;
background: linear-gradient(90deg, light-dark(#42454c, #e5e6e9), light-dark(#373a40, #f1f2f3) 40%, light-dark(#42454c, #e5e6e9));
clip-path: polygon(0 0, 100% 0, 100% 100%, 50% 74%, 0 100%);
transform: translateY(-105%);
transition: transform 0.3s cubic-bezier(0.5, 0, 0.75, 0);
}
.tabs-ribbon__tab[aria-selected='true'] .tabs-ribbon__mark { transform: translateY(0); transition: transform 0.55s cubic-bezier(0.3, 1.6, 0.5, 1) 0.2s; }
.tabs-ribbon__panel { padding: 12px 4px 0; min-height: calc(3.1em + 14px); color: light-dark(#50535b, #c6c8cd); font-size: 13px; line-height: 1.55; text-align: center; }
@media (prefers-reduced-motion: reduce) {
.tabs-ribbon__mark { transition: none !important; }
}
`;
export function RibbonTabs() {
const id = useId();
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-ribbon">
<div className="tabs-ribbon__list" role="tablist" aria-label="Recipes" onKeyDown={onKeyDown}>
{TABS.map((t, i) => (
<button
key={t.label}
ref={(el) => {
tabs.current[i] = el;
}}
className="tabs-ribbon__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-ribbon__mark" aria-hidden="true" />
{t.label}
</button>
))}
</div>
{TABS.map((t, i) => (
<div key={t.label} className="tabs-ribbon__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 initTabsRibbon(el) on the mounted element instead.
<div class="tabs-ribbon">
<div class="tabs-ribbon__list" role="tablist" aria-label="Recipes">
<button class="tabs-ribbon__tab" type="button" role="tab" id="tabs-ribbon-tab-0" aria-selected="true" aria-controls="tabs-ribbon-panel-0" tabindex="0"><span class="tabs-ribbon__mark" aria-hidden="true"></span>Saved</button>
<button class="tabs-ribbon__tab" type="button" role="tab" id="tabs-ribbon-tab-1" aria-selected="false" aria-controls="tabs-ribbon-panel-1" tabindex="-1"><span class="tabs-ribbon__mark" aria-hidden="true"></span>Cooked</button>
<button class="tabs-ribbon__tab" type="button" role="tab" id="tabs-ribbon-tab-2" aria-selected="false" aria-controls="tabs-ribbon-panel-2" tabindex="-1"><span class="tabs-ribbon__mark" aria-hidden="true"></span>Shared</button>
</div>
<div class="tabs-ribbon__panel" role="tabpanel" id="tabs-ribbon-panel-0" aria-labelledby="tabs-ribbon-tab-0">Twenty-three recipes you marked to cook later.</div>
<div class="tabs-ribbon__panel" role="tabpanel" id="tabs-ribbon-panel-1" aria-labelledby="tabs-ribbon-tab-1" hidden="">Nine you made, with your own notes on each.</div>
<div class="tabs-ribbon__panel" role="tabpanel" id="tabs-ribbon-panel-2" aria-labelledby="tabs-ribbon-tab-2" hidden="">Four you sent to friends this month.</div>
</div>.tabs-ribbon { width: 260px; }
.tabs-ribbon__list { display: grid; grid-template-columns: repeat(3, 1fr); border-top: 2px solid light-dark(#cbced3, #393c41); }
.tabs-ribbon__tab { position: relative; overflow: hidden; padding: 30px 0 8px; border: 0; border-radius: 0 0 8px 8px; background: none; color: light-dark(#5f626a, #a7abb2); font: inherit; font-size: 13px; font-weight: 600; cursor: pointer; transition: color 0.25s; }
.tabs-ribbon__tab:hover { color: light-dark(#42454c, #e5e6e9); }
.tabs-ribbon__tab[aria-selected='true'] { color: light-dark(#373a40, #f1f2f3); }
.tabs-ribbon__tab:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: -2px; }
.tabs-ribbon__mark {
position: absolute;
left: 50%;
top: 0;
width: 14px;
height: 24px;
margin-left: -7px;
background: linear-gradient(90deg, light-dark(#42454c, #e5e6e9), light-dark(#373a40, #f1f2f3) 40%, light-dark(#42454c, #e5e6e9));
clip-path: polygon(0 0, 100% 0, 100% 100%, 50% 74%, 0 100%);
transform: translateY(-105%);
transition: transform 0.3s cubic-bezier(0.5, 0, 0.75, 0);
}
.tabs-ribbon__tab[aria-selected='true'] .tabs-ribbon__mark { transform: translateY(0); transition: transform 0.55s cubic-bezier(0.3, 1.6, 0.5, 1) 0.2s; }
.tabs-ribbon__panel { padding: 12px 4px 0; min-height: calc(3.1em + 14px); color: light-dark(#50535b, #c6c8cd); font-size: 13px; line-height: 1.55; text-align: center; }
@media (prefers-reduced-motion: reduce) {
.tabs-ribbon__mark { transition: none !important; }
}function initTabsRibbon(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;
var prev = sel;
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; });
}
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-ribbon').forEach(initTabsRibbon);Goal: Create a "Ribbon" tab set.
Visual behavior: Recipe tabs with a bookmark ribbon: it drops down onto the active tab while the old one pulls back up.
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 has a ribbon with a notched end cut by clip-path; it is translated down into view when the tab is selected, on an overshooting curve.
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 ribbons appear without dropping.
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 "Ribbon" tab set.
Visual behavior: Recipe tabs with a bookmark ribbon: it drops down onto the active tab while the old one pulls back up.
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 has a ribbon with a notched end cut by clip-path; it is translated down into view when the tab is selected, on an overshooting curve.
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 ribbons appear without dropping.
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
Recipe tabs with a bookmark ribbon: it drops down onto the active tab while the old one pulls back up.
- Dependencies
- None
- Difficulty
- Beginner
- Triggers
- click, state change
- Techniques
- css transition, clip path, transform only
- 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 ribbons appear without dropping.