Telegraph
import { useEffect, useRef, useState } from 'react';
const css = `
@keyframes field-telegraph-in { from { transform: translateX(10px); opacity: 0; } }
.field-telegraph { width: 250px; }
.field-telegraph__label { display: block; margin-bottom: 6px; color: light-dark(#50535b, #c6c8cd); font-size: 12px; }
.field-telegraph__out {
width: 100%;
height: 36px;
padding: 0 10px;
border: 0;
outline: 0;
border-radius: 8px;
background: light-dark(#f3f4f7, #1a1c1f);
box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035);
color: light-dark(#373a40, #f1f2f3);
font: 600 14px ui-monospace, SFMono-Regular, Menlo, monospace;
letter-spacing: 2px;
}
.field-telegraph__out:focus { box-shadow: inset 0 0 0 1px light-dark(#5f626a, #a7abb2); }
.field-telegraph__tape { display: flex; align-items: center; justify-content: flex-end; gap: 4px; height: 22px; margin: 8px 0; padding: 0 10px; border-radius: 4px; background: light-dark(#fbfcfd, #121417); overflow: hidden; }
.field-telegraph__sym { flex: none; height: 4px; border-radius: 2px; background: light-dark(#42454c, #e5e6e9); animation: field-telegraph-in 0.15s ease-out; }
.field-telegraph__sym[data-k='.'] { width: 4px; }
.field-telegraph__sym[data-k='-'] { width: 12px; }
.field-telegraph__sym[data-k='/'] { width: 1px; height: 12px; background: light-dark(#cbced3, #393c41); }
.field-telegraph__row { user-select: none; -webkit-user-select: none; display: flex; align-items: center; gap: 12px; }
.field-telegraph__key {
position: relative;
width: 116px;
height: 46px;
border: 0;
padding: 0;
background: none;
cursor: pointer;
touch-action: none;
}
.field-telegraph__key:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 2px; border-radius: 8px; }
.field-telegraph__base { position: absolute; left: 0; right: 0; bottom: 0; height: 10px; border-radius: 4px; background: light-dark(#ebedf0, #232529); box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035); }
.field-telegraph__lever {
position: absolute;
left: 12px;
right: 8px;
bottom: 14px;
height: 6px;
border-radius: 3px;
background: linear-gradient(light-dark(#50535b, #c6c8cd), light-dark(#6d7179, #7d8087));
transform-origin: 6% 50%;
transform: rotate(-4deg);
transition: transform 0.06s;
}
.field-telegraph__knob { position: absolute; right: -4px; top: -11px; width: 24px; height: 14px; border-radius: 12px 12px 4px 4px; background: light-dark(#42454c, #e5e6e9); box-shadow: inset 0 -2px 0 light-dark(#6d7179, #7d8087); }
.field-telegraph__key[data-down] .field-telegraph__lever { transform: rotate(1deg); }
.field-telegraph__lamp { width: 12px; height: 12px; border-radius: 50%; background: light-dark(#dbdde1, #2e3035); transition: background-color 0.05s, box-shadow 0.05s; }
.field-telegraph__lamp[data-on] { background: light-dark(#373a40, #f1f2f3); box-shadow: 0 0 10px 2px color-mix(in oklab, light-dark(#373a40, #f1f2f3) 55%, transparent); }
.field-telegraph__top { display: flex; align-items: center; gap: 5px; margin-bottom: 6px; }
.field-telegraph__top > :first-child { margin: 0; }
.field-telegraph__info { position: relative; display: grid; place-items: center; width: 16px; height: 16px; padding: 0; border: 0; border-radius: 50%; background: none; color: light-dark(#5f626a, #a7abb2); cursor: help; }
.field-telegraph__info svg { width: 14px; height: 14px; }
.field-telegraph__info:hover { color: light-dark(#42454c, #e5e6e9); }
.field-telegraph__info:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 1px; }
/* How-to lives behind the icon, so the field itself stays minimal. */
.field-telegraph__info::after {
content: attr(data-tip);
position: absolute;
left: -6px;
bottom: calc(100% + 6px);
z-index: 5;
padding: 4px 8px;
border-radius: 6px;
background: light-dark(#373a40, #f1f2f3);
color: light-dark(#fbfcfd, #121417);
font-family: inherit; font-size: 11px; font-weight: 500; line-height: 1.3;
width: max-content;
max-width: 150px;
opacity: 0;
transform: translateY(3px);
pointer-events: none;
transition: opacity 0.15s, transform 0.15s;
}
.field-telegraph__info:hover::after, .field-telegraph__info:focus-visible::after { opacity: 1; transform: none; }
@media (prefers-reduced-motion: reduce) {
.field-telegraph__sym { animation: none; }
.field-telegraph__lever, .field-telegraph__lamp { transition: none; }
}
`;
const MORSE: Record<string, string> = {
'.-': 'A', '-...': 'B', '-.-.': 'C', '-..': 'D', '.': 'E', '..-.': 'F', '--.': 'G', '....': 'H', '..': 'I', '.---': 'J',
'-.-': 'K', '.-..': 'L', '--': 'M', '-.': 'N', '---': 'O', '.--.': 'P', '--.-': 'Q', '.-.': 'R', '...': 'S', '-': 'T',
'..-': 'U', '...-': 'V', '.--': 'W', '-..-': 'X', '-.--': 'Y', '--..': 'Z', '-----': '0', '.----': '1', '..---': '2',
'...--': '3', '....-': '4', '.....': '5', '-....': '6', '--...': '7', '---..': '8', '----.': '9',
};
const DASH = 220; // a press longer than this is a dash
const LETTER_GAP = 650;
const WORD_GAP = 1500;
export function TelegraphInput() {
const [text, setText] = useState('HI ');
const [code, setCode] = useState('');
const [tape, setTape] = useState<{ k: string; id: number }[]>([]);
const [down, setDown] = useState(false);
const pressed = useRef(0);
const seq = useRef(0);
const timers = useRef<number[]>([]);
const codeRef = useRef('');
useEffect(() => () => timers.current.forEach(clearTimeout), []);
const press = () => {
if (pressed.current) return;
timers.current.forEach(clearTimeout);
pressed.current = performance.now();
setDown(true);
};
const lift = () => {
if (!pressed.current) return;
const held = performance.now() - pressed.current;
pressed.current = 0;
setDown(false);
const k = held > DASH ? '-' : '.';
codeRef.current += k;
setCode(codeRef.current);
setTape((t) => [...t, { k, id: seq.current++ }].slice(-22));
// A pause ends the letter; a longer one ends the word.
timers.current = [
window.setTimeout(() => {
const letter = MORSE[codeRef.current] ?? '?';
codeRef.current = '';
setCode('');
setText((s) => (s + letter).slice(-24));
setTape((t) => [...t, { k: '/', id: seq.current++ }].slice(-22));
}, LETTER_GAP),
window.setTimeout(() => setText((s) => (s.endsWith(' ') ? s : `${s} `)), WORD_GAP),
];
};
return (
<>
<style>
{css}
</style>
<div className="field-telegraph">
<div className="field-telegraph__top">
<label className="field-telegraph__label" htmlFor="field-telegraph-out">
Message
</label>
<button type="button" className="field-telegraph__info" aria-label="Tap for a dot, hold for a dash" data-tip="Tap for a dot, hold for a dash">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
<circle cx="12" cy="12" r="9" />
<path d="M12 11v5M12 7.5v.01" />
</svg>
</button>
</div>
<input id="field-telegraph-out" className="field-telegraph__out" value={text + (code ? '_' : '')} onChange={(e) => setText(e.target.value.toUpperCase())} />
<div className="field-telegraph__tape" aria-hidden="true">
{tape.map((s) => (
<span key={s.id} className="field-telegraph__sym" data-k={s.k} />
))}
</div>
<div className="field-telegraph__row">
<button
type="button"
className="field-telegraph__key"
aria-label="Morse key: tap for a dot, hold for a dash"
data-down={down || undefined}
onPointerDown={(e) => {
e.currentTarget.setPointerCapture(e.pointerId);
press();
}}
onPointerUp={lift}
onPointerCancel={lift}
onKeyDown={(e) => {
if ((e.key === ' ' || e.key === 'Enter') && !e.repeat) {
e.preventDefault();
press();
}
}}
onKeyUp={(e) => {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
lift();
}
}}
>
<span className="field-telegraph__base" />
<span className="field-telegraph__lever">
<span className="field-telegraph__knob" />
</span>
</button>
<span className="field-telegraph__lamp" data-on={down || undefined} aria-hidden="true" />
</div>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initFieldTelegraph(el) on the mounted element instead.
<div class="field-telegraph">
<div class="field-telegraph__top">
<label class="field-telegraph__label" for="field-telegraph-out">Message</label>
<button type="button" class="field-telegraph__info" aria-label="Tap for a dot, hold for a dash" data-tip="Tap for a dot, hold for a dash">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
<circle cx="12" cy="12" r="9"></circle>
<path d="M12 11v5M12 7.5v.01"></path>
</svg>
</button>
</div>
<input id="field-telegraph-out" class="field-telegraph__out" value="HI "/>
<div class="field-telegraph__tape" aria-hidden="true"></div>
<div class="field-telegraph__row">
<button type="button" class="field-telegraph__key" aria-label="Morse key: tap for a dot, hold for a dash">
<span class="field-telegraph__base"></span>
<span class="field-telegraph__lever">
<span class="field-telegraph__knob"></span>
</span>
</button>
<span class="field-telegraph__lamp" aria-hidden="true"></span>
</div>
</div>@keyframes field-telegraph-in { from { transform: translateX(10px); opacity: 0; } }
.field-telegraph { width: 250px; }
.field-telegraph__label { display: block; margin-bottom: 6px; color: light-dark(#50535b, #c6c8cd); font-size: 12px; }
.field-telegraph__out {
width: 100%;
height: 36px;
padding: 0 10px;
border: 0;
outline: 0;
border-radius: 8px;
background: light-dark(#f3f4f7, #1a1c1f);
box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035);
color: light-dark(#373a40, #f1f2f3);
font: 600 14px ui-monospace, SFMono-Regular, Menlo, monospace;
letter-spacing: 2px;
}
.field-telegraph__out:focus { box-shadow: inset 0 0 0 1px light-dark(#5f626a, #a7abb2); }
.field-telegraph__tape { display: flex; align-items: center; justify-content: flex-end; gap: 4px; height: 22px; margin: 8px 0; padding: 0 10px; border-radius: 4px; background: light-dark(#fbfcfd, #121417); overflow: hidden; }
.field-telegraph__sym { flex: none; height: 4px; border-radius: 2px; background: light-dark(#42454c, #e5e6e9); animation: field-telegraph-in 0.15s ease-out; }
.field-telegraph__sym[data-k='.'] { width: 4px; }
.field-telegraph__sym[data-k='-'] { width: 12px; }
.field-telegraph__sym[data-k='/'] { width: 1px; height: 12px; background: light-dark(#cbced3, #393c41); }
.field-telegraph__row { user-select: none; -webkit-user-select: none; display: flex; align-items: center; gap: 12px; }
.field-telegraph__key {
position: relative;
width: 116px;
height: 46px;
border: 0;
padding: 0;
background: none;
cursor: pointer;
touch-action: none;
}
.field-telegraph__key:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 2px; border-radius: 8px; }
.field-telegraph__base { position: absolute; left: 0; right: 0; bottom: 0; height: 10px; border-radius: 4px; background: light-dark(#ebedf0, #232529); box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035); }
.field-telegraph__lever {
position: absolute;
left: 12px;
right: 8px;
bottom: 14px;
height: 6px;
border-radius: 3px;
background: linear-gradient(light-dark(#50535b, #c6c8cd), light-dark(#6d7179, #7d8087));
transform-origin: 6% 50%;
transform: rotate(-4deg);
transition: transform 0.06s;
}
.field-telegraph__knob { position: absolute; right: -4px; top: -11px; width: 24px; height: 14px; border-radius: 12px 12px 4px 4px; background: light-dark(#42454c, #e5e6e9); box-shadow: inset 0 -2px 0 light-dark(#6d7179, #7d8087); }
.field-telegraph__key[data-down] .field-telegraph__lever { transform: rotate(1deg); }
.field-telegraph__lamp { width: 12px; height: 12px; border-radius: 50%; background: light-dark(#dbdde1, #2e3035); transition: background-color 0.05s, box-shadow 0.05s; }
.field-telegraph__lamp[data-on] { background: light-dark(#373a40, #f1f2f3); box-shadow: 0 0 10px 2px color-mix(in oklab, light-dark(#373a40, #f1f2f3) 55%, transparent); }
.field-telegraph__top { display: flex; align-items: center; gap: 5px; margin-bottom: 6px; }
.field-telegraph__top > :first-child { margin: 0; }
.field-telegraph__info { position: relative; display: grid; place-items: center; width: 16px; height: 16px; padding: 0; border: 0; border-radius: 50%; background: none; color: light-dark(#5f626a, #a7abb2); cursor: help; }
.field-telegraph__info svg { width: 14px; height: 14px; }
.field-telegraph__info:hover { color: light-dark(#42454c, #e5e6e9); }
.field-telegraph__info:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 1px; }
/* How-to lives behind the icon, so the field itself stays minimal. */
.field-telegraph__info::after {
content: attr(data-tip);
position: absolute;
left: -6px;
bottom: calc(100% + 6px);
z-index: 5;
padding: 4px 8px;
border-radius: 6px;
background: light-dark(#373a40, #f1f2f3);
color: light-dark(#fbfcfd, #121417);
font-family: inherit; font-size: 11px; font-weight: 500; line-height: 1.3;
width: max-content;
max-width: 150px;
opacity: 0;
transform: translateY(3px);
pointer-events: none;
transition: opacity 0.15s, transform 0.15s;
}
.field-telegraph__info:hover::after, .field-telegraph__info:focus-visible::after { opacity: 1; transform: none; }
@media (prefers-reduced-motion: reduce) {
.field-telegraph__sym { animation: none; }
.field-telegraph__lever, .field-telegraph__lamp { transition: none; }
}function initFieldTelegraph(root) {
var out = root.querySelector('.field-telegraph__out');
var tape = root.querySelector('.field-telegraph__tape');
var key = root.querySelector('.field-telegraph__key');
var lamp = root.querySelector('.field-telegraph__lamp');
if (!out || !tape || !key || !lamp) return;
var MORSE = {
'.-': 'A', '-...': 'B', '-.-.': 'C', '-..': 'D', '.': 'E', '..-.': 'F', '--.': 'G', '....': 'H', '..': 'I', '.---': 'J',
'-.-': 'K', '.-..': 'L', '--': 'M', '-.': 'N', '---': 'O', '.--.': 'P', '--.-': 'Q', '.-.': 'R', '...': 'S', '-': 'T',
'..-': 'U', '...-': 'V', '.--': 'W', '-..-': 'X', '-.--': 'Y', '--..': 'Z', '-----': '0', '.----': '1', '..---': '2',
'...--': '3', '....-': '4', '.....': '5', '-....': '6', '--...': '7', '---..': '8', '----.': '9',
};
var DASH = 220; // a press longer than this is a dash
var LETTER_GAP = 650;
var WORD_GAP = 1500;
var text = out.value.replace(/_$/, '');
var code = '';
var pressed = 0;
var timers = [];
function show() { out.value = text + (code ? '_' : ''); }
function sym(k) {
var s = document.createElement('span');
s.className = 'field-telegraph__sym';
s.setAttribute('data-k', k);
tape.appendChild(s);
while (tape.children.length > 22) tape.firstChild.remove();
}
function press() {
if (pressed) return;
timers.forEach(clearTimeout);
pressed = performance.now();
key.setAttribute('data-down', '');
lamp.setAttribute('data-on', '');
}
function lift() {
if (!pressed) return;
var held = performance.now() - pressed;
pressed = 0;
key.removeAttribute('data-down');
lamp.removeAttribute('data-on');
var k = held > DASH ? '-' : '.';
code += k;
sym(k);
show();
// A pause ends the letter; a longer one ends the word.
timers = [
setTimeout(function () {
text = (text + (MORSE[code] || '?')).slice(-24);
code = '';
sym('/');
show();
}, LETTER_GAP),
setTimeout(function () {
if (text.slice(-1) !== ' ') text += ' ';
show();
}, WORD_GAP),
];
}
function down(e) {
key.setPointerCapture(e.pointerId);
press();
}
function keydown(e) {
if ((e.key === ' ' || e.key === 'Enter') && !e.repeat) {
e.preventDefault();
press();
}
}
function keyup(e) {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
lift();
}
}
function typed() { text = out.value.toUpperCase().replace(/_$/, ''); }
key.addEventListener('pointerdown', down);
key.addEventListener('pointerup', lift);
key.addEventListener('pointercancel', lift);
key.addEventListener('keydown', keydown);
key.addEventListener('keyup', keyup);
out.addEventListener('input', typed);
return function () {
timers.forEach(clearTimeout);
key.removeEventListener('pointerdown', down);
key.removeEventListener('pointerup', lift);
key.removeEventListener('pointercancel', lift);
key.removeEventListener('keydown', keydown);
key.removeEventListener('keyup', keyup);
out.removeEventListener('input', typed);
};
}
document.querySelectorAll('.field-telegraph').forEach(initFieldTelegraph);Goal: Create a "Telegraph" input.
Visual behavior: A message field you key in Morse: tap the brass key for a dot or hold it for a dash, a lamp lights while it is down, the tape records each symbol, and pauses decode them into letters and spaces.
Technique:
- Press time decides the symbol: over 220ms is a dash, otherwise a dot. Pointer and Space/Enter (ignoring auto-repeat) both key it.
- After each release, a 650ms timer ends the letter (looked up in a Morse table) and a 1.5s timer ends the word with a space; pressing again cancels them.
- Symbols are appended to a scrolling tape with a short slide-in; the lever tilts and the lamp glows while the key is held.
Accessibility: The key is a real button whose label explains tap and hold, and the decoded text is a real labelled field you can also type into. The how-to sits behind the info button.
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 "Telegraph" input.
Visual behavior: A message field you key in Morse: tap the brass key for a dot or hold it for a dash, a lamp lights while it is down, the tape records each symbol, and pauses decode them into letters and spaces.
Technique:
- Press time decides the symbol: over 220ms is a dash, otherwise a dot. Pointer and Space/Enter (ignoring auto-repeat) both key it.
- After each release, a 650ms timer ends the letter (looked up in a Morse table) and a 1.5s timer ends the word with a space; pressing again cancels them.
- Symbols are appended to a scrolling tape with a short slide-in; the lever tilts and the lamp glows while the key is held.
Accessibility: The key is a real button whose label explains tap and hold, and the decoded text is a real labelled field you can also type into. The how-to sits behind the info button.
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 message field you key in Morse: tap the brass key for a dot or hold it for a dash, a lamp lights while it is down, the tape records each symbol, and pauses decode them into letters and spaces.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- click, state change
- Techniques
- state machine, keyframes, interval
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
The key is a real button whose label explains tap and hold, and the decoded text is a real labelled field you can also type into. The how-to sits behind the info button.