- secure
- movin.design
- components
- toggles
- sortnew
Typewriter
import { useState } from 'react';
const css = `
@keyframes field-typewriter-strike {
from { transform: translateY(-8px) rotate(var(--r)); opacity: 0.2; }
55% { transform: translateY(1px) rotate(var(--r)); opacity: 1; }
to { transform: translateY(0) rotate(var(--r)); opacity: var(--ink); }
}
@keyframes field-typewriter-guide { 40% { transform: translateY(3px); } }
@keyframes field-typewriter-ding { from { opacity: 1; transform: translateY(0); } to { opacity: 0; transform: translateY(-14px); } }
.field-typewriter { width: 250px; }
.field-typewriter__label { display: block; margin-bottom: 6px; color: light-dark(#50535b, #c6c8cd); font-size: 12px; }
.field-typewriter__paper {
position: relative;
height: 74px;
border-radius: 6px;
background:
linear-gradient(transparent 46px, light-dark(#dbdde1, #2e3035) 46px 47px, transparent 47px),
light-dark(#f3f4f7, #1a1c1f);
box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035);
overflow: hidden;
}
.field-typewriter__paper:focus-within { box-shadow: inset 0 0 0 1px light-dark(#5f626a, #a7abb2); }
/* The real field sits on top, invisible, so typing, paste and screen readers work as normal. */
.field-typewriter__input { position: absolute; inset: 0; z-index: 1; width: 100%; height: 100%; border: 0; outline: 0; padding: 0; background: none; color: transparent; caret-color: transparent; font-size: 16px; cursor: text; }
/* The carriage: the line slides left so the type always lands at the strike point. */
.field-typewriter__line {
position: absolute;
left: 125px;
top: 24px;
display: flex;
color: light-dark(#373a40, #f1f2f3);
font: 600 17px/22px ui-monospace, SFMono-Regular, Menlo, monospace;
white-space: pre;
transition: transform 0.08s steps(2);
}
.field-typewriter__ch { display: inline-block; width: 1ch; opacity: var(--ink); animation: field-typewriter-strike 0.16s ease-out both; }
.field-typewriter__guide { position: absolute; left: 124px; top: 12px; width: 0; height: 0; border: 5px solid transparent; border-top-color: light-dark(#6d7179, #7d8087); margin-left: -4px; }
.field-typewriter__guide[data-hit] { animation: field-typewriter-guide 0.12s ease-out; }
.field-typewriter__ding { position: absolute; right: 10px; top: 6px; color: light-dark(#50535b, #c6c8cd); font-family: inherit; font-style: italic; font-size: 11px; font-weight: 600; animation: field-typewriter-ding 0.8s ease-out forwards; }
.field-typewriter__count { display: block; margin-top: 6px; color: light-dark(#5f626a, #a7abb2); font-size: 11px; text-align: right; font-variant-numeric: tabular-nums; }
@media (prefers-reduced-motion: reduce) {
.field-typewriter__ch, .field-typewriter__guide[data-hit] { animation: none; }
.field-typewriter__line { transition: none; }
}
`;
// Each strike lands with its own slight tilt and ink density, like real type.
const tilt = (i: number) => (Math.sin(i * 12.9898) * 43758.5453) % 1;
export function TypewriterInput() {
const [value, setValue] = useState('Dear diary,');
const n = value.length;
return (
<>
<style>
{css}
</style>
<div className="field-typewriter">
<label className="field-typewriter__label" htmlFor="field-typewriter-input">
Note
</label>
<div className="field-typewriter__paper">
<input
id="field-typewriter-input"
className="field-typewriter__input"
value={value}
maxLength={120}
autoComplete="off"
spellCheck={false}
onChange={(e) => setValue(e.target.value)}
// The carriage only ever types at the end, so keep the caret there.
onSelect={(e) => e.currentTarget.setSelectionRange(e.currentTarget.value.length, e.currentTarget.value.length)}
/>
<span key={n} className="field-typewriter__guide" data-hit={n > 0 || undefined} aria-hidden="true" />
<span className="field-typewriter__line" style={{ transform: `translateX(${-n}ch)` }} aria-hidden="true">
{[...value].map((c, i) => (
<span key={i} className="field-typewriter__ch" style={{ '--r': `${(tilt(i) * 3).toFixed(1)}deg`, '--ink': (0.78 + Math.abs(tilt(i + 7)) * 0.22).toFixed(2) } as React.CSSProperties}>
{c}
</span>
))}
</span>
{n > 0 && n % 20 === 0 && (
<span key={`ding${n}`} className="field-typewriter__ding" aria-hidden="true">
ding
</span>
)}
</div>
<span className="field-typewriter__count">{n} / 120</span>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initFieldTypewriter(el) on the mounted element instead.
<div class="field-typewriter">
<label class="field-typewriter__label" for="field-typewriter-input">Note</label>
<div class="field-typewriter__paper">
<input id="field-typewriter-input" class="field-typewriter__input" maxLength="120" autoComplete="off" spellCheck="false" value="Dear diary,"/>
<span class="field-typewriter__guide" data-hit="true" aria-hidden="true"></span>
<span class="field-typewriter__line" style="transform:translateX(-11ch)" aria-hidden="true"><span class="field-typewriter__ch" style="--r:0.0deg;--ink:0.82">D</span><span class="field-typewriter__ch" style="--r:2.8deg;--ink:0.93">e</span><span class="field-typewriter__ch" style="--r:0.2deg;--ink:0.88">a</span><span class="field-typewriter__ch" style="--r:1.7deg;--ink:0.79">r</span><span class="field-typewriter__ch" style="--r:1.1deg;--ink:0.82"> </span><span class="field-typewriter__ch" style="--r:1.4deg;--ink:0.96">d</span><span class="field-typewriter__ch" style="--r:1.9deg;--ink:0.78">i</span><span class="field-typewriter__ch" style="--r:0.5deg;--ink:0.82">a</span><span class="field-typewriter__ch" style="--r:-2.0deg;--ink:0.88">r</span><span class="field-typewriter__ch" style="--r:-1.4deg;--ink:0.98">y</span><span class="field-typewriter__ch" style="--r:-0.1deg;--ink:0.88">,</span></span>
</div>
<span class="field-typewriter__count">11 / 120</span>
</div>@keyframes field-typewriter-strike {
from { transform: translateY(-8px) rotate(var(--r)); opacity: 0.2; }
55% { transform: translateY(1px) rotate(var(--r)); opacity: 1; }
to { transform: translateY(0) rotate(var(--r)); opacity: var(--ink); }
}
@keyframes field-typewriter-guide { 40% { transform: translateY(3px); } }
@keyframes field-typewriter-ding { from { opacity: 1; transform: translateY(0); } to { opacity: 0; transform: translateY(-14px); } }
.field-typewriter { width: 250px; }
.field-typewriter__label { display: block; margin-bottom: 6px; color: light-dark(#50535b, #c6c8cd); font-size: 12px; }
.field-typewriter__paper {
position: relative;
height: 74px;
border-radius: 6px;
background:
linear-gradient(transparent 46px, light-dark(#dbdde1, #2e3035) 46px 47px, transparent 47px),
light-dark(#f3f4f7, #1a1c1f);
box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035);
overflow: hidden;
}
.field-typewriter__paper:focus-within { box-shadow: inset 0 0 0 1px light-dark(#5f626a, #a7abb2); }
/* The real field sits on top, invisible, so typing, paste and screen readers work as normal. */
.field-typewriter__input { position: absolute; inset: 0; z-index: 1; width: 100%; height: 100%; border: 0; outline: 0; padding: 0; background: none; color: transparent; caret-color: transparent; font-size: 16px; cursor: text; }
/* The carriage: the line slides left so the type always lands at the strike point. */
.field-typewriter__line {
position: absolute;
left: 125px;
top: 24px;
display: flex;
color: light-dark(#373a40, #f1f2f3);
font: 600 17px/22px ui-monospace, SFMono-Regular, Menlo, monospace;
white-space: pre;
transition: transform 0.08s steps(2);
}
.field-typewriter__ch { display: inline-block; width: 1ch; opacity: var(--ink); animation: field-typewriter-strike 0.16s ease-out both; }
.field-typewriter__guide { position: absolute; left: 124px; top: 12px; width: 0; height: 0; border: 5px solid transparent; border-top-color: light-dark(#6d7179, #7d8087); margin-left: -4px; }
.field-typewriter__guide[data-hit] { animation: field-typewriter-guide 0.12s ease-out; }
.field-typewriter__ding { position: absolute; right: 10px; top: 6px; color: light-dark(#50535b, #c6c8cd); font-family: inherit; font-style: italic; font-size: 11px; font-weight: 600; animation: field-typewriter-ding 0.8s ease-out forwards; }
.field-typewriter__count { display: block; margin-top: 6px; color: light-dark(#5f626a, #a7abb2); font-size: 11px; text-align: right; font-variant-numeric: tabular-nums; }
@media (prefers-reduced-motion: reduce) {
.field-typewriter__ch, .field-typewriter__guide[data-hit] { animation: none; }
.field-typewriter__line { transition: none; }
}function initFieldTypewriter(root) {
var input = root.querySelector('.field-typewriter__input');
var line = root.querySelector('.field-typewriter__line');
var paper = root.querySelector('.field-typewriter__paper');
var count = root.querySelector('.field-typewriter__count');
if (!input || !line || !paper || !count) return;
// Each strike lands with its own slight tilt and ink density, like real type.
function tilt(i) { return (Math.sin(i * 12.9898) * 43758.5453) % 1; }
function ch(c, i) {
var s = document.createElement('span');
s.className = 'field-typewriter__ch';
s.style.setProperty('--r', (tilt(i) * 3).toFixed(1) + 'deg');
s.style.setProperty('--ink', (0.78 + Math.abs(tilt(i + 7)) * 0.22).toFixed(2));
s.textContent = c;
return s;
}
function update() {
var value = input.value;
var n = value.length;
// Keep the characters already on the paper; strike only the new ones.
var spans = line.children;
while (spans.length > n) line.lastChild.remove();
for (var i = 0; i < n; i++) {
if (spans[i] && spans[i].textContent === value[i]) continue;
var s = ch(value[i], i);
if (spans[i]) spans[i].replaceWith(s);
else line.appendChild(s);
}
line.style.transform = 'translateX(' + -n + 'ch)';
count.textContent = n + ' / 120';
var guide = paper.querySelector('.field-typewriter__guide');
var fresh = guide.cloneNode(false);
if (n > 0) fresh.setAttribute('data-hit', '');
guide.replaceWith(fresh);
if (n > 0 && n % 20 === 0) {
var ding = document.createElement('span');
ding.className = 'field-typewriter__ding';
ding.setAttribute('aria-hidden', 'true');
ding.textContent = 'ding';
ding.addEventListener('animationend', function () { ding.remove(); });
paper.appendChild(ding);
}
}
// The carriage only ever types at the end, so keep the caret there.
function toEnd() { input.setSelectionRange(input.value.length, input.value.length); }
input.addEventListener('input', update);
input.addEventListener('select', toEnd);
input.addEventListener('click', toEnd);
return function () {
input.removeEventListener('input', update);
input.removeEventListener('select', toEnd);
input.removeEventListener('click', toEnd);
};
}
document.querySelectorAll('.field-typewriter').forEach(initFieldTypewriter);Goal: Create a "Typewriter" input.
Visual behavior: A note field that types like a typewriter: each key strikes the paper with its own slight tilt and ink density, the carriage slides the line along so the type always lands at the strike point, and a bell dings every twenty characters.
Technique:
- The real input covers the paper but is invisible (transparent text and caret), so typing, paste and screen readers work as normal; its caret is kept at the end.
- The visible line starts at the strike point and translates left by one ch per character in a monospace face, in two quick steps like a carriage.
- Each character strikes down from above with a per-character tilt and ink opacity from a fixed hash, so the pattern is stable; the type guide dips on each key and "ding" floats up every 20th character.
Accessibility: A real <input> with a visible <label>; decoration is aria-hidden. Under prefers-reduced-motion characters appear without striking and the carriage 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 "Typewriter" input.
Visual behavior: A note field that types like a typewriter: each key strikes the paper with its own slight tilt and ink density, the carriage slides the line along so the type always lands at the strike point, and a bell dings every twenty characters.
Technique:
- The real input covers the paper but is invisible (transparent text and caret), so typing, paste and screen readers work as normal; its caret is kept at the end.
- The visible line starts at the strike point and translates left by one ch per character in a monospace face, in two quick steps like a carriage.
- Each character strikes down from above with a per-character tilt and ink opacity from a fixed hash, so the pattern is stable; the type guide dips on each key and "ding" floats up every 20th character.
Accessibility: A real <input> with a visible <label>; decoration is aria-hidden. Under prefers-reduced-motion characters appear without striking and the carriage 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
A note field that types like a typewriter: each key strikes the paper with its own slight tilt and ink density, the carriage slides the line along so the type always lands at the strike point, and a bell dings every twenty characters.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- focus, state change, delay stagger
- Techniques
- keyframes, css variables, text animation
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
A real <input> with a visible <label>; decoration is aria-hidden. Under prefers-reduced-motion characters appear without striking and the carriage jumps.