Inputs

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { CircleAlert, TriangleAlert } from "lucide-react"
import { cn } from "@/lib/utils"

/* ------------------------------------------------------------------ */
/* Tinting — one regex per language, no highlighter dependency.        */
/*                                                                     */
/* This is a FIELD, so the tokenizer runs on text that is half-typed   */
/* most of the time: closing quotes are optional in the string rule,   */
/* and every character of a line always ends up in exactly one token,  */
/* which is what keeps the tinted mirror aligned with the textarea.    */
/* ------------------------------------------------------------------ */

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/code-input.json

Prompt

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 blind indent spaces), 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 maxLength once (refusing with both numbers instead of truncating) and edits through execCommand, so the browser's undo history survives a scripted edit.

On This Page