Code Input
A monospace code field with an aligned line gutter, indent-aware Tab, bracket auto-close, regex syntax tinting and error markers fed by a diagnostics prop.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/code-input.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "CodeInput" component: a controlled
monospace textarea for editing a short piece of code, with a line-number
gutter, indent-aware Tab, bracket auto-close and its own regex tint.
Dependencies: lucide-react icons and a `cn()` class merger. No editor engine
(CodeMirror/Monaco), no highlighter (Shiki/Prism), no dangerouslySetInnerHTML.
Contract
- forwardRef to the textarea (useImperativeHandle), extending
React.TextareaHTMLAttributes<HTMLTextAreaElement> minus value/defaultValue/
onChange/wrap/disabled. `className` styles the outer panel; every other
native prop spreads onto the textarea.
- Props: value: string (required), onValueChange: (value: string) => void
(required), language = "tsx" ("tsx" | "ts" | "jsx" | "js" | "json" | "sql" |
"bash" | "sh" | "plain"), diagnostics?: CodeInputDiagnostic[], indent = 2
(clamped to 1–8 integers with a NaN/Infinity fallback), rows = 10,
showLineNumbers = true, autoCloseBrackets = true, disabled = false.
maxLength / readOnly / placeholder / aria-* come from the native props.
- CodeInputDiagnostic = { line: number (1-based), column?: number,
message: string, severity?: "error" | "warning" }. Diagnostics are an INPUT,
produced by whatever already checked the code (tsc, a linter, the server).
The component never parses, never lints and never invents a marker — that is
the line between this and a JSON editor that owns its own validator.
- `wrap` is fixed to "off" so one gutter row is exactly one logical line, and
`disabled` is re-declared as a normal prop: the native attribute blurs a
focused field the instant it is set, so "inert" is implemented as readOnly +
aria-disabled + a muted surface, and every edit path checks it.
Behavior — keyboard map
- Tab, collapsed caret or a selection inside one line: insert spaces to the
next tab stop, width = indent - (column mod indent) where column is
caret - lineStart. A selection is replaced, exactly like typing.
- Tab with a selection spanning a line break: indent every touched line by
`indent` spaces; blank lines are left alone rather than filled with trailing
whitespace. A selection that ends exactly on a newline does NOT touch the
line below it. Keep the selection over the same text: anchor moves by the
first line's delta, focus by the sum of all deltas.
- Shift+Tab: outdent the touched lines, removing up to `indent` leading
spaces, or one leading tab, per line. When nothing can be removed, make no
edit at all — no undo entry, no message.
- Escape arms a one-shot hatch and is NOT swallowed (a surrounding dialog
still closes on it); the next Tab moves focus normally, then indenting comes
back. Hold the flag in a ref that the Tab handler reads AND clears in the
same call, mirror it into state for the footer, and announce the armed state
in a polite sr-only live region — a field that quietly steals Tab is a
keyboard trap, and the way out has to be findable without a mouse.
- Enter keeps the current line's leading whitespace; after a trailing ( [ or {
it adds one more indent level, and if the matching closer sits right after
the caret it goes to its own line at the opener's level. A line with no
indentation and no opener falls through to the native newline — cheaper, and
one undo entry per keystroke.
- Backspace between an empty pair deletes both halves. Inside leading
whitespace it deletes back to the previous tab stop:
((prefix.length - 1) mod indent) + 1 characters, falling through to the
native single-character delete when that is 1.
- Auto-close: ( [ { and " ' ` insert the pair with the caret between them; a
ranged selection is WRAPPED, never replaced. Quotes are suppressed after a
word character or the same quote (`don't` stays one apostrophe) and in front
of anything that is not whitespace or a closing/punctuation character.
- Typing a closer types OVER one this component inserted, instead of doubling
it. Remember each auto-inserted closer as a distance from the END of the
document, not an absolute offset: editing inside the pair (the normal case)
leaves that distance untouched, so the typing path needs no bookkeeping.
Before use, pop entries whose character no longer matches, then require the
top entry to sit exactly at the caret. Cap the stack (32) and clear it on
Tab and on blur.
- All programmatic edits go through ONE replaceRange(from, to, text,
selectionAfter) helper: it enforces maxLength once and REFUSES with both
numbers (never truncates), then edits via document.execCommand("insertText")
— or execCommand("delete") when the replacement is empty, because inserting
"" is a no-op in several engines — so the browser's native undo stack
survives. If execCommand refuses, assign textarea.value and call
onValueChange manually, since a programmatic value assignment fires no input
event. Restore the selection after the edit either way.
Behavior — tint and gutter
- Tokenizer: tokenizeLine(line, language) cuts one line into { text, type }
tokens (comment | string | number | keyword | call | plain) using ONE regex
per language built from named groups (comment, string, number, ident),
cached per language with lastIndex reset before each use, and always
emitting the untouched gap before a match as a "plain" token — that is what
guarantees the tokens re-join into the exact input line. Classify an
identifier as keyword FIRST, then the "followed by (" call heuristic, or
`if (`, `for (`, `while (` and `return (` get painted as calls. SQL matches
keywords upper-cased. The closing quote is OPTIONAL in the string rule:
this is a field, so a half-typed string is the normal state and a
"must be closed" rule makes the tint flicker every other keystroke.
Unknown languages and "plain" produce one plain token per line — a
documented limitation, not a bug.
- Painting: the textarea's own text is `text-transparent` with
`caret-foreground`, and a mirror <div> underneath renders the same lines as
tinted spans, one h-6 row per line. Both boxes carry identical font family,
size, line-height, padding and tab-size (tab-size = indent, set inline on
both, or a pasted literal tab lands on a different grid in each). Give the
selection a translucent token background AND transparent selection text so
tinted code stays readable while selected.
- Alignment: keep the mirror in an absolutely positioned, overflow-hidden box
and drive it from the textarea's own scrollLeft/scrollTop with
transform: translate(-x, -y) — NOT by setting the mirror's scrollLeft, which
clamps to its own (slightly different) scroll extent and drifts the tint off
the text at the end of a long line.
- Gutter: a width-only shell (width: calc(<digits>ch + 2rem + 2px), exact in a
monospace face, the 2px absorbing sub-pixel `ch` rounding) with an
absolutely positioned rail inside it — in a flex row `overflow-hidden` still
lets content set an item's height, so numbers in normal flow stretch the row
on a long document. Each row is h-6 with a 12px marker slot and a
right-aligned tabular-nums number.
- Diagnostics rendering: clamp each line into [1, lineCount], let an error beat a
warning on the same line, and sort by line then column. The marked row gets
a gutter icon (CircleAlert / TriangleAlert) plus a coloured number, and the
mirror row gets a wavy (error) or dotted (warning) underline — a decoration
on the text itself, because a row background would stop short of a
horizontally scrolled line. The status bar reads the first problem as
"Line L, column C — message" with a "+N more" tail.
- Sync the mirror and the gutter in a rAF-throttled onScroll handler (cancel
the pending frame on unmount) AND in an effect on `value`, because Tab,
auto-close and a consumer replacing the document all move scrollTop without
emitting a scroll event.
Rendering & styling
- Semantic tokens only: bg-card shell, border dividers, bg-muted/30 gutter,
bg-muted/40 when inert, text-muted-foreground, text-destructive +
decoration-destructive for errors, ring-ring for focus. No hex.
Token colours are foreground/primary/muted variants with emphasis carrying
what hue cannot (keyword -> text-primary font-medium, string ->
text-foreground/70, comment -> text-muted-foreground italic, call ->
font-semibold): a project whose --chart-* ramp is monochrome would render
chart-coloured strings at ~1.5:1 against the card, drawn but unreadable.
- The editor row carries focus-within:ring-2 ring-inset while the textarea
itself is outline-none, so the whole panel reads as one control.
- Accessibility: aria-invalid while any error-severity diagnostic exists;
aria-describedby pointing at BOTH an sr-only hint ("Tab indents, Shift plus
Tab outdents, press Escape then Tab to move focus out") and the status bar;
the status bar is aria-live="polite" (an external check landing must not
interrupt typing); the gutter and the mirror are aria-hidden, since the
textarea already carries the text; a separate polite sr-only status
announces the armed Tab hatch.
- Only colour transitions animate; there is no auto-playing motion, so
reduced-motion needs nothing switched off and nothing breaks with motion
disabled.
- Cleanup: cancel the pending rAF and clear the refusal-message timeout on
unmount.
Customization levers
- Tint palette: remap the 6-entry TOKEN_CLASS table (swap in var(--chart-N)
slots if your palette really is multi-hue), or drop a category to "plain".
- Add a language: one entry in the LANGUAGES table (keyword set, comment
syntax, backtick strings, case-insensitive lookup, whether `name(` reads as
a call) — the tokenizer loop never changes.
- Density: `rows`, or change the text-[13px]/leading-6 pair — change it on the
textarea, the mirror AND the gutter together, or the rows drift apart. The
gutter's h-6 rows must equal the code line-height.
- Editing model: `indent={4}`, `autoCloseBrackets={false}` for languages where
pairs get in the way, or `readOnly` to reuse the panel as a tinted viewer
with the same gutter.
- Diagnostics: feed them from tsc, ESLint or a server response; add a
"severity: info" tone by extending the marker/underline maps; or render your
own list under the panel and keep the gutter markers as the index into it.
- Chrome: drop the footer for a bare field (keep the sr-only hint, it is the
only announcement of the Tab hatch), or add a language badge / problem count
beside the line count.Concepts
- Tinted mirror — the textarea's own text is transparent and a mirror underneath paints the tokens, so the caret, selection and native undo all stay the browser's while the colour is ours. The mirror is driven by
transform: translate(-scrollLeft, -scrollTop)rather than its own scroll offsets, which would clamp to a slightly different extent and drift at the end of a long line. - Indent-aware Tab — a collapsed caret jumps to the next tab stop (
indent - column mod indent, not a blindindentspaces), a selection that crosses a line break indents or outdents every line it touches, and the selection is re-anchored so it still covers the same text afterwards. - Announced escape hatch — hijacking Tab is what makes a code field usable and what makes it a keyboard trap, so Escape arms a one-shot flag (read and cleared inside the same handler) that lets the next Tab leave, the footer says so in plain text, and a polite live region says it to screen readers.
- Distance-from-end pair memory — an auto-inserted closer is remembered as
length - index, so typing inside the pair does not disturb it and no offset bookkeeping is needed; typing a closer only types over one this component actually inserted, never over a bracket the author wrote. - Diagnostics are an input — the component owns editing, not checking. Markers come from whatever already checked the code, are clamped into the current line count, and errors outrank warnings on a shared line; nothing here parses the document.
- One write path — Tab, Enter, Backspace and every auto-close funnel through a single range replacement that enforces
maxLengthonce (refusing with both numbers instead of truncating) and edits throughexecCommand, so the browser's undo history survives a scripted edit.
Period Picker
A whole-period picker — week, month, quarter or year — whose grid changes shape per granularity and whose value carries the resolved start and end instants.
Matrix Rating
A survey matrix: statements down the side, one shared scale across the top, one native radio group per row, a pinned scale, answered-row progress, and a stacked fallback instead of sideways scrolling.