Marquee
import { useId, useLayoutEffect, useRef, useState } from 'react';
const TABS = [
{ label: 'Matinee', body: '2:30 pm. Stalls from $38, with a talk from the cast after.' },
{ label: 'Evening', body: '7:45 pm. Two hours and ten minutes, with one interval.' },
{ label: 'Late', body: '10:30 pm. A stripped-back cabaret set in the bar.' },
];
const MOVE = '0.55s cubic-bezier(0.3, 1.15, 0.45, 1)';
const css = `
.tabs-marquee { width: 270px; }
.tabs-marquee__list { position: relative; display: flex; justify-content: center; gap: 8px; padding: 6px 0; }
.tabs-marquee__tab { position: relative; z-index: 1; border: 0; padding: 9px 16px; border-radius: 10px; background: none; font: inherit; font-size: 13px; font-weight: 500; color: light-dark(#5f626a, #a7abb2); cursor: pointer; transition: color 0.3s; }
.tabs-marquee__tab:hover, .tabs-marquee__tab[aria-selected='true'] { color: light-dark(#373a40, #f1f2f3); }
.tabs-marquee__tab:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 4px; }
.tabs-marquee__frame {
position: absolute;
overflow: visible;
pointer-events: none;
visibility: hidden;
transition: left ${MOVE}, top ${MOVE}, width ${MOVE}, height ${MOVE};
}
.tabs-marquee__bulbs, .tabs-marquee__lit { fill: none; stroke-width: 3; stroke-linecap: round; }
.tabs-marquee__bulbs { stroke: light-dark(#cbced3, #393c41); stroke-dasharray: 0 7; }
/* Every third bulb is lit, and the lit pattern runs round the frame. */
.tabs-marquee__lit {
stroke: light-dark(#373a40, #f1f2f3);
stroke-dasharray: 0 21;
filter: drop-shadow(0 0 2px light-dark(#373a40, #f1f2f3));
animation: tabs-marquee-chase 0.9s linear infinite;
}
@keyframes tabs-marquee-chase {
to { stroke-dashoffset: -21; }
}
.tabs-marquee__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-marquee__frame { transition: none; }
.tabs-marquee__lit { animation: none; }
}
`;
export function MarqueeTabs() {
const id = useId();
const [sel, setSel] = useState(1);
const tabs = useRef<(HTMLButtonElement | null)[]>([]);
const frame = useRef<SVGSVGElement>(null);
// The frame takes the active tab's box; the transition carries the lights across.
useLayoutEffect(() => {
const place = () => {
const t = tabs.current[sel]!;
const f = frame.current!;
Object.assign(f.style, { left: `${t.offsetLeft}px`, top: `${t.offsetTop}px`, width: `${t.offsetWidth}px`, height: `${t.offsetHeight}px`, visibility: 'visible' });
};
place();
document.fonts?.ready.then(place);
}, [sel]);
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-marquee">
<div className="tabs-marquee__list" role="tablist" aria-label="Showtimes" onKeyDown={onKeyDown}>
{TABS.map((t, i) => (
<button
key={t.label}
ref={(el) => {
tabs.current[i] = el;
}}
className="tabs-marquee__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>
))}
<svg className="tabs-marquee__frame" ref={frame} aria-hidden="true">
<rect className="tabs-marquee__bulbs" width="100%" height="100%" rx={10} />
<rect className="tabs-marquee__lit" width="100%" height="100%" rx={10} />
</svg>
</div>
{TABS.map((t, i) => (
<div key={t.label} className="tabs-marquee__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 initTabsMarquee(el) on the mounted element instead.
<div class="tabs-marquee">
<div class="tabs-marquee__list" role="tablist" aria-label="Showtimes">
<button class="tabs-marquee__tab" type="button" role="tab" id="tabs-marquee-tab-0" aria-selected="false" aria-controls="tabs-marquee-panel-0" tabindex="-1">Matinee</button>
<button class="tabs-marquee__tab" type="button" role="tab" id="tabs-marquee-tab-1" aria-selected="true" aria-controls="tabs-marquee-panel-1" tabindex="0">Evening</button>
<button class="tabs-marquee__tab" type="button" role="tab" id="tabs-marquee-tab-2" aria-selected="false" aria-controls="tabs-marquee-panel-2" tabindex="-1">Late</button>
<svg class="tabs-marquee__frame" aria-hidden="true">
<rect class="tabs-marquee__bulbs" width="100%" height="100%" rx="10"></rect>
<rect class="tabs-marquee__lit" width="100%" height="100%" rx="10"></rect>
</svg>
</div>
<div class="tabs-marquee__panel" role="tabpanel" id="tabs-marquee-panel-0" aria-labelledby="tabs-marquee-tab-0" hidden="">2:30 pm. Stalls from $38, with a talk from the cast after.</div>
<div class="tabs-marquee__panel" role="tabpanel" id="tabs-marquee-panel-1" aria-labelledby="tabs-marquee-tab-1">7:45 pm. Two hours and ten minutes, with one interval.</div>
<div class="tabs-marquee__panel" role="tabpanel" id="tabs-marquee-panel-2" aria-labelledby="tabs-marquee-tab-2" hidden="">10:30 pm. A stripped-back cabaret set in the bar.</div>
</div>.tabs-marquee { width: 270px; }
.tabs-marquee__list { position: relative; display: flex; justify-content: center; gap: 8px; padding: 6px 0; }
.tabs-marquee__tab { position: relative; z-index: 1; border: 0; padding: 9px 16px; border-radius: 10px; background: none; font: inherit; font-size: 13px; font-weight: 500; color: light-dark(#5f626a, #a7abb2); cursor: pointer; transition: color 0.3s; }
.tabs-marquee__tab:hover, .tabs-marquee__tab[aria-selected='true'] { color: light-dark(#373a40, #f1f2f3); }
.tabs-marquee__tab:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 4px; }
.tabs-marquee__frame {
position: absolute;
overflow: visible;
pointer-events: none;
visibility: hidden;
transition: left 0.55s cubic-bezier(0.3, 1.15, 0.45, 1), top 0.55s cubic-bezier(0.3, 1.15, 0.45, 1), width 0.55s cubic-bezier(0.3, 1.15, 0.45, 1), height 0.55s cubic-bezier(0.3, 1.15, 0.45, 1);
}
.tabs-marquee__bulbs, .tabs-marquee__lit { fill: none; stroke-width: 3; stroke-linecap: round; }
.tabs-marquee__bulbs { stroke: light-dark(#cbced3, #393c41); stroke-dasharray: 0 7; }
/* Every third bulb is lit, and the lit pattern runs round the frame. */
.tabs-marquee__lit {
stroke: light-dark(#373a40, #f1f2f3);
stroke-dasharray: 0 21;
filter: drop-shadow(0 0 2px light-dark(#373a40, #f1f2f3));
animation: tabs-marquee-chase 0.9s linear infinite;
}
@keyframes tabs-marquee-chase {
to { stroke-dashoffset: -21; }
}
.tabs-marquee__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-marquee__frame { transition: none; }
.tabs-marquee__lit { animation: none; }
}function initTabsMarquee(root) {
var list = root.querySelector('[role="tablist"]');
var tabs = Array.prototype.slice.call(list.querySelectorAll('[role="tab"]'));
var panels = root.querySelectorAll('[role="tabpanel"]');
var frame = root.querySelector('.tabs-marquee__frame');
var sel = tabs.findIndex(function (tab) { return tab.getAttribute('aria-selected') === 'true'; });
// The frame of bulbs takes the active tab's box; its transition carries the lights across.
function place() {
var t = tabs[sel];
frame.style.left = t.offsetLeft + 'px';
frame.style.top = t.offsetTop + 'px';
frame.style.width = t.offsetWidth + 'px';
frame.style.height = t.offsetHeight + 'px';
frame.style.visibility = 'visible';
}
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; });
place();
}
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);
place();
if (document.fonts) document.fonts.ready.then(place);
return function () {
list.removeEventListener('click', onClick);
list.removeEventListener('keydown', onKeyDown);
};
}
document.querySelectorAll('.tabs-marquee').forEach(initTabsMarquee);Goal: Create a "Marquee" tab set.
Visual behavior: Showtime tabs with the active one framed in chasing marquee bulbs; pick another and the frame of lights travels across 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 frame is an SVG with two rounded rects at its full size. A dash array of 0 7 with round caps turns one stroke into a row of dim bulbs; a second, every third bulb and glowing, animates its dash offset in a loop so the lit bulbs chase round.
- The SVG takes the active tab's box, and a transition on its position and size carries the lights across.
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 bulbs stop chasing and the frame jumps.
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 "Marquee" tab set.
Visual behavior: Showtime tabs with the active one framed in chasing marquee bulbs; pick another and the frame of lights travels across 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 frame is an SVG with two rounded rects at its full size. A dash array of 0 7 with round caps turns one stroke into a row of dim bulbs; a second, every third bulb and glowing, animates its dash offset in a loop so the lit bulbs chase round.
- The SVG takes the active tab's box, and a transition on its position and size carries the lights across.
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 bulbs stop chasing and the frame jumps.
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
Showtime tabs with the active one framed in chasing marquee bulbs; pick another and the frame of lights travels across to it.
- Dependencies
- None
- Difficulty
- Beginner
- Triggers
- click, state change, loop, glow
- Techniques
- svg stroke, keyframes, 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 bulbs stop chasing and the frame jumps.