JSON Input
Structured-data entry with a gutter, live validation, and an error message that says which line broke.
Also called Code Editor, YAML Editor, Config Field — in this system all of them are JSON Input.
Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.
The error names a line
A missing comma on line 6. The gutter marks it, the message says where, and the field border carries the state — three signals for one fault.
Format on demand, never on type
A pasted single line becomes readable in one press. Reformatting as the user types moves the caret out from under them mid-word.
Schema errors are different from syntax errors
Valid JSON can still be wrong. Say which it is — a user hunting for a missing brace when the real problem is an unknown key will not find it.
Read-only payloads
Still focusable, still selectable, still copyable. A response body the user cannot select is a response body they will screenshot.
Interaction states
Every state a user can put this component into, rendered side by side. If a state is missing here, it is missing in production too.
Valid JSON
Line 3, column 3: Expected ',' or '}' after property value
Line 1, column 1: Unexpected end of JSON input
Valid JSON
2
3
Every part, every measurement, and the reason it is that number.
Line 7, column 5: Expected ',' or '}' after property value
Gutter, monospace body, a border carrying the parse state, and a message that converts a byte offset into a line and column.
- Gutter11px, unselectable
The reason this is not a textarea. Without line numbers an error message has nothing to point at. Never part of a selection or a copy.
- Error rowDanger tone, semibold
The gutter number for the failing line changes weight and colour. It is what turns "line 6" from a number into a location.
- Type13px / 1.55 monospace
Same as a Code Snippet, for the same reason: 1, l and I must be distinguishable, and indentation only reads if the glyphs are even.
- Tab width2 spaces
Two, not four. Config nests deeply and four spaces pushes the values off a narrow field.
- Rows12 default, resizable
Deep enough to see a nested object without scrolling. Vertical resize stays enabled — this is the one field where users genuinely want more room.
- Validation delay400ms after typing stops
Every partial object is invalid on the way to valid. Erroring instantly trains people to ignore the message.
- Format actionBelow, right-aligned
Explicit, never automatic. Reformatting while typing moves the caret out from under the user.
Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.
Color
| Token | Value | Used for |
|---|---|---|
| --ds-surface-inset | — | Editor fill |
| --ds-border-interactive | — | Idle border |
| --ds-border-subtle | — | Gutter divider |
| --ds-accent | — | Focus border |
| --ds-danger-border | — | Border while unparseable |
| --ds-danger-text | — | Error message and failing gutter row |
| --ds-warning-text | — | Schema violations, which parsed fine |
| --ds-success-text | — | Valid confirmation |
| --ds-fg-disabled | — | Gutter numbers — non-content |
Radius
| Token | Value | Used for |
|---|---|---|
| --radius-md | Field corners |
Typography
| Token | Value | Used for |
|---|---|---|
| font-mono | Editor body and gutter |
Motion
| Token | Value | Used for |
|---|---|---|
| debounce | Validation delay |
Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.
| Size | Height | Padding | Type | Min width | Max width | When to use |
|---|---|---|---|---|---|---|
| Compact | 6 rows | 8px | 12px | — | — | A small payload inside a dialog or a table row expansion. |
| Default | 12 rows | 10px | 13px | — | — | The default. Deep enough to see a nested object whole. |
| Tall | 20 rows | 10px | 13px | — | — | A dedicated configuration page where the field is the screen. |
| Gutter | — | — | 11px | 2.5rem | — | Right-aligned, unselectable, widening only past 999 lines. |
| Measure | — | — | — | — | 48rem | About 90 monospace characters. Wider and deeply nested values become hard to trace back to their key. |
const before = text.slice(0, pos)
line = before.split('\\n').lengthSyntaxError: Unexpected string in JSON at position 118{
"notifyByEmail": true,
"theme": "dark"
}Not a checklist to run at the end. These are the requirements the component was built from.
Contrast
- Every syntax colour owes 4.5:1 against the inset surface, in both themes. Themes ported from an editor almost always fail on strings and comments.
- The gutter is non-content and may use the disabled tone — but the failing line’s number is content and owes full contrast.
- The error state is carried by border, message and gutter together, so it survives greyscale and High Contrast Mode.
- Do not distinguish syntax errors from schema errors by colour alone; the wording carries it.
Keyboard
| Tab | Inserts two spaces. Shift+Tab outdents. |
| Esc then Tab | Leaves the field. Required — without it, Tab-to-indent is a keyboard trap. |
| ⌘ / Ctrl + Enter | Submits, since Enter must insert a newline. |
| ⌘ / Ctrl + ⇧ + F | Formats. Optional, but it matches every editor the audience already uses. |
| ⌘ / Ctrl + A | Selects the document only. The gutter must never be included. |
Screen readers
- Announce the outcome, not the document: "Valid JSON" or "Line 6, column 5: expected a comma".
- Never announce the code as it is typed. A live region on a code field reading punctuation is the worst possible experience.
- For read-only payloads, state the size up front — "Response body, 24 lines" — so the user knows what they are about to arrow through.
Focus & touch
- The focus halo surrounds the whole control including the gutter, so it never looks like two adjacent fields. After a failed submit, focus moves into the field and the caret goes to the reported error position — putting the user exactly where the problem is.
- This is a desktop control. On a phone, an on-screen keyboard without braces, brackets and colons on the primary layer makes JSON editing genuinely hostile. Where you must ship it, provide a symbol bar above the keyboard with the six characters that matter, keep the field read-only where possible, and let the user copy the value out to edit elsewhere.
| Attribute | Applied to | Notes |
|---|---|---|
| aria-label | The field | Names what the document is: "Deployment configuration JSON", not "Editor". |
| aria-invalid | The field | Set when parsing fails. Not set for schema violations, which are valid documents with wrong values. |
| aria-errormessage | The field | Points at the message element, which must be rendered before it is referenced. |
| role="status" | The validation message | Polite, and debounced. Announcing on every keystroke is unusable. |
| aria-hidden | The gutter | Line numbers are visual scaffolding. The error message carries the line for anyone not looking at them. |
| spellcheck="false" | The field | Not ARIA, but the same intent: red squiggles under every key name make the real error impossible to find. |
Example usage
1import { JsonInput } from '@/ui/Input'23<Field label="Deployment config" description="Merged over the project defaults.">4 <JsonInput5 value={config}6 onChange={setConfig}7 rows={12}8 schema={deploymentSchema} // schema errors are reported separately9 />10</Field>1112// The component's real job: JSON.parse throws a BYTE OFFSET. A human needs13// a line and a column.14function parseWithPosition(text: string) {15 try {16 return { ok: true, value: JSON.parse(text) } as const17 } catch (e) {18 const raw = (e as Error).message19 const pos = Number(/position (\d+)/.exec(raw)?.[1] ?? -1)20 if (pos < 0) return { ok: false, line: 1, column: 1, message: raw } as const21 const before = text.slice(0, pos)22 return {23 ok: false,24 line: before.split('\n').length,25 column: pos - before.lastIndexOf('\n'),26 message: raw.replace(/ in JSON at position \d+.*/, ''),27 } as const28 }29}3031// Tab indents. Escape-then-Tab escapes, or this is a keyboard trap.32function onKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {33 if (e.key === 'Escape') return setEscaping(true)34 if (e.key === 'Tab' && escaping) return // let it bubble: leave35 if (e.key === 'Tab') {36 e.preventDefault()37 insertAtCaret(' ')38 }39 setEscaping(false)40}Framework-free HTML
<div class="ds-json">
<!-- Visual scaffolding only. The message carries the line for everyone else. -->
<div class="ds-json__gutter" aria-hidden="true">
<span>1</span>
<span>2</span>
<span class="is-error">3</span>
</div>
<textarea
id="config"
class="ds-json__body"
aria-label="Deployment configuration JSON"
aria-invalid="true"
aria-errormessage="config-error"
spellcheck="false"
autocapitalize="off"
autocorrect="off"
rows="12"
></textarea>
</div>
<!-- Debounced. Announcing on every keystroke is unusable. -->
<p id="config-error" role="status" aria-live="polite" class="ds-json__error">
Line 3, column 5: expected a comma
</p>CSS
.ds-json {
display: flex;
border: 1px solid var(--ds-border-interactive);
border-radius: var(--radius-md);
background: var(--ds-surface-inset);
overflow: hidden;
}
/* One ring around both halves, or it reads as two adjacent fields. */
.ds-json:focus-within {
border-color: var(--ds-accent);
box-shadow: 0 0 0 3px var(--ds-accent-subtle);
}
.ds-json__gutter {
flex: 0 0 auto;
min-inline-size: 2.5rem;
padding: 10px 8px 10px 12px;
border-inline-end: 1px solid var(--ds-border-subtle);
text-align: end;
/* Never selected, never copied. */
user-select: none;
font-family: var(--font-mono);
font-size: 11px;
line-height: 1.55; /* must match the body exactly */
color: var(--ds-fg-disabled);
}
/* This line IS content — it is the location of the fault. */
.ds-json__gutter .is-error {
color: var(--ds-danger-text);
font-weight: 600;
}
.ds-json__body {
flex: 1;
min-inline-size: 0;
padding: 10px;
border: 0;
background: none;
resize: vertical; /* the one field where users want more room */
font-family: var(--font-mono);
font-size: 13px;
line-height: 1.55;
tab-size: 2; /* two, not four: config nests deeply */
}
.ds-json[data-invalid='true'] { border-color: var(--ds-danger-border); }Component API
JsonInput
| Prop | Type | Default | Description |
|---|---|---|---|
| value* | string | — | The raw text, not a parsed object. Round-tripping through an object destroys the user’s formatting. |
| onChange* | (v: string) => void | — | Fires with the raw text on every keystroke. Parse downstream, on a debounce. |
| rows | number | 12 | Initial height. Vertical resize stays enabled. |
| schema | JSONSchema | — | Validates the parsed document. Schema errors are reported separately from syntax errors. |
| readOnly | boolean | false | Still focusable, selectable and copyable — a payload the user cannot select is one they will screenshot. |
| onValidChange | (value: unknown | null) => void | — | Fires with the parsed document, or null while it is unparseable. |
Professional tips
- Keep the raw text as the source of truth. Parsing to an object and stringifying back destroys the user’s formatting and their comments-as-whitespace.
- Disable submit while the document is unparseable, and say why on the button’s tooltip. Submitting broken JSON to find out it is broken is a wasted round trip.
- Show the schema’s defaults as a placeholder or a "Reset to defaults" action. Most users want a small change to a known-good document, not a blank field.
- If you accept YAML too, detect which one was pasted and say so rather than erroring — the two are pasted interchangeably from documentation.
- Autosave drafts. A long config lost to a navigation is the most avoidable frustration this component has.
Performance
- Debounce parsing by about 400ms. Parsing a large document on every keystroke is measurable, and every intermediate state is invalid anyway.
- Do not tokenise for syntax colouring on every render. Memoise on the text, or a large payload re-highlights on each keypress.
- Cap the document size you will validate live. Past a few hundred kilobytes, move validation to submit and say so.
- Keep the gutter and body line-heights identical to the pixel. Any drift and the numbers desynchronise from the lines they label, which is worse than having no gutter.
Common mistakes
- Showing the raw parser message with its byte offset.
- Validating on every keystroke, so an error flashes at the first open brace.
- Auto-formatting on type, which moves the caret and replaces the user’s indentation.
- Tab that leaves the field, making indentation impossible — or Tab with no escape hatch, which is a keyboard trap.
- A gutter that is selectable, so line numbers end up in the clipboard.
- Spellcheck left on, putting red squiggles under every key name.
- Line-height drift between gutter and body, desynchronising the numbers from the lines.
- Using it for a small known shape that should have been a form.
Real-world recommendations
- The users of this component are a small, expert minority — and they are usually the ones configuring the thing everyone else depends on. The error message quality matters more than the visual polish.
- Most edits start from a paste. Format-on-demand and a schema-default starting document remove nearly all of the friction.
- Where a form and a JSON view both exist, keep them in sync and let users switch. The form is for the common case; the raw view is the escape hatch, and knowing it exists is what makes the form acceptable.
- On mobile, do not pretend this works. Make it read-only with a copy button and let people edit on a machine with a real keyboard.