Typewriter
React + Tailwind
const TEXT = 'Loading...';
const N = TEXT.length;
const T = 3.2; // seconds per line
const STRIKES = Array.from({ length: N }, (_, i) => 0.15 + i * 0.17);
const DING = 1.92;
const RETURN = 2.2;
const FED = 2.72;
const LINE = 24;
const at = (s: number) => `${((s / T) * 100).toFixed(2)}%`;
// Each letter sits a hair off its neighbours, the way a worn typewriter prints.
const JITTER = [0.4, -0.3, 0.2, 0.5, -0.4, 0.1, -0.2, 0.3, -0.1, 0.4];
const INK = [1, 0.86, 0.95, 0.9, 1, 0.82, 0.94, 1, 0.88, 0.96];
// The carriage: a jolt as each key lands, a step left after it, then the return and a line feed.
const carriage = [
'0% { transform: translate(0, 0); }',
...STRIKES.flatMap((s, i) => [
`${at(s)} { transform: translate(${-i}ch, 0); }`,
`${at(s + 0.02)} { transform: translate(${-i}ch, 0.8px); }`,
`${at(s + 0.06)} { transform: translate(${-(i + 1)}ch, 0); }`,
]),
`${at(RETURN)} { transform: translate(${-N}ch, 0); animation-timing-function: cubic-bezier(0.55, 0, 0.25, 1); }`,
`${at(FED)} { transform: translate(0, ${-LINE}px); }`,
`100% { transform: translate(0, ${-LINE}px); }`,
].join(' ');
// The line being typed is revealed one character at the moment its key lands.
const reveal = ['0% { width: 0; }', ...STRIKES.map((s, i) => `${at(s)} { width: ${i + 1}ch; }`), `100% { width: ${N}ch; }`].join(' ');
// The typebar flicks up to the paper for every strike and falls back.
const typebar = [
'0%, 100% { transform: rotate(-58deg); opacity: 0; }',
...STRIKES.flatMap((s) => [`${at(s - 0.05)} { transform: rotate(-58deg); opacity: 0; }`, `${at(s - 0.03)} { opacity: 1; }`, `${at(s)} { transform: rotate(0deg); opacity: 1; }`, `${at(s + 0.05)} { opacity: 1; }`, `${at(s + 0.07)} { transform: rotate(-58deg); opacity: 0; }`]),
].join(' ');
const css = `
.loader-typewriter { --paper: light-dark(#f3f4f7, #e5e6e9); --ink: light-dark(#373a40, #121417); --steel: light-dark(#6d7179, #7d8087); position: relative; width: 230px; height: 164px; overflow: hidden; -webkit-mask: linear-gradient(90deg, transparent, #000 14%, #000 86%, transparent), linear-gradient(transparent, #000 30%); -webkit-mask-composite: source-in; mask: linear-gradient(90deg, transparent, #000 14%, #000 86%, transparent), linear-gradient(transparent, #000 30%); mask-composite: intersect; }
/* The sheet: its left edge starts at the strike point and is carried left a character at a time. */
.loader-typewriter__paper { position: absolute; left: 87px; top: -40px; width: 240px; height: 206px; padding: 0 0 0 28px; box-sizing: border-box; background: var(--paper); box-shadow: 0 0 0 1px color-mix(in oklab, var(--ink) 16%, transparent), 0 6px 14px light-dark(rgba(0, 0, 0, 0.09), rgba(0, 0, 0, 0.3)); color: var(--ink); font: 700 18px/${LINE}px 'Courier New', Courier, ui-monospace, monospace; animation: loader-typewriter-carriage ${T}s infinite; }
.loader-typewriter__line { position: absolute; left: 28px; width: ${N}ch; height: ${LINE}px; overflow: hidden; white-space: pre; text-shadow: 0 0 0.6px var(--ink); }
.loader-typewriter__line span { display: inline-block; }
.loader-typewriter__line--now { animation: loader-typewriter-reveal ${T}s steps(1, end) infinite; }
/* The platen the paper wraps round, with the guide that marks where the type lands. */
.loader-typewriter__platen { position: absolute; left: 0; right: 0; top: 134px; height: 30px; border-radius: 15px; background: linear-gradient(light-dark(#50535b, #2e3035), light-dark(#373a40, #1a1c1f) 55%, light-dark(#42454c, #232529)); box-shadow: inset 0 2px 0 color-mix(in oklab, light-dark(#373a40, #f1f2f3) 18%, transparent), 0 -3px 8px light-dark(rgba(0, 0, 0, 0.08), rgba(0, 0, 0, 0.25)); }
.loader-typewriter__guide { position: absolute; left: 103px; top: 112px; width: 24px; height: 16px; background: linear-gradient(var(--steel), color-mix(in oklab, var(--steel) 70%, light-dark(#fbfcfd, #121417))); clip-path: polygon(0 0, 40% 0, 50% 45%, 60% 0, 100% 0, 100% 100%, 0 100%); }
.loader-typewriter__bar { position: absolute; left: 114px; top: 112px; width: 3px; height: 80px; border-radius: 1.5px; background: linear-gradient(light-dark(#50535b, #c6c8cd), var(--steel) 30%); transform-origin: 50% 100%; translate: 0 -2px; animation: loader-typewriter-typebar ${T}s infinite; }
.loader-typewriter__bar::before { content: ''; position: absolute; left: -3px; top: -2px; width: 9px; height: 7px; border-radius: 1px; background: light-dark(#50535b, #c6c8cd); }
.loader-typewriter__ding { position: absolute; right: 26px; top: 18px; color: light-dark(#5f626a, #a7abb2); font: italic 600 12px Georgia, 'Times New Roman', serif; opacity: 0; animation: loader-typewriter-ding ${T}s ease-out infinite; }
@keyframes loader-typewriter-carriage { ${carriage} }
@keyframes loader-typewriter-reveal { ${reveal} }
@keyframes loader-typewriter-typebar { ${typebar} }
@keyframes loader-typewriter-ding { 0%, ${at(DING - 0.01)} { opacity: 0; transform: none; } ${at(DING + 0.06)} { opacity: 1; transform: translateY(-4px) rotate(-6deg); } ${at(DING + 0.5)}, 100% { opacity: 0; transform: translateY(-8px) rotate(-6deg); } }
@media (prefers-reduced-motion: reduce) {
.loader-typewriter *, .loader-typewriter *::before { animation: none !important; }
.loader-typewriter__paper { transform: translateX(-5ch); }
.loader-typewriter__bar { opacity: 0; }
}
`;
function Typed() {
return (
<>
{[...TEXT].map((ch, i) => (
<span key={i} style={{ transform: `translateY(${JITTER[i]}px)`, opacity: INK[i] }}>
{ch}
</span>
))}
</>
);
}
export function Preview() {
return (
<>
<style href="kit-loaders-typewriter" precedence="kit">
{css}
</style>
<div className="loader-typewriter" role="status" aria-label="Loading">
<div className="loader-typewriter__paper" aria-hidden="true">
{[0, 1, 2].map((k) => (
<div key={k} className={`loader-typewriter__line${k === 2 ? ' loader-typewriter__line--now' : ''}`} style={{ top: 128 + (k - 2) * LINE }}>
<Typed />
</div>
))}
</div>
<span className="loader-typewriter__bar" aria-hidden="true" />
<span className="loader-typewriter__guide" aria-hidden="true" />
<span className="loader-typewriter__platen" aria-hidden="true" />
<span className="loader-typewriter__ding" aria-hidden="true">
ding
</span>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet. No JavaScript.
HTML
<div class="loader-typewriter" role="status" aria-label="Loading">
<div class="loader-typewriter__paper" aria-hidden="true">
<div class="loader-typewriter__line" style="top:80px">
<span style="transform:translateY(0.4px);opacity:1">L</span>
<span style="transform:translateY(-0.3px);opacity:0.86">o</span>
<span style="transform:translateY(0.2px);opacity:0.95">a</span>
<span style="transform:translateY(0.5px);opacity:0.9">d</span>
<span style="transform:translateY(-0.4px);opacity:1">i</span>
<span style="transform:translateY(0.1px);opacity:0.82">n</span>
<span style="transform:translateY(-0.2px);opacity:0.94">g</span>
<span style="transform:translateY(0.3px);opacity:1">.</span>
<span style="transform:translateY(-0.1px);opacity:0.88">.</span>
<span style="transform:translateY(0.4px);opacity:0.96">.</span>
</div>
<div class="loader-typewriter__line" style="top:104px">
<span style="transform:translateY(0.4px);opacity:1">L</span>
<span style="transform:translateY(-0.3px);opacity:0.86">o</span>
<span style="transform:translateY(0.2px);opacity:0.95">a</span>
<span style="transform:translateY(0.5px);opacity:0.9">d</span>
<span style="transform:translateY(-0.4px);opacity:1">i</span>
<span style="transform:translateY(0.1px);opacity:0.82">n</span>
<span style="transform:translateY(-0.2px);opacity:0.94">g</span>
<span style="transform:translateY(0.3px);opacity:1">.</span>
<span style="transform:translateY(-0.1px);opacity:0.88">.</span>
<span style="transform:translateY(0.4px);opacity:0.96">.</span>
</div>
<div class="loader-typewriter__line loader-typewriter__line--now" style="top:128px">
<span style="transform:translateY(0.4px);opacity:1">L</span>
<span style="transform:translateY(-0.3px);opacity:0.86">o</span>
<span style="transform:translateY(0.2px);opacity:0.95">a</span>
<span style="transform:translateY(0.5px);opacity:0.9">d</span>
<span style="transform:translateY(-0.4px);opacity:1">i</span>
<span style="transform:translateY(0.1px);opacity:0.82">n</span>
<span style="transform:translateY(-0.2px);opacity:0.94">g</span>
<span style="transform:translateY(0.3px);opacity:1">.</span>
<span style="transform:translateY(-0.1px);opacity:0.88">.</span>
<span style="transform:translateY(0.4px);opacity:0.96">.</span>
</div>
</div>
<span class="loader-typewriter__bar" aria-hidden="true"></span>
<span class="loader-typewriter__guide" aria-hidden="true"></span>
<span class="loader-typewriter__platen" aria-hidden="true"></span>
<span class="loader-typewriter__ding" aria-hidden="true">ding</span>
</div>CSS
.loader-typewriter { --paper: light-dark(#f3f4f7, #e5e6e9); --ink: light-dark(#373a40, #121417); --steel: light-dark(#6d7179, #7d8087); position: relative; width: 230px; height: 164px; overflow: hidden; -webkit-mask: linear-gradient(90deg, transparent, #000 14%, #000 86%, transparent), linear-gradient(transparent, #000 30%); -webkit-mask-composite: source-in; mask: linear-gradient(90deg, transparent, #000 14%, #000 86%, transparent), linear-gradient(transparent, #000 30%); mask-composite: intersect; }
/* The sheet: its left edge starts at the strike point and is carried left a character at a time. */
.loader-typewriter__paper { position: absolute; left: 87px; top: -40px; width: 240px; height: 206px; padding: 0 0 0 28px; box-sizing: border-box; background: var(--paper); box-shadow: 0 0 0 1px color-mix(in oklab, var(--ink) 16%, transparent), 0 6px 14px light-dark(rgba(0, 0, 0, 0.09), rgba(0, 0, 0, 0.3)); color: var(--ink); font: 700 18px/24px 'Courier New', Courier, ui-monospace, monospace; animation: loader-typewriter-carriage 3.2s infinite; }
.loader-typewriter__line { position: absolute; left: 28px; width: 10ch; height: 24px; overflow: hidden; white-space: pre; text-shadow: 0 0 0.6px var(--ink); }
.loader-typewriter__line span { display: inline-block; }
.loader-typewriter__line--now { animation: loader-typewriter-reveal 3.2s steps(1, end) infinite; }
/* The platen the paper wraps round, with the guide that marks where the type lands. */
.loader-typewriter__platen { position: absolute; left: 0; right: 0; top: 134px; height: 30px; border-radius: 15px; background: linear-gradient(light-dark(#50535b, #2e3035), light-dark(#373a40, #1a1c1f) 55%, light-dark(#42454c, #232529)); box-shadow: inset 0 2px 0 color-mix(in oklab, light-dark(#373a40, #f1f2f3) 18%, transparent), 0 -3px 8px light-dark(rgba(0, 0, 0, 0.08), rgba(0, 0, 0, 0.25)); }
.loader-typewriter__guide { position: absolute; left: 103px; top: 112px; width: 24px; height: 16px; background: linear-gradient(var(--steel), color-mix(in oklab, var(--steel) 70%, light-dark(#fbfcfd, #121417))); clip-path: polygon(0 0, 40% 0, 50% 45%, 60% 0, 100% 0, 100% 100%, 0 100%); }
.loader-typewriter__bar { position: absolute; left: 114px; top: 112px; width: 3px; height: 80px; border-radius: 1.5px; background: linear-gradient(light-dark(#50535b, #c6c8cd), var(--steel) 30%); transform-origin: 50% 100%; translate: 0 -2px; animation: loader-typewriter-typebar 3.2s infinite; }
.loader-typewriter__bar::before { content: ''; position: absolute; left: -3px; top: -2px; width: 9px; height: 7px; border-radius: 1px; background: light-dark(#50535b, #c6c8cd); }
.loader-typewriter__ding { position: absolute; right: 26px; top: 18px; color: light-dark(#5f626a, #a7abb2); font: italic 600 12px Georgia, 'Times New Roman', serif; opacity: 0; animation: loader-typewriter-ding 3.2s ease-out infinite; }
@keyframes loader-typewriter-carriage { 0% { transform: translate(0, 0); } 4.69% { transform: translate(0ch, 0); } 5.31% { transform: translate(0ch, 0.8px); } 6.56% { transform: translate(-1ch, 0); } 10.00% { transform: translate(-1ch, 0); } 10.63% { transform: translate(-1ch, 0.8px); } 11.88% { transform: translate(-2ch, 0); } 15.31% { transform: translate(-2ch, 0); } 15.94% { transform: translate(-2ch, 0.8px); } 17.19% { transform: translate(-3ch, 0); } 20.63% { transform: translate(-3ch, 0); } 21.25% { transform: translate(-3ch, 0.8px); } 22.50% { transform: translate(-4ch, 0); } 25.94% { transform: translate(-4ch, 0); } 26.56% { transform: translate(-4ch, 0.8px); } 27.81% { transform: translate(-5ch, 0); } 31.25% { transform: translate(-5ch, 0); } 31.87% { transform: translate(-5ch, 0.8px); } 33.13% { transform: translate(-6ch, 0); } 36.56% { transform: translate(-6ch, 0); } 37.19% { transform: translate(-6ch, 0.8px); } 38.44% { transform: translate(-7ch, 0); } 41.88% { transform: translate(-7ch, 0); } 42.50% { transform: translate(-7ch, 0.8px); } 43.75% { transform: translate(-8ch, 0); } 47.19% { transform: translate(-8ch, 0); } 47.81% { transform: translate(-8ch, 0.8px); } 49.06% { transform: translate(-9ch, 0); } 52.50% { transform: translate(-9ch, 0); } 53.13% { transform: translate(-9ch, 0.8px); } 54.37% { transform: translate(-10ch, 0); } 68.75% { transform: translate(-10ch, 0); animation-timing-function: cubic-bezier(0.55, 0, 0.25, 1); } 85.00% { transform: translate(0, -24px); } 100% { transform: translate(0, -24px); } }
@keyframes loader-typewriter-reveal { 0% { width: 0; } 4.69% { width: 1ch; } 10.00% { width: 2ch; } 15.31% { width: 3ch; } 20.63% { width: 4ch; } 25.94% { width: 5ch; } 31.25% { width: 6ch; } 36.56% { width: 7ch; } 41.88% { width: 8ch; } 47.19% { width: 9ch; } 52.50% { width: 10ch; } 100% { width: 10ch; } }
@keyframes loader-typewriter-typebar { 0%, 100% { transform: rotate(-58deg); opacity: 0; } 3.12% { transform: rotate(-58deg); opacity: 0; } 3.75% { opacity: 1; } 4.69% { transform: rotate(0deg); opacity: 1; } 6.25% { opacity: 1; } 6.87% { transform: rotate(-58deg); opacity: 0; } 8.44% { transform: rotate(-58deg); opacity: 0; } 9.06% { opacity: 1; } 10.00% { transform: rotate(0deg); opacity: 1; } 11.56% { opacity: 1; } 12.19% { transform: rotate(-58deg); opacity: 0; } 13.75% { transform: rotate(-58deg); opacity: 0; } 14.37% { opacity: 1; } 15.31% { transform: rotate(0deg); opacity: 1; } 16.88% { opacity: 1; } 17.50% { transform: rotate(-58deg); opacity: 0; } 19.06% { transform: rotate(-58deg); opacity: 0; } 19.69% { opacity: 1; } 20.63% { transform: rotate(0deg); opacity: 1; } 22.19% { opacity: 1; } 22.81% { transform: rotate(-58deg); opacity: 0; } 24.38% { transform: rotate(-58deg); opacity: 0; } 25.00% { opacity: 1; } 25.94% { transform: rotate(0deg); opacity: 1; } 27.50% { opacity: 1; } 28.13% { transform: rotate(-58deg); opacity: 0; } 29.69% { transform: rotate(-58deg); opacity: 0; } 30.31% { opacity: 1; } 31.25% { transform: rotate(0deg); opacity: 1; } 32.81% { opacity: 1; } 33.44% { transform: rotate(-58deg); opacity: 0; } 35.00% { transform: rotate(-58deg); opacity: 0; } 35.62% { opacity: 1; } 36.56% { transform: rotate(0deg); opacity: 1; } 38.13% { opacity: 1; } 38.75% { transform: rotate(-58deg); opacity: 0; } 40.31% { transform: rotate(-58deg); opacity: 0; } 40.94% { opacity: 1; } 41.88% { transform: rotate(0deg); opacity: 1; } 43.44% { opacity: 1; } 44.06% { transform: rotate(-58deg); opacity: 0; } 45.63% { transform: rotate(-58deg); opacity: 0; } 46.25% { opacity: 1; } 47.19% { transform: rotate(0deg); opacity: 1; } 48.75% { opacity: 1; } 49.38% { transform: rotate(-58deg); opacity: 0; } 50.94% { transform: rotate(-58deg); opacity: 0; } 51.56% { opacity: 1; } 52.50% { transform: rotate(0deg); opacity: 1; } 54.06% { opacity: 1; } 54.69% { transform: rotate(-58deg); opacity: 0; } }
@keyframes loader-typewriter-ding { 0%, 59.69% { opacity: 0; transform: none; } 61.87% { opacity: 1; transform: translateY(-4px) rotate(-6deg); } 75.63%, 100% { opacity: 0; transform: translateY(-8px) rotate(-6deg); } }
@media (prefers-reduced-motion: reduce) {
.loader-typewriter *, .loader-typewriter *::before { animation: none !important; }
.loader-typewriter__paper { transform: translateX(-5ch); }
.loader-typewriter__bar { opacity: 0; }
}Goal: Create a "Typewriter" loader.
Visual behavior: Loading... typed onto paper one strike at a time: the typebar flicks up, the carriage steps along, the bell rings and the carriage returns a line.
Technique:
- Pure CSS: every motion is a keyframe animation, so the plain HTML version needs no script.
- The strike times are one list in the code. The carriage jolt, the typebar flick and the reveal of the line are all generated from it, so every letter lands exactly as its key hits.
- The line being typed grows a character at a time with a width in ch on steps(1, end), and each letter sits a hair off its neighbours, as a worn machine prints.
- After the last strike a bell mark flashes, the carriage slides home and the paper feeds up one line, ready for the next.
Accessibility: The root has role="status" and aria-label="Loading"; everything inside it is aria-hidden. Under prefers-reduced-motion the sheet shows typed lines and the carriage rests mid-line.
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" loader.
Visual behavior: Loading... typed onto paper one strike at a time: the typebar flicks up, the carriage steps along, the bell rings and the carriage returns a line.
Technique:
- Pure CSS: every motion is a keyframe animation, so the plain HTML version needs no script.
- The strike times are one list in the code. The carriage jolt, the typebar flick and the reveal of the line are all generated from it, so every letter lands exactly as its key hits.
- The line being typed grows a character at a time with a width in ch on steps(1, end), and each letter sits a hair off its neighbours, as a worn machine prints.
- After the last strike a bell mark flashes, the carriage slides home and the paper feeds up one line, ready for the next.
Accessibility: The root has role="status" and aria-label="Loading"; everything inside it is aria-hidden. Under prefers-reduced-motion the sheet shows typed lines and the carriage rests mid-line.
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
Loading... typed onto paper one strike at a time: the typebar flicks up, the carriage steps along, the bell rings and the carriage returns a line.
- Dependencies
- None
- Difficulty
- Advanced
- Triggers
- continuous, loop, keyframe
- Techniques
- keyframes, text animation, clip path, transform only
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
The root has role="status" and aria-label="Loading"; everything inside it is aria-hidden. Under prefers-reduced-motion the sheet shows typed lines and the carriage rests mid-line.