API documentation frequently needs to show multiple response examples for the same endpoint — a successful 200 response, a 400 validation error, a 401 authentication failure, and a 500 server error. Displaying all of these inline creates a wall of JSON that’s hard to scan. A tabbed interface groups responses by HTTP status code, letting users click to the response they care about while keeping the rest hidden. Combined with syntax-highlighted JSON and a copy-to-clipboard button, this pattern is the standard for developer-facing documentation.
A well-designed documentation site might implement a ResponseTabs React component that accepts an array of response examples (each with a status code, description, and JSON body), renders them as an accessible tablist, pretty-prints the JSON with react-syntax-highlighter, and provides a copy button that writes the raw JSON to the clipboard with visual feedback. The component is client-side only — it uses the browser’s Clipboard API and manages tab state with React’s useState.
While the individual pieces (tabs, syntax highlighting, clipboard) are straightforward, combining them correctly requires attention to accessibility (keyboard navigation between tabs, ARIA roles), UX polish (copy button feedback states), and performance (syntax highlighting large JSON payloads without blocking the main thread).
The WAI-ARIA tabs pattern defines roles (tablist, tab, tabpanel) and keyboard behaviors (arrow keys to move between tabs, Enter/Space to activate, Home/End to jump to first/last). Implementing this correctly means each tab has role="tab", aria-selected, and aria-controls pointing to its panel. The panel has role="tabpanel" and aria-labelledby pointing back to its tab. This two-way association lets screen readers announce “Tab: 200 OK, selected, 1 of 4” and navigate directly to the associated panel.
react-syntax-highlighter wraps Prism.js or highlight.js in a React component, rendering tokenized source code as styled <span> elements. For JSON, it colorizes keys, string values, numbers, booleans, and nulls differently. The component accepts a style prop for theme selection (e.g., oneDark, dracula) and a language prop. It processes the entire string synchronously on render, so very large payloads can block — keep example responses under a few KB.
The modern Clipboard API (navigator.clipboard.writeText()) is asynchronous and requires a secure context (HTTPS or localhost). The copy button cycles through three states: idle (“Copy”), success (“Copied!”), and error (“Failed”). After a successful copy, the button shows the success state for 2 seconds before reverting to idle. This feedback is essential — without it, users click the button repeatedly because they can’t tell if it worked.
JSON.stringify(obj, null, 2) produces human-readable JSON with 2-space indentation. The component accepts either a pre-formatted JSON string or a JavaScript object and normalizes to a formatted string before passing to the syntax highlighter. Parsing and re-stringifying ensures consistent formatting regardless of how the example was authored in the documentation source.
Each response example is a typed object with the HTTP status code, a human-readable description, and the response body as a JavaScript object or pre-formatted string.
// types/api-docs.ts
export interface ResponseExample {
status: number;
statusText: string; // e.g., "OK", "Bad Request"
description: string; // e.g., "Successful search query"
body: Record<string, unknown> | string;
headers?: Record<string, string>; // optional response headers
}
// Example usage in documentation page
const searchResponses: ResponseExample[] = [
{
status: 200,
statusText: 'OK',
description: 'Successful search with results',
body: {
hits: [
{ id: 'doc_1', score: 0.95, title: 'Getting Started' },
{ id: 'doc_2', score: 0.87, title: 'Configuration Guide' },
],
total: 2,
query_time_ms: 12,
},
},
{
status: 400,
statusText: 'Bad Request',
description: 'Invalid query syntax',
body: {
error: 'invalid_query',
message: 'Unexpected token at position 15',
details: { position: 15, token: '&&' },
},
},
{
status: 401,
statusText: 'Unauthorized',
description: 'Missing or invalid API key',
body: {
error: 'unauthorized',
message: 'Invalid API key provided',
},
},
];
The component manages active tab state, renders the tablist with keyboard navigation, and delegates JSON display to the syntax highlighter.
// components/ResponseTabs.tsx
'use client';
import { useState, useRef, useCallback } from 'react';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
import type { ResponseExample } from '@/types/api-docs';
function statusColor(status: number): string {
if (status < 300) return '#00d4aa'; // green for 2xx
if (status < 400) return '#f0c040'; // yellow for 3xx
if (status < 500) return '#ff6b8a'; // red for 4xx
return '#ff4444'; // bright red for 5xx
}
function formatBody(body: Record<string, unknown> | string): string {
if (typeof body === 'string') {
try {
return JSON.stringify(JSON.parse(body), null, 2);
} catch {
return body;
}
}
return JSON.stringify(body, null, 2);
}
interface CopyButtonProps {
text: string;
}
function CopyButton({ text }: CopyButtonProps) {
const [state, setState] = useState<'idle' | 'copied' | 'error'>('idle');
const timerRef = useRef<ReturnType<typeof setTimeout>>();
const handleCopy = useCallback(async () => {
try {
await navigator.clipboard.writeText(text);
setState('copied');
} catch {
setState('error');
}
clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => setState('idle'), 2000);
}, [text]);
const label = { idle: 'Copy', copied: 'Copied!', error: 'Failed' }[state];
return (
<button onClick={handleCopy} className="copy-btn" aria-label="Copy response JSON">
{label}
</button>
);
}
export function ResponseTabs({ responses }: { responses: ResponseExample[] }) {
const [activeIndex, setActiveIndex] = useState(0);
const tabsRef = useRef<(HTMLButtonElement | null)[]>([]);
const handleKeyDown = (e: React.KeyboardEvent, index: number) => {
let next = index;
if (e.key === 'ArrowRight') next = (index + 1) % responses.length;
else if (e.key === 'ArrowLeft') next = (index - 1 + responses.length) % responses.length;
else if (e.key === 'Home') next = 0;
else if (e.key === 'End') next = responses.length - 1;
else return;
e.preventDefault();
setActiveIndex(next);
tabsRef.current[next]?.focus();
};
const active = responses[activeIndex];
const formatted = formatBody(active.body);
return (
<div className="response-tabs">
<div role="tablist" aria-label="API responses" className="tab-bar">
{responses.map((r, i) => (
<button
key={r.status}
ref={el => { tabsRef.current[i] = el; }}
role="tab"
id={`tab-${r.status}`}
aria-selected={i === activeIndex}
aria-controls={`panel-${r.status}`}
tabIndex={i === activeIndex ? 0 : -1}
className={`status-tab ${i === activeIndex ? 'active' : ''}`}
onClick={() => setActiveIndex(i)}
onKeyDown={e => handleKeyDown(e, i)}
>
<span className="status-dot"
style={{ background: statusColor(r.status) }} />
{r.status} {r.statusText}
</button>
))}
</div>
<div role="tabpanel" id={`panel-${active.status}`}
aria-labelledby={`tab-${active.status}`} className="tab-panel">
<p className="response-desc">{active.description}</p>
<div className="code-block">
<CopyButton text={formatted} />
<SyntaxHighlighter language="json" style={oneDark}
customStyle={{ margin: 0, borderRadius: 8, fontSize: 13 }}>
{formatted}
</SyntaxHighlighter>
</div>
</div>
</div>
);
}
The copy button uses CSS transitions to animate between states. The “Copied!” state gets a green background, and the “Failed” state gets a red background, both fading back to the default after the timer resets.
/* styles for the copy button states */
.copy-btn {
position: absolute;
top: 8px;
right: 8px;
padding: 4px 12px;
font-size: 12px;
font-weight: 600;
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 4px;
background: rgba(255, 255, 255, 0.08);
color: #ccc;
cursor: pointer;
transition: all 0.2s ease;
}
.copy-btn:hover {
background: rgba(255, 255, 255, 0.15);
color: #fff;
}
/* Applied via inline styles or data attributes based on state */
.copy-btn[data-state="copied"] {
background: rgba(0, 212, 170, 0.2);
border-color: rgba(0, 212, 170, 0.4);
color: #00d4aa;
}
.copy-btn[data-state="error"] {
background: rgba(255, 107, 138, 0.2);
border-color: rgba(255, 107, 138, 0.4);
color: #ff6b8a;
}
A documentation site using this pattern would place ResponseTabs on every API endpoint page. The search endpoint alone has six response examples (200 with results, 200 with empty results, 400 for bad syntax, 401, 403 for rate limiting, and 500). Without tabs, that’s six JSON blocks stacked vertically — over a screenful of code that users must scroll past to reach the next section. Tabs collapse this to a single visible block with a compact tab bar.
The copy-to-clipboard feature sees heavy use from developers integrating the API. They copy the 200 response example into their test fixtures, or copy the error response into their error-handling code to match against. The feedback state (“Copied!”) was added after user testing showed that developers would click the button, see no change, and paste — only to find the clipboard still held whatever they’d copied before, because the Clipboard API had silently failed due to a permissions issue.
The component is also well-suited for showing different response formats — JSON, MessagePack, and Protobuf (the latter two shown as hex dumps). The language prop on the syntax highlighter switches between json and plaintext based on the response content type.
The active tab has tabIndex={0} and all inactive tabs have tabIndex={-1}. This means pressing Tab from outside the tablist focuses the active tab, and arrow keys move between tabs. This is the “roving tabIndex” pattern recommended by WAI-ARIA — it keeps the tab order simple (one tab stop for the entire tablist) while allowing full keyboard navigation within.
Documentation authors might write JSON examples inline as strings with inconsistent formatting, or as JavaScript objects in MDX files. The formatBody function normalizes both cases: strings are parsed and re-stringified with consistent indentation, objects are stringified directly. This ensures every response panel shows identically formatted JSON regardless of how the source was authored.
The colored dot next to each status code uses a simple range-based color function: 2xx is green (success), 3xx is yellow (redirect), 4xx is pink/red (client error), 5xx is bright red (server error). This visual shorthand matches the convention developers already know from browser DevTools network tabs, reducing cognitive load.
navigator.clipboard.writeText() only works on HTTPS or localhost. If the docs site is accessed via HTTP (e.g., internal staging), the copy button silently fails. Always handle the error case and show the “Failed” state — or fall back to the deprecated document.execCommand('copy') with a textarea workaround for insecure contexts.wrapLongLines prop and lazy-load the syntax highlighter with React.lazy() to avoid blocking initial page render.aria-controls on the tab doesn’t match the id on the panel, or if aria-labelledby is missing from the panel, screen readers can’t associate the tab with its content. Users hear “tab, 200 OK” but pressing Enter doesn’t announce the panel content. Test with VoiceOver or NVDA to verify the association works.setState is called on an unmounted component. Use a useEffect cleanup or an AbortController pattern to cancel the timer on unmount.status as the React key causes rendering bugs. Use a unique identifier (index or a slug combining status + description) instead of the status code alone.