CRT
Evening news
Floods in the valley, and the bridge reopens Monday.
Match of the day
Late winner at the harbour ground, 2 to 1.
Late film
A silent-era heist, starting in ten minutes.
import { useEffect, useId, useRef, useState } from 'react';
const CHANNELS = [
{ label: 'News', title: 'Evening news', body: 'Floods in the valley, and the bridge reopens Monday.' },
{ label: 'Sport', title: 'Match of the day', body: 'Late winner at the harbour ground, 2 to 1.' },
{ label: 'Film', title: 'Late film', body: 'A silent-era heist, starting in ten minutes.' },
];
// Off: the picture squeezes to a bright line, then the line to a dot. On: the reverse, overshooting a little.
const OFF: Keyframe[] = [
{ transform: 'scale(1, 1)', filter: 'brightness(1)' },
{ transform: 'scale(1, 0.012)', filter: 'brightness(4)', offset: 0.6 },
{ transform: 'scale(0, 0.012)', filter: 'brightness(4)' },
];
const ON: Keyframe[] = [
{ transform: 'scale(0, 0.012)', filter: 'brightness(4)' },
{ transform: 'scale(1, 0.012)', filter: 'brightness(3)', offset: 0.35 },
{ transform: 'scale(1, 1.05)', filter: 'brightness(1.4)', offset: 0.8 },
{ transform: 'scale(1, 1)', filter: 'brightness(1)' },
];
const css = `
.tabs-crt { width: 240px; }
.tabs-crt__set { padding: 10px; border-radius: 18px; background: light-dark(#ebedf0, #232529); }
.tabs-crt__screen { position: relative; height: 104px; border-radius: 14px / 18px; overflow: hidden; background: light-dark(#fbfcfd, #121417); }
.tabs-crt__picture { position: absolute; inset: 0; padding: 16px; background: radial-gradient(ellipse at 50% 40%, light-dark(#dbdde1, #2e3035), light-dark(#f3f4f7, #1a1c1f)); }
.tabs-crt__screen::after { content: ''; position: absolute; inset: 0; background: repeating-linear-gradient(transparent 0 2px, color-mix(in srgb, light-dark(#fbfcfd, #121417) 35%, transparent) 2px 3px); pointer-events: none; }
.tabs-crt__title { margin: 0 0 6px; font-size: 15px; font-weight: 700; color: light-dark(#373a40, #f1f2f3); }
.tabs-crt__body { margin: 0; font-size: 12.5px; line-height: 1.5; color: light-dark(#42454c, #e5e6e9); }
.tabs-crt__list { display: flex; gap: 6px; margin-top: 10px; }
.tabs-crt__tab { flex: 1; height: 28px; border: 0; border-radius: 8px; background: light-dark(#dbdde1, #2e3035); color: light-dark(#50535b, #c6c8cd); font: inherit; font-size: 12px; font-weight: 600; cursor: pointer; transition: background-color 0.2s, color 0.2s; }
.tabs-crt__tab[aria-selected='true'] { background: light-dark(#373a40, #f1f2f3); color: light-dark(#fbfcfd, #121417); }
.tabs-crt__tab:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 2px; }
.tabs-crt__tab span { margin-right: 5px; font-variant-numeric: tabular-nums; opacity: 0.7; }
`;
export function CrtTabs() {
const id = useId();
const [sel, setSel] = useState(0);
const [shown, setShown] = useState(0);
const tabs = useRef<(HTMLButtonElement | null)[]>([]);
const picture = useRef<HTMLDivElement>(null);
const on = useRef(0);
// The old channel switches off, the picture swaps while it is a dot, and the new one switches on.
useEffect(() => {
const el = picture.current!;
const running = el.getAnimations();
running.forEach((a) => a.cancel());
if (sel === on.current) {
if (running.length) el.animate(ON, { duration: 380, easing: 'ease-out' });
return;
}
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
on.current = sel;
return setShown(sel);
}
let live = true;
const off = el.animate(OFF, { duration: 300, easing: 'ease-in', fill: 'forwards' });
off.onfinish = () => {
if (!live) return;
on.current = sel;
setShown(sel);
el.animate(ON, { duration: 380, easing: 'ease-out' });
off.cancel();
};
return () => {
live = false;
};
}, [sel]);
const n = CHANNELS.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-crt">
<div className="tabs-crt__set">
<div className="tabs-crt__screen">
<div className="tabs-crt__picture" ref={picture}>
{CHANNELS.map((c, i) => (
<div key={c.label} role="tabpanel" id={`${id}-panel-${i}`} aria-labelledby={`${id}-tab-${i}`} hidden={shown !== i}>
<p className="tabs-crt__title">{c.title}</p>
<p className="tabs-crt__body">{c.body}</p>
</div>
))}
</div>
</div>
</div>
<div className="tabs-crt__list" role="tablist" aria-label="Channels" onKeyDown={onKeyDown}>
{CHANNELS.map((c, i) => (
<button
key={c.label}
ref={(el) => {
tabs.current[i] = el;
}}
className="tabs-crt__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 aria-hidden="true">{i + 1}</span>
{c.label}
</button>
))}
</div>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initTabsCrt(el) on the mounted element instead.
<div class="tabs-crt">
<div class="tabs-crt__set">
<div class="tabs-crt__screen">
<div class="tabs-crt__picture">
<div role="tabpanel" id="tabs-crt-panel-0" aria-labelledby="tabs-crt-tab-0">
<p class="tabs-crt__title">Evening news</p>
<p class="tabs-crt__body">Floods in the valley, and the bridge reopens Monday.</p>
</div>
<div role="tabpanel" id="tabs-crt-panel-1" aria-labelledby="tabs-crt-tab-1" hidden="">
<p class="tabs-crt__title">Match of the day</p>
<p class="tabs-crt__body">Late winner at the harbour ground, 2 to 1.</p>
</div>
<div role="tabpanel" id="tabs-crt-panel-2" aria-labelledby="tabs-crt-tab-2" hidden="">
<p class="tabs-crt__title">Late film</p>
<p class="tabs-crt__body">A silent-era heist, starting in ten minutes.</p>
</div>
</div>
</div>
</div>
<div class="tabs-crt__list" role="tablist" aria-label="Channels">
<button class="tabs-crt__tab" type="button" role="tab" id="tabs-crt-tab-0" aria-selected="true" aria-controls="tabs-crt-panel-0" tabindex="0">
<span aria-hidden="true">1</span>
News
</button>
<button class="tabs-crt__tab" type="button" role="tab" id="tabs-crt-tab-1" aria-selected="false" aria-controls="tabs-crt-panel-1" tabindex="-1">
<span aria-hidden="true">2</span>
Sport
</button>
<button class="tabs-crt__tab" type="button" role="tab" id="tabs-crt-tab-2" aria-selected="false" aria-controls="tabs-crt-panel-2" tabindex="-1">
<span aria-hidden="true">3</span>
Film
</button>
</div>
</div>.tabs-crt { width: 240px; }
.tabs-crt__set { padding: 10px; border-radius: 18px; background: light-dark(#ebedf0, #232529); }
.tabs-crt__screen { position: relative; height: 104px; border-radius: 14px / 18px; overflow: hidden; background: light-dark(#fbfcfd, #121417); }
.tabs-crt__picture { position: absolute; inset: 0; padding: 16px; background: radial-gradient(ellipse at 50% 40%, light-dark(#dbdde1, #2e3035), light-dark(#f3f4f7, #1a1c1f)); }
.tabs-crt__screen::after { content: ''; position: absolute; inset: 0; background: repeating-linear-gradient(transparent 0 2px, color-mix(in srgb, light-dark(#fbfcfd, #121417) 35%, transparent) 2px 3px); pointer-events: none; }
.tabs-crt__title { margin: 0 0 6px; font-size: 15px; font-weight: 700; color: light-dark(#373a40, #f1f2f3); }
.tabs-crt__body { margin: 0; font-size: 12.5px; line-height: 1.5; color: light-dark(#42454c, #e5e6e9); }
.tabs-crt__list { display: flex; gap: 6px; margin-top: 10px; }
.tabs-crt__tab { flex: 1; height: 28px; border: 0; border-radius: 8px; background: light-dark(#dbdde1, #2e3035); color: light-dark(#50535b, #c6c8cd); font: inherit; font-size: 12px; font-weight: 600; cursor: pointer; transition: background-color 0.2s, color 0.2s; }
.tabs-crt__tab[aria-selected='true'] { background: light-dark(#373a40, #f1f2f3); color: light-dark(#fbfcfd, #121417); }
.tabs-crt__tab:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 2px; }
.tabs-crt__tab span { margin-right: 5px; font-variant-numeric: tabular-nums; opacity: 0.7; }function initTabsCrt(root) {
var list = root.querySelector('[role="tablist"]');
var tabs = Array.prototype.slice.call(list.querySelectorAll('[role="tab"]'));
var panels = Array.prototype.slice.call(root.querySelectorAll('[role="tabpanel"]'));
var n = tabs.length;
var sel = tabs.findIndex(function (tab) { return tab.getAttribute('aria-selected') === 'true'; });
var OFF = [
{ transform: 'scale(1, 1)', filter: 'brightness(1)' },
{ transform: 'scale(1, 0.012)', filter: 'brightness(4)', offset: 0.6 },
{ transform: 'scale(0, 0.012)', filter: 'brightness(4)' },
];
var ON = [
{ transform: 'scale(0, 0.012)', filter: 'brightness(4)' },
{ transform: 'scale(1, 0.012)', filter: 'brightness(3)', offset: 0.35 },
{ transform: 'scale(1, 1.05)', filter: 'brightness(1.4)', offset: 0.8 },
{ transform: 'scale(1, 1)', filter: 'brightness(1)' },
];
var picture = root.querySelector('.tabs-crt__picture');
var on = sel;
var token = 0;
function show(i) {
on = i;
panels.forEach(function (panel, k) { panel.hidden = k !== i; });
}
// The old channel switches off, the picture swaps while it is a dot, and the new one switches on.
function tune() {
var mine = ++token;
var running = picture.getAnimations();
running.forEach(function (a) { a.cancel(); });
if (sel === on) {
if (running.length) picture.animate(ON, { duration: 380, easing: 'ease-out' });
return;
}
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return show(sel);
var off = picture.animate(OFF, { duration: 300, easing: 'ease-in', fill: 'forwards' });
off.onfinish = function () {
if (mine !== token) return;
show(sel);
picture.animate(ON, { duration: 380, easing: 'ease-out' });
off.cancel();
};
}
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;
});
tune();
}
function onClick(e) {
var tab = e.target.closest('[role="tab"]');
if (tab) select(tabs.indexOf(tab));
}
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();
select(next);
tabs[next].focus();
}
list.addEventListener('click', onClick);
list.addEventListener('keydown', onKeyDown);
return function () {
token++;
picture.getAnimations().forEach(function (a) { a.cancel(); });
list.removeEventListener('click', onClick);
list.removeEventListener('keydown', onKeyDown);
};
}
document.querySelectorAll('.tabs-crt').forEach(initTabsCrt);Goal: Create a "CRT" tab set.
Visual behavior: Channels on an old television: the picture squeezes to a bright line and a dot, swaps, then springs back open on the new channel.
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.
- Switching off is a Web Animation that scales the picture to a thin line and then a dot while brightness rises. When it finishes the panel swaps, and a second animation opens it back out with a small overshoot.
- A pick during the switch-off cancels the old run, so only the last channel is shown.
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. The new panel is shown once the old picture has switched off, a third of a second later. Under prefers-reduced-motion it swaps at once.
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 "CRT" tab set.
Visual behavior: Channels on an old television: the picture squeezes to a bright line and a dot, swaps, then springs back open on the new channel.
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.
- Switching off is a Web Animation that scales the picture to a thin line and then a dot while brightness rises. When it finishes the panel swaps, and a second animation opens it back out with a small overshoot.
- A pick during the switch-off cancels the old run, so only the last channel is shown.
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. The new panel is shown once the old picture has switched off, a third of a second later. Under prefers-reduced-motion it swaps at once.
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
Channels on an old television: the picture squeezes to a bright line and a dot, swaps, then springs back open on the new channel.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- click, state change, keyframe
- Techniques
- keyframes, filter, 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. The new panel is shown once the old picture has switched off, a third of a second later. Under prefers-reduced-motion it swaps at once.