A contact form whose validation prints as a teletype receipt below it — each field's check accretes as a new line, and correcting a field strikes through the old line and reprints rather than clearing it.
npx shadcn add https://design.helpmarq.com /r/contact-form-teletype.jsonregistry/core/contact-form-teletype/component.tsx"use client";
import { useEffect, useId, useRef, useState, type FormEvent } from "react";
// ---------------------------------------------------------------------------
// TeletypeForm — a contact form whose validation surfaces as a real running
// receipt, not a red border. Every field validates on a debounced keystroke
// (or on submit, for whatever hasn't fired yet) and PRINTS a new line below
// the form — teletype-style, each line typing itself out over a duration
// proportional to its own length via a width transition stepped one
// character at a time, never cleared once written. Correcting a field does
// NOT erase its old line: the old line gets struck through in place and a
// fresh line for the same field prints below it, so the receipt reads as an
// honest log of the fill (what was tried, in order) rather than a status
// badge that overwrites itself.
//
// Distinct from validation-inline-wick (a single field's border diffuses a
// tint from the offending character — no log, no history) and from
// validation-error-summary (a punch list gathered at the TOP of the form on
// failed submit, with leader lines back to fields, that self-dismisses once
// every field resolves and forgets earlier attempts). Distinct from
// ticker-teleprinter (a marquee crawl with no semantic content) and
// progress-telegraph-log (a log of a caller-driven async operation's own
// sub-steps, not of user-typed field validation). Here the log is the point:
// it accretes for the life of the form, keeping every correction visible.
// ---------------------------------------------------------------------------
export interface TeletypeFieldSpec {
name: string;
label: string;
type?: "text" | "email" | "textarea";
placeholder?: string;
/** return an error message, or null when the value passes */
validate: (value: string) => string | null;
}
export interface ContactFormTeletypeProps {
fields?: TeletypeFieldSpec[];
onSubmit?: (values: Record<string, string>) => void;
/** ms after the last keystroke before that field's line prints. Default 260. */
debounceMs?: number;
className?: string;
}
const DEFAULT_FIELDS: TeletypeFieldSpec[] = [
{
name: "name",
label: "Name",
validate: (v) => (v.trim().length === 0 ? "required" : v.trim().length < 2 ? "too short" : null),
},
{
name: "email",
label: "Email",
type: "email",
validate: (v) =>
v.trim().length === 0 ? "required" : /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v) ? null : "not a valid address",
},
{
name: "message",
label: "Message",
type: "textarea",
validate: (v) => (v.trim().length === 0 ? "required" : v.trim().length < 10 ? "too short" : null),
},
];
interface Entry {
id: number;
field: string;
label: string;
ok: boolean;
text: string;
struck: boolean;
}
function receiptLine(label: string, ok: boolean, err: string | null): string {
const dots = ".".repeat(Math.max(3, 22 - label.length));
return ok ? `${label.toUpperCase()} ${dots} OK` : `${label.toUpperCase()} ${dots} FAIL — ${err}`;
}
export function ContactFormTeletype({
fields = DEFAULT_FIELDS,
onSubmit,
debounceMs = 260,
className = "",
}: ContactFormTeletypeProps) {
const uid = useId();
const [values, setValues] = useState<Record<string, string>>(() =>
Object.fromEntries(fields.map((f) => [f.name, ""]))
);
const [entries, setEntries] = useState<Entry[]>([]);
const debounceRefs = useRef<Record<string, ReturnType<typeof setTimeout> | undefined>>({});
const entryIdRef = useRef(0);
const reducedRef = useRef(false);
useEffect(() => {
reducedRef.current = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const refs = debounceRefs.current;
return () => {
Object.values(refs).forEach((t) => t && clearTimeout(t));
};
}, []);
function printLine(field: TeletypeFieldSpec, value: string) {
const err = field.validate(value);
const ok = err === null;
setEntries((prev) => {
const struck = prev.map((e) => (e.field === field.name ? { ...e, struck: true } : e));
return [
...struck,
{
id: entryIdRef.current++,
field: field.name,
label: field.label,
ok,
text: receiptLine(field.label, ok, err),
struck: false,
},
];
});
}
function handleChange(field: TeletypeFieldSpec, next: string) {
setValues((v) => ({ ...v, [field.name]: next }));
const refs = debounceRefs.current;
if (refs[field.name]) clearTimeout(refs[field.name]);
refs[field.name] = setTimeout(() => printLine(field, next), debounceMs);
}
function handleSubmit(e: FormEvent) {
e.preventDefault();
let allOk = true;
fields.forEach((f) => {
const refs = debounceRefs.current;
if (refs[f.name]) clearTimeout(refs[f.name]);
const v = values[f.name] ?? "";
const err = f.validate(v);
if (err) allOk = false;
printLine(f, v);
});
if (allOk) onSubmit?.(values);
}
const fieldCls =
"ns-cft-field w-full rounded-sm border border-border bg-background px-3 py-2 font-mono text-sm text-foreground outline-none transition-colors duration-150 placeholder:text-muted/60 hover:border-muted focus-visible:border-accent focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent";
return (
<div className={`ns-cft w-full ${className}`}>
<style>{CSS}</style>
<form onSubmit={handleSubmit} className="flex flex-col gap-4" noValidate>
{fields.map((field) => {
const fieldId = `${uid}-${field.name}`;
return (
<div key={field.name} className="flex flex-col gap-1.5">
<label htmlFor={fieldId} className="font-mono text-xs uppercase tracking-widest text-muted">
{field.label}
</label>
{field.type === "textarea" ? (
<textarea
id={fieldId}
name={field.name}
rows={3}
value={values[field.name]}
placeholder={field.placeholder}
onChange={(e) => handleChange(field, e.target.value)}
className={`${fieldCls} resize-none`}
/>
) : (
<input
id={fieldId}
name={field.name}
type={field.type ?? "text"}
value={values[field.name]}
placeholder={field.placeholder}
onChange={(e) => handleChange(field, e.target.value)}
className={fieldCls}
/>
)}
</div>
);
})}
<button
type="submit"
className="ns-cft-submit self-start rounded-sm border border-border px-4 py-2 font-mono text-xs uppercase tracking-widest text-foreground transition-colors duration-150 hover:border-accent hover:text-accent focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
>
Send
</button>
</form>
<div
role="log"
aria-live="polite"
aria-label="Field validation receipt"
className="ns-cft-receipt mt-6 flex flex-col gap-0.5 rounded-sm border border-border bg-surface p-3 font-mono text-xs"
>
<p className="pb-1 text-muted">— RECEIPT —</p>
{entries.length === 0 && (
<p aria-hidden="true" className="text-muted/60">
(awaiting input)
</p>
)}
{entries.map((entry) => (
<ReceiptLine key={entry.id} entry={entry} reduced={reducedRef.current} />
))}
</div>
</div>
);
}
function ReceiptLine({ entry, reduced }: { entry: Entry; reduced: boolean }) {
const [printed, setPrinted] = useState(reduced);
const chars = Math.max(1, entry.text.length);
useEffect(() => {
if (reduced) return;
const raf = requestAnimationFrame(() => setPrinted(true));
return () => cancelAnimationFrame(raf);
}, [reduced]);
const duration = reduced ? 0 : Math.min(900, Math.max(160, chars * 20));
return (
<div
className="ns-cft-line"
style={{ color: entry.ok ? "var(--foreground)" : "var(--error, #ea001d)" }}
>
<span
className={`ns-cft-print inline-block overflow-hidden whitespace-pre align-top ${
entry.struck ? "ns-cft-struck" : ""
}`}
style={{
width: printed ? `${chars}ch` : "0ch",
transition: reduced ? "none" : `width ${duration}ms steps(${chars}, end)`,
}}
>
{entry.text}
</span>
</div>
);
}
const CSS = `
.ns-cft-struck { text-decoration: line-through; text-decoration-thickness: 1px; opacity: 0.55; transition: opacity 200ms ease; }
`;
a contact/lead form where the fill itself should read as an honest running log — every validation attempt stays visible in order, corrections struck through rather than erased — instead of a status badge that overwrites itself; pick validation-inline-wick instead for a single field whose border should diffuse a tint from the offending character with no history, or validation-error-summary for a punch list gathered at the top on failed submit that self-dismisses once every field resolves.
<ContactFormTeletype fields? onSubmit? debounceMs? className?> renders a real <form> (Name, Email, Message by default, each with its own validate(value) returning an error string or null) and, below it, a role=log aria-live=polite receipt panel. 260ms after the last keystroke in a field (debounceMs), that field's validate runs and a new dot-leader line prints into the receipt — 'NAME .......... OK' or 'EMAIL .......... FAIL — not a valid address' — via a width transition stepped one character at a time (steps(N, end)) so it reads as typed rather than faded in. Submitting also validates and prints a line for every field regardless of debounce state. Nothing is ever erased: when a field is corrected and revalidated, its previous receipt line gets a strike-through and stays exactly where it was, and the new result prints as a fresh line below it — the receipt is a real cumulative log of the fill, not a status that overwrites itself. Colors: --foreground for a passing line, --error for a failing one; the form fields themselves use --border/--muted/--accent for resting/hover/focus, no hardcoded hex anywhere. Under prefers-reduced-motion every line renders at full width instantly with no step transition. Zero dependencies, no canvas.