Typewriter
import { useEffect, useId, useRef, useState } from 'react';
const TABS = [
{ label: 'Draft', body: 'Dear Sam, the garden is finally in bloom. Come see it.' },
{ label: 'Reply', body: 'Sam wrote back: Saturday works. I will bring the cake.' },
{ label: 'P.S.', body: 'P.S. Bring the old camera if you still have it.' },
];
const css = `
@keyframes tabs-typewriter-blink { 50% { opacity: 0; } }
.tabs-typewriter { width: 250px; }
.tabs-typewriter__list { display: flex; gap: 14px; padding: 0 4px 8px; border-bottom: 1px solid light-dark(#ebedf0, #232529); }
.tabs-typewriter__tab { padding: 4px 0; border: 0; border-radius: 4px; background: none; color: light-dark(#5f626a, #a7abb2); font: inherit; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; cursor: pointer; transition: color 0.2s; }
.tabs-typewriter__tab:hover { color: light-dark(#42454c, #e5e6e9); }
.tabs-typewriter__tab[aria-selected='true'] { color: light-dark(#373a40, #f1f2f3); text-decoration: underline; text-decoration-thickness: 1.5px; text-underline-offset: 5px; }
.tabs-typewriter__tab:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 2px; }
.tabs-typewriter__panel { min-height: 76px; padding: 12px 4px 0; color: light-dark(#42454c, #e5e6e9); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12.5px; line-height: 1.6; }
.tabs-typewriter__caret { display: inline-block; width: 7px; height: 14px; margin-left: 1px; vertical-align: -2px; background: light-dark(#373a40, #f1f2f3); animation: tabs-typewriter-blink 1s steps(1) infinite; }
.tabs-typewriter__sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
@media (prefers-reduced-motion: reduce) {
.tabs-typewriter__caret { animation: none; }
}
`;
export function TypewriterTabs() {
const id = useId();
const [sel, setSel] = useState(0);
const [typed, setTyped] = useState(TABS[0].body.length);
const tabs = useRef<(HTMLButtonElement | null)[]>([]);
const full = TABS[sel].body.length;
useEffect(() => {
if (typed >= full) return;
const t = setTimeout(() => setTyped((n) => n + 1), 24);
return () => clearTimeout(t);
}, [typed, full]);
const select = (i: number) => {
if (i === sel) return;
setSel(i);
setTyped(window.matchMedia('(prefers-reduced-motion: reduce)').matches ? TABS[i].body.length : 0);
};
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();
select(next);
tabs.current[next]?.focus();
};
return (
<>
<style>
{css}
</style>
<div className="tabs-typewriter">
<div className="tabs-typewriter__list" role="tablist" aria-label="Letter" onKeyDown={onKeyDown}>
{TABS.map((t, i) => (
<button
key={t.label}
ref={(el) => {
tabs.current[i] = el;
}}
className="tabs-typewriter__tab"
type="button"
role="tab"
id={`${id}-tab-${i}`}
aria-selected={sel === i}
aria-controls={`${id}-panel-${i}`}
tabIndex={sel === i ? 0 : -1}
onClick={() => select(i)}
>
{t.label}
</button>
))}
</div>
{TABS.map((t, i) => (
<div key={t.label} className="tabs-typewriter__panel" role="tabpanel" id={`${id}-panel-${i}`} aria-labelledby={`${id}-tab-${i}`} hidden={sel !== i}>
{/* Screen readers get the whole sentence at once; the typing is for the eyes only. */}
<span className="tabs-typewriter__sr">{t.body}</span>
<span aria-hidden="true">
{t.body.slice(0, sel === i ? typed : 0)}
<span className="tabs-typewriter__caret" />
</span>
</div>
))}
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initTabsTypewriter(el) on the mounted element instead.
<div class="tabs-typewriter">
<div class="tabs-typewriter__list" role="tablist" aria-label="Letter">
<button class="tabs-typewriter__tab" type="button" role="tab" id="tabs-typewriter-tab-0" aria-selected="true" aria-controls="tabs-typewriter-panel-0" tabindex="0">Draft</button>
<button class="tabs-typewriter__tab" type="button" role="tab" id="tabs-typewriter-tab-1" aria-selected="false" aria-controls="tabs-typewriter-panel-1" tabindex="-1">Reply</button>
<button class="tabs-typewriter__tab" type="button" role="tab" id="tabs-typewriter-tab-2" aria-selected="false" aria-controls="tabs-typewriter-panel-2" tabindex="-1">P.S.</button>
</div>
<div class="tabs-typewriter__panel" role="tabpanel" id="tabs-typewriter-panel-0" aria-labelledby="tabs-typewriter-tab-0">
<span class="tabs-typewriter__sr">Dear Sam, the garden is finally in bloom. Come see it.</span>
<span aria-hidden="true">Dear Sam, the garden is finally in bloom. Come see it.<span class="tabs-typewriter__caret"></span></span>
</div>
<div class="tabs-typewriter__panel" role="tabpanel" id="tabs-typewriter-panel-1" aria-labelledby="tabs-typewriter-tab-1" hidden="">
<span class="tabs-typewriter__sr">Sam wrote back: Saturday works. I will bring the cake.</span>
<span aria-hidden="true">
<span class="tabs-typewriter__caret"></span>
</span>
</div>
<div class="tabs-typewriter__panel" role="tabpanel" id="tabs-typewriter-panel-2" aria-labelledby="tabs-typewriter-tab-2" hidden="">
<span class="tabs-typewriter__sr">P.S. Bring the old camera if you still have it.</span>
<span aria-hidden="true">
<span class="tabs-typewriter__caret"></span>
</span>
</div>
</div>@keyframes tabs-typewriter-blink { 50% { opacity: 0; } }
.tabs-typewriter { width: 250px; }
.tabs-typewriter__list { display: flex; gap: 14px; padding: 0 4px 8px; border-bottom: 1px solid light-dark(#ebedf0, #232529); }
.tabs-typewriter__tab { padding: 4px 0; border: 0; border-radius: 4px; background: none; color: light-dark(#5f626a, #a7abb2); font: inherit; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; cursor: pointer; transition: color 0.2s; }
.tabs-typewriter__tab:hover { color: light-dark(#42454c, #e5e6e9); }
.tabs-typewriter__tab[aria-selected='true'] { color: light-dark(#373a40, #f1f2f3); text-decoration: underline; text-decoration-thickness: 1.5px; text-underline-offset: 5px; }
.tabs-typewriter__tab:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 2px; }
.tabs-typewriter__panel { min-height: 76px; padding: 12px 4px 0; color: light-dark(#42454c, #e5e6e9); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12.5px; line-height: 1.6; }
.tabs-typewriter__caret { display: inline-block; width: 7px; height: 14px; margin-left: 1px; vertical-align: -2px; background: light-dark(#373a40, #f1f2f3); animation: tabs-typewriter-blink 1s steps(1) infinite; }
.tabs-typewriter__sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
@media (prefers-reduced-motion: reduce) {
.tabs-typewriter__caret { animation: none; }
}function initTabsTypewriter(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'; });
var timer;
var lines = Array.prototype.map.call(panels, function (panel) { return panel.querySelector('[aria-hidden="true"]'); });
var carets = lines.map(function (line) { return line.querySelector('.tabs-typewriter__caret'); });
var texts = Array.prototype.map.call(panels, function (panel) { return panel.querySelector('.tabs-typewriter__sr').textContent; });
function show(k, typed) {
lines[k].replaceChildren(texts[k].slice(0, typed), carets[k]);
}
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; });
// Screen readers get the whole sentence at once; the typing is for the eyes only.
clearTimeout(timer);
show(prev, 0);
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
show(i, texts[i].length);
return;
}
var typed = 0;
(function type() {
show(i, typed);
if (typed++ < texts[i].length) timer = setTimeout(type, 24);
})();
}
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 () {
clearTimeout(timer);
list.removeEventListener('click', onClick);
list.removeEventListener('keydown', onKeyDown);
};
}
document.querySelectorAll('.tabs-typewriter').forEach(initTabsTypewriter);Goal: Create a "Typewriter" tab set.
Visual behavior: Letter tabs whose text types itself out behind a blinking caret each time you switch.
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.
- A timer reveals one more character every 24ms; the visible text is aria-hidden, and a visually hidden copy carries the full sentence for screen readers.
- The caret blinks on keyframes.
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. Screen readers get the whole sentence at once. Under prefers-reduced-motion the text appears in full and the caret stops blinking.
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 "Typewriter" tab set.
Visual behavior: Letter tabs whose text types itself out behind a blinking caret each time you switch.
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.
- A timer reveals one more character every 24ms; the visible text is aria-hidden, and a visually hidden copy carries the full sentence for screen readers.
- The caret blinks on keyframes.
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. Screen readers get the whole sentence at once. Under prefers-reduced-motion the text appears in full and the caret stops blinking.
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
Letter tabs whose text types itself out behind a blinking caret each time you switch.
- Dependencies
- None
- Difficulty
- Beginner
- Triggers
- click, state change, keyframe
- Techniques
- interval, text animation, keyframes
- 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. Screen readers get the whole sentence at once. Under prefers-reduced-motion the text appears in full and the caret stops blinking.