Docs Response Tabs with Copy-to-Clipboard

Frontend Engineering beginner Building with AI ~15 min read

Overview

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

Core Concepts

WAI-ARIA Tablist Pattern

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.

Syntax Highlighting with react-syntax-highlighter

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.

Clipboard API with Feedback States

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 Pretty-Printing

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.

How It Works

Response Data Model

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',
    },
  },
];

ResponseTabs Component

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>
  );
}

Copy Button Feedback Animation

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;
}

Practical Applications

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.

Implementation Patterns

Roving tabIndex for Keyboard Navigation

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.

Normalizing JSON Before Display

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.

Status Code Color Coding

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.

Common Pitfalls

Further Reading