Skip to content

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.

Live preview

Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.

Playground

Valid JSON

Merged over the project defaults on every deploy.

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.

Line 7, column 5: Expected ',' or '}' after property value

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.

Pasted
{"project":"api-gateway","region":"eu-west-2","replicas":3}
After Format
{ "project": "api-gateway", "region": "eu-west-2", "replicas": 3 }

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.

Line 6, column 5: expected a comma — the document could not be parsed.
Line 4: “replicas” must be between 1 and 12 — the document parsed, but the value is not allowed.

Read-only payloads

Still focusable, still selectable, still copyable. A response body the user cannot select is a response body they will screenshot.

Valid JSON

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

Valid

Line 3, column 3: Expected ',' or '}' after property value

Invalid

Line 1, column 1: Unexpected end of JSON input

Empty

Valid JSON

Read only
6
Error line
1
2
3
Gutter
Valid JSON
Valid message
Format action

Anatomy

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.

  1. 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.

  2. 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.

  3. 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.

  4. Tab width2 spaces

    Two, not four. Config nests deeply and four spaces pushes the values off a narrow field.

  5. 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.

  6. Validation delay400ms after typing stops

    Every partial object is invalid on the way to valid. Erroring instantly trains people to ignore the message.

  7. Format actionBelow, right-aligned

    Explicit, never automatic. Reformatting while typing moves the caret out from under the user.

Design tokens used

Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.

Color

TokenValueUsed 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

TokenValueUsed for
--radius-mdField corners

Typography

TokenValueUsed for
font-monoEditor body and gutter

Motion

TokenValueUsed for
debounceValidation delay

Recommended sizes

Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.

SizeHeightPaddingTypeMin widthMax widthWhen to use
Compact6 rows8px12px——A small payload inside a dialog or a table row expansion.
Default12 rows10px13px——The default. Deep enough to see a nested object whole.
Tall20 rows10px13px——A dedicated configuration page where the field is the screen.
Gutter——11px2.5rem—Right-aligned, unselectable, widening only past 999 lines.
Measure————48remAbout 90 monospace characters. Wider and deeply nested values become hard to trace back to their key.

Do

const before = text.slice(0, pos)
line = before.split('\\n').length
Convert the parser offset into a line and column"Unexpected string at position 118" is a riddle. "Line 6, column 5: expected a comma" is an instruction, and it is a dozen lines of arithmetic.
Tab → indent·Esc then Tab → leave
Make Tab indent, and Escape-then-Tab escapeA code field that does not indent is unusable. One that swallows Tab with no escape hatch is a keyboard trap and a WCAG 2.1.2 failure.
Could not be parsed — line 6Parsed, but “replicas” must be 1–12
Separate syntax errors from schema errorsValid JSON can still be wrong. A user hunting for a missing brace when the real fault is an unknown key will never find it.
Offer Format as an explicit actionPeople paste minified JSON constantly. One press to make it readable is the highest-value affordance on the component — and it must never fire on its own.

Don't

SyntaxError: Unexpected string in JSON at position 118
Do not show the raw parser messageA byte offset is meaningless without counting characters by hand, and every engine words it differently. Translate it or say nothing.
{ → “Unexpected end of input” → {" → “Unexpected end of input” → …
Do not validate on every keystrokeEvery partial object is invalid on the way to being valid. An error that flashes at the first open brace teaches people to ignore the error line.
onChange → JSON.stringify(parse(v), null, 2) → caret at end
Do not reformat while the user typesThe caret jumps out from under them mid-word and their indentation is replaced by yours. Formatting is a request, not a policy.
{ "notifyByEmail": true, "theme": "dark" }
Do not use it for a known, small shapeNobody should hand-write JSON for four settings. Every character of syntax is a chance to fail at a task a form would have made impossible to get wrong.

Accessibility

Not a checklist to run at the end. These are the requirements the component was built from.

1.4.3Contrast (Minimum)AA2.1.1KeyboardA2.1.2No Keyboard TrapA3.3.1Error IdentificationA3.3.3Error SuggestionAA

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

TabInserts two spaces. Shift+Tab outdents.
Esc then TabLeaves the field. Required — without it, Tab-to-indent is a keyboard trap.
⌘ / Ctrl + EnterSubmits, since Enter must insert a newline.
⌘ / Ctrl + ⇧ + FFormats. Optional, but it matches every editor the audience already uses.
⌘ / Ctrl + ASelects 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.
AttributeApplied toNotes
aria-labelThe fieldNames what the document is: "Deployment configuration JSON", not "Editor".
aria-invalidThe fieldSet when parsing fails. Not set for schema violations, which are valid documents with wrong values.
aria-errormessageThe fieldPoints at the message element, which must be rendered before it is referenced.
role="status"The validation messagePolite, and debounced. Announcing on every keystroke is unusable.
aria-hiddenThe gutterLine numbers are visual scaffolding. The error message carries the line for anyone not looking at them.
spellcheck="false"The fieldNot ARIA, but the same intent: red squiggles under every key name make the real error impossible to find.

Code

Example usage

tsx
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

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

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

PropTypeDefaultDescription
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.
rowsnumber12Initial height. Vertical resize stays enabled.
schemaJSONSchema—Validates the parsed document. Schema errors are reported separately from syntax errors.
readOnlybooleanfalseStill 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.

Notes

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.