Swash
import { useId, useRef, useState } from 'react';
const TABS = [
{ label: 'Menu', body: 'Five courses from the market, changing with the week.' },
{ label: 'Wine', body: 'Natural wines by the glass, and a short list of old vintages.' },
{ label: 'Dessert', body: 'Burnt honey tart, poached quince and a cheese from the Jura.' },
];
// Drawn in a 112 by 18 box and stretched to each word: a long sweep, a small loop, and a flick.
const SWASH = 'M2 7C22 12 58 2.5 90 7.5C98 8.8 103 13.5 97 15.4 90.5 17.4 87 10 99 6.6 104 5.4 108 5.6 111 6.6';
const css = `
.tabs-swash { width: 270px; }
.tabs-swash__list { display: flex; justify-content: center; gap: 28px; }
.tabs-swash__tab {
position: relative;
border: 0;
padding: 4px 2px 14px;
background: none;
font-family: ui-serif, Georgia, 'Times New Roman', serif;
font-size: 18px;
font-style: italic;
color: light-dark(#5f626a, #a7abb2);
cursor: pointer;
transition: color 0.35s;
}
.tabs-swash__tab:hover, .tabs-swash__tab[aria-selected='true'] { color: light-dark(#373a40, #f1f2f3); }
.tabs-swash__tab:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 2px; border-radius: 4px; }
.tabs-swash__ink { position: absolute; left: -2px; top: calc(100% - 17px); width: calc(100% + 12px); height: 18px; overflow: visible; pointer-events: none; }
.tabs-swash__ink path {
fill: none;
stroke: light-dark(#42454c, #e5e6e9);
stroke-width: 1.4;
stroke-linecap: round;
stroke-dasharray: 1 1;
stroke-dashoffset: 1;
/* Leaving, it rewinds back into the start of the stroke. */
transition: stroke-dashoffset 0.45s cubic-bezier(0.5, 0, 0.75, 0);
}
.tabs-swash__tab[aria-selected='true'] .tabs-swash__ink path { stroke-dashoffset: 0; transition: stroke-dashoffset 0.8s 0.15s cubic-bezier(0.35, 0, 0.25, 1); }
.tabs-swash__panel { padding: 12px 4px 0; min-height: calc(3.1em + 12px); color: light-dark(#50535b, #c6c8cd); font-size: 13px; line-height: 1.55; text-align: center; }
@media (prefers-reduced-motion: reduce) {
.tabs-swash__ink path, .tabs-swash__tab[aria-selected='true'] .tabs-swash__ink path { transition: none; }
}
`;
export function SwashTabs() {
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-swash">
<div className="tabs-swash__list" role="tablist" aria-label="Restaurant" onKeyDown={onKeyDown}>
{TABS.map((t, i) => (
<button
key={t.label}
ref={(el) => {
tabs.current[i] = el;
}}
className="tabs-swash__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}
<svg className="tabs-swash__ink" viewBox="0 0 112 18" preserveAspectRatio="none" aria-hidden="true">
<path d={SWASH} pathLength={1} vectorEffect="non-scaling-stroke" />
</svg>
</button>
))}
</div>
{TABS.map((t, i) => (
<div key={t.label} className="tabs-swash__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 initTabsSwash(el) on the mounted element instead.
<div class="tabs-swash">
<div class="tabs-swash__list" role="tablist" aria-label="Restaurant">
<button class="tabs-swash__tab" type="button" role="tab" id="tabs-swash-tab-0" aria-selected="false" aria-controls="tabs-swash-panel-0" tabindex="-1">
Menu
<svg class="tabs-swash__ink" viewBox="0 0 112 18" preserveAspectRatio="none" aria-hidden="true">
<path d="M2 7C22 12 58 2.5 90 7.5C98 8.8 103 13.5 97 15.4 90.5 17.4 87 10 99 6.6 104 5.4 108 5.6 111 6.6" pathLength="1" vector-effect="non-scaling-stroke"></path>
</svg>
</button>
<button class="tabs-swash__tab" type="button" role="tab" id="tabs-swash-tab-1" aria-selected="true" aria-controls="tabs-swash-panel-1" tabindex="0">
Wine
<svg class="tabs-swash__ink" viewBox="0 0 112 18" preserveAspectRatio="none" aria-hidden="true">
<path d="M2 7C22 12 58 2.5 90 7.5C98 8.8 103 13.5 97 15.4 90.5 17.4 87 10 99 6.6 104 5.4 108 5.6 111 6.6" pathLength="1" vector-effect="non-scaling-stroke"></path>
</svg>
</button>
<button class="tabs-swash__tab" type="button" role="tab" id="tabs-swash-tab-2" aria-selected="false" aria-controls="tabs-swash-panel-2" tabindex="-1">
Dessert
<svg class="tabs-swash__ink" viewBox="0 0 112 18" preserveAspectRatio="none" aria-hidden="true">
<path d="M2 7C22 12 58 2.5 90 7.5C98 8.8 103 13.5 97 15.4 90.5 17.4 87 10 99 6.6 104 5.4 108 5.6 111 6.6" pathLength="1" vector-effect="non-scaling-stroke"></path>
</svg>
</button>
</div>
<div class="tabs-swash__panel" role="tabpanel" id="tabs-swash-panel-0" aria-labelledby="tabs-swash-tab-0" hidden="">Five courses from the market, changing with the week.</div>
<div class="tabs-swash__panel" role="tabpanel" id="tabs-swash-panel-1" aria-labelledby="tabs-swash-tab-1">Natural wines by the glass, and a short list of old vintages.</div>
<div class="tabs-swash__panel" role="tabpanel" id="tabs-swash-panel-2" aria-labelledby="tabs-swash-tab-2" hidden="">Burnt honey tart, poached quince and a cheese from the Jura.</div>
</div>.tabs-swash { width: 270px; }
.tabs-swash__list { display: flex; justify-content: center; gap: 28px; }
.tabs-swash__tab {
position: relative;
border: 0;
padding: 4px 2px 14px;
background: none;
font-family: ui-serif, Georgia, 'Times New Roman', serif;
font-size: 18px;
font-style: italic;
color: light-dark(#5f626a, #a7abb2);
cursor: pointer;
transition: color 0.35s;
}
.tabs-swash__tab:hover, .tabs-swash__tab[aria-selected='true'] { color: light-dark(#373a40, #f1f2f3); }
.tabs-swash__tab:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 2px; border-radius: 4px; }
.tabs-swash__ink { position: absolute; left: -2px; top: calc(100% - 17px); width: calc(100% + 12px); height: 18px; overflow: visible; pointer-events: none; }
.tabs-swash__ink path {
fill: none;
stroke: light-dark(#42454c, #e5e6e9);
stroke-width: 1.4;
stroke-linecap: round;
stroke-dasharray: 1 1;
stroke-dashoffset: 1;
/* Leaving, it rewinds back into the start of the stroke. */
transition: stroke-dashoffset 0.45s cubic-bezier(0.5, 0, 0.75, 0);
}
.tabs-swash__tab[aria-selected='true'] .tabs-swash__ink path { stroke-dashoffset: 0; transition: stroke-dashoffset 0.8s 0.15s cubic-bezier(0.35, 0, 0.25, 1); }
.tabs-swash__panel { padding: 12px 4px 0; min-height: calc(3.1em + 12px); color: light-dark(#50535b, #c6c8cd); font-size: 13px; line-height: 1.55; text-align: center; }
@media (prefers-reduced-motion: reduce) {
.tabs-swash__ink path, .tabs-swash__tab[aria-selected='true'] .tabs-swash__ink path { transition: none; }
}function initTabsSwash(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'; });
// The swash writes and unwrites itself with a stroke-dashoffset transition on aria-selected.
function select(i) {
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-swash').forEach(initTabsSwash);Goal: Create a "Swash" tab set.
Visual behavior: Restaurant tabs in italic serif: a calligraphic swash writes itself under the chosen word, ending in a small loop and a flick, while the old one unwrites.
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 one swash path, drawn in a fixed box and stretched to the word with preserveAspectRatio="none" and a non-scaling stroke. With pathLength="1", stroke-dashoffset 1 hides it and 0 draws it.
- Selecting draws it over 0.8s after a short delay; leaving rewinds it into its start more quickly.
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 swash appears and disappears without drawing.
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 "Swash" tab set.
Visual behavior: Restaurant tabs in italic serif: a calligraphic swash writes itself under the chosen word, ending in a small loop and a flick, while the old one unwrites.
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 one swash path, drawn in a fixed box and stretched to the word with preserveAspectRatio="none" and a non-scaling stroke. With pathLength="1", stroke-dashoffset 1 hides it and 0 draws it.
- Selecting draws it over 0.8s after a short delay; leaving rewinds it into its start more quickly.
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 swash appears and disappears without drawing.
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
Restaurant tabs in italic serif: a calligraphic swash writes itself under the chosen word, ending in a small loop and a flick, while the old one unwrites.
- Dependencies
- None
- Difficulty
- Beginner
- Triggers
- click, state change, draw
- Techniques
- svg stroke, stroke dashoffset, 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 swash appears and disappears without drawing.