Need help with your JSON?

Try our JSON Formatter tool to automatically identify and fix syntax errors in your JSON. JSON Formatter tool

Implementing Tabs for Multiple JSON Documents

Opening several JSON payloads in one tool sounds simple until users start comparing API responses, editing drafts, or checking one config file against another. A useful tab system has to preserve document state, expose validation errors per tab, and let people move between documents without losing context.

The safest approach is to treat tabs as two problems: accessible navigation and JSON-specific workflow. Build the tab pattern correctly first, then add features such as dirty-state badges, draft persistence, and large-file safeguards so the interface still feels solid after the second or third document opens.

When Tabs Are the Right Pattern

Tabs work well when one JSON document is the main focus at a time and the user mostly needs quick switching, not simultaneous visibility. They are a strong fit for:

  • Comparing successive API responses while keeping the workspace in a single view.
  • Editing a request body, sample response, and schema without opening multiple browser windows.
  • Keeping environment-specific config files nearby during debugging.
  • Reviewing imported JSON files one by one before formatting, validating, or transforming them.

If users need to see two documents at the same time, add a split view or diff mode. Tabs are for fast context switching, not side-by-side analysis.

Accessibility Rules to Get Right First

If only one panel is shown at a time, follow the WAI-ARIA tabs pattern instead of styling a row of generic buttons and hoping screen readers infer the structure. A solid baseline includes:

  1. A tab container with role="tablist" and a clear accessible label.
  2. One interactive element per tab with role="tab", aria-selected, and a programmatic link to its panel.
  3. A visible panel with role="tabpanel" linked back to the active tab via aria-labelledby.
  4. Keyboard support for Left and Right Arrow, Home, End, and activation by Enter or Space.
  5. Roving focus so the selected tab stays in the normal tab order while inactive tabs use tabIndex={-1}.

Manual activation is often the best default for JSON tools: arrow keys move focus across the tab strip, and Enter or Space opens the focused document. That avoids expensive re-renders when each panel contains a large editor, formatter, or tree viewer. Automatic activation is fine when switching panels is effectively instant.

Use a Tab Model That Can Carry Document State

A simple activeTabId is not enough once users can edit data. Each tab should store the document state that needs to survive switching:

  • Stable ID: Use a real document ID, file hash, or generated draft ID. Do not rely on the array index for keys or ARIA relationships.
  • Title: File name, endpoint name, or a user-editable label.
  • Raw value: The current JSON string, even when it is temporarily invalid.
  • Validation state: Store parse errors per tab so one broken document does not block the rest of the workspace.
  • Dirty status: Track whether the document changed since the last save, import, or formatting step.

In React, keep those stable IDs in your data. The current React guidance is to use useId for accessibility relationships when needed, not as a replacement for list keys that should come from the data itself.

React Example: Accessible Tabs for Multiple JSON Documents

This example uses manual activation, validates each document independently, and keeps the active editor bound to the selected tab. It is intentionally small, but the structure scales to add close buttons, duplication, import actions, or persisted drafts.

Client Component Example

'use client';

import { type KeyboardEvent, useRef, useState } from 'react';

type JsonTab = {
  id: string;
  title: string;
  value: string;
  dirty: boolean;
  error: string | null;
};

const initialTabs: JsonTab[] = [
  {
    id: 'request-body',
    title: 'Request Body',
    value: '{\n  "userId": 42,\n  "includePosts": true\n}',
    dirty: false,
    error: null,
  },
  {
    id: 'response-preview',
    title: 'Response Preview',
    value: '{\n  "ok": true,\n  "count": 3\n}',
    dirty: false,
    error: null,
  },
];

function validateJson(value: string) {
  try {
    JSON.parse(value);
    return null;
  } catch (error) {
    return error instanceof Error ? error.message : 'Invalid JSON';
  }
}

export default function JsonTabs() {
  const [tabs, setTabs] = useState(initialTabs);
  const [activeId, setActiveId] = useState(initialTabs[0].id);
  const tabRefs = useRef<Array<HTMLButtonElement | null>>([]);

  const activeIndex = tabs.findIndex((tab) => tab.id === activeId);
  const activeTab = tabs[activeIndex];

  function focusTab(index: number) {
    tabRefs.current[index]?.focus();
  }

  function activateTab(tabId: string) {
    setActiveId(tabId);
  }

  function handleKeyDown(event: KeyboardEvent<HTMLButtonElement>, index: number) {
    if (event.key === 'ArrowRight') {
      event.preventDefault();
      focusTab((index + 1) % tabs.length);
      return;
    }

    if (event.key === 'ArrowLeft') {
      event.preventDefault();
      focusTab((index - 1 + tabs.length) % tabs.length);
      return;
    }

    if (event.key === 'Home') {
      event.preventDefault();
      focusTab(0);
      return;
    }

    if (event.key === 'End') {
      event.preventDefault();
      focusTab(tabs.length - 1);
      return;
    }

    if (event.key === 'Enter' || event.key === ' ') {
      event.preventDefault();
      activateTab(tabs[index].id);
    }
  }

  function updateTab(tabId: string, nextValue: string) {
    setTabs((currentTabs) =>
      currentTabs.map((tab) =>
        tab.id === tabId
          ? {
              ...tab,
              value: nextValue,
              dirty: true,
              error: validateJson(nextValue),
            }
          : tab
      )
    );
  }

  return (
    <section>
      <div role="tablist" aria-label="Open JSON documents" className="flex gap-2 border-b pb-2">
        {tabs.map((tab, index) => {
          const isSelected = tab.id === activeId;

          return (
            <button
              key={tab.id}
              ref={(node) => {
                tabRefs.current[index] = node;
              }}
              id={tab.id + '-tab'}
              role="tab"
              type="button"
              tabIndex={isSelected ? 0 : -1}
              aria-selected={isSelected}
              aria-controls={tab.id + '-panel'}
              onClick={() => activateTab(tab.id)}
              onKeyDown={(event) => handleKeyDown(event, index)}
              className={isSelected ? 'rounded-t border px-3 py-2 font-medium' : 'px-3 py-2 text-slate-600'}
            >
              {tab.title}
              {tab.dirty ? ' *' : ''}
            </button>
          );
        })}
      </div>

      <div
        id={activeTab.id + '-panel'}
        role="tabpanel"
        aria-labelledby={activeTab.id + '-tab'}
        className="mt-4 space-y-3"
      >
        <textarea
          value={activeTab.value}
          spellCheck={false}
          onChange={(event) => updateTab(activeTab.id, event.target.value)}
          className="min-h-72 w-full rounded border p-3 font-mono text-sm"
        />

        <p role="status" className={activeTab.error ? 'text-red-600' : 'text-emerald-700'}>
          {activeTab.error ? 'Invalid JSON: ' + activeTab.error : 'Valid JSON'}
        </p>
      </div>
    </section>
  );
}

The important pieces are the per-tab state, the keyboard handler for roving focus, and the explicit ARIA links between each tab and its panel.

JSON-Specific Features Worth Adding Early

Once the tab pattern works, the biggest usability gains come from features specific to JSON editing rather than from visual polish:

  • Validation per tab: Show parse errors next to the tab title or in a status line so users can find the broken document immediately.
  • Format on demand: Pretty-print only when the JSON parses successfully, and avoid rewriting the text area on every keystroke.
  • Unsaved-change indicators: Mark dirty tabs before allowing close, replace, or reset actions.
  • Restore drafts locally: Use sessionStorage for short-lived sessions or localStorage if users expect drafts to survive a restart.
  • Safe import behavior: When a user drops or uploads a new file, open it in a new tab instead of overwriting the current document unexpectedly.
  • Private by default: JSON often contains tokens, emails, or internal IDs, so keep drafts on-device unless the user explicitly chooses to sync or upload them.

Performance Tips for Large Documents

Tabs usually fail under load for avoidable reasons. The common problem is doing too much work every time the active editor changes or every time a user types a character.

  • Debounce expensive parsing, formatting, or schema validation once documents reach hundreds of KB.
  • Keep only the active editor mounted if each tab hosts a heavy code editor or tree viewer.
  • Cache derived views such as parsed objects or tree nodes by tab ID instead of recomputing everything.
  • Move very large formatting or diff work to a Web Worker so the tab strip stays responsive.
  • Preserve cursor and scroll position per tab so switching documents does not feel destructive.

Common Failure Case

Reformatting JSON on every keystroke looks impressive in a demo, but it often breaks cursor position, increases input latency, and makes partially typed JSON impossible to edit comfortably. Validate continuously if you want, but reserve full formatting for an explicit action or a short debounce.

Troubleshooting Checklist

  • If arrow keys do nothing, confirm the tab elements really have focus and handle keyboard events.
  • If screen readers do not announce the relationship, verify the tab and panel IDs match exactly.
  • If the wrong panel opens after closing a tab, store the active tab by stable ID, not by array index.
  • If users lose work after refresh, persist only the raw tab data and recreate derived parse state later.
  • If many tabs share the same file name, add a source label or path hint so the strip remains readable.

Conclusion

Implementing tabs for multiple JSON documents is less about the visual tab strip and more about preserving a trustworthy editing workflow. Start with the ARIA tab pattern, keep document state per tab, and design for invalid JSON and unsaved edits from the beginning.

Once that foundation is in place, you can add niceties such as drag-and-drop import, duplicate tab actions, and compare mode without rewriting the core interaction model.

Need help with your JSON?

Try our JSON Formatter tool to automatically identify and fix syntax errors in your JSON. JSON Formatter tool