API Playground
A try-it console that generates parameter, header and body editors from an operation definition, sends the request through an injected fetcher, and reports status, timing, size and headers with a pretty/raw toggle.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/api-playground.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "ApiPlayground" block with zod and
lucide-react, on the shadcn primitives button / input / label / badge /
dropdown-menu; cn() (clsx + tailwind-merge) for classes. This is the try-it
console under an endpoint in a developer portal: a method and path, editors
generated from the operation's parameter list, a body editor, one Send, and a
response pane. Its only job is to let someone call the endpoint for real
without lying to them about what was sent, how long it took, or what came back.
Contract
- A zod schema (`apiPlaygroundSchema` in a sibling contract file) is the single
source of truth and props are z.infer of it — never a parallel interface:
{ status; title; operation: ApiOperation | null; lastResponse: ApiResponse |
null; errorMessage? }.
- status is "loading" | "empty" | "error" | "ready" and describes the OPERATION
DEFINITION, not the request. The request has its own phase — idle / sending /
done / failed / cancelled — held separately. Collapsing the two is what makes
consoles claim "no endpoint selected" while a request is in flight.
- ApiOperation = { id; method: "GET"|"POST"|"PUT"|"PATCH"|"DELETE"|"HEAD"|
"OPTIONS"; baseUrl; path (template with {placeholders}); summary?;
parameters: ApiParameter[]; body: RequestBody | null }.
- ApiParameter = { name; in: "path"|"query"|"header"; kind:
"string"|"number"|"boolean"|"enum"; required; description?; initialValue?;
options?; placeholder?; secret? }. EVERY value is edited as text whatever the
kind — a half-typed number has to be representable, so the draft is never
typed. `secret` means: mask the input behind a reveal toggle, and substitute a
shell variable for the value in the copyable snippet.
- RequestBody = { contentType; template; required; description? }. null for an
operation that carries no body.
- ApiResponse = { status; statusText; durationMs; sizeBytes: number | null;
headers: {name,value}[]; body }. durationMs is MEASURED BY THE FETCHER and
sizeBytes may be null when the transport could not report it. headers may
repeat — Set-Cookie legitimately does.
- ApiRequest (what the fetcher receives) = { method; url; headers: Record<string,
string>; body: string | null }. Header values are REAL here: masking only ever
happens in the copyable snippet, never on the wire.
- Component props = the schema type plus: onSend?((request, signal) =>
Promise<ApiResponse>), onRetry?, showCurl? (true), bodyRows? (8, clamped
3-40), skeletonRows? (4, clamped 1-12), defaultResponseHeadersOpen? (false),
sendLabel? ("Send"). forwardRef<HTMLElement>, extends Omit<
React.HTMLAttributes<HTMLElement>, "title">, rest spread on the root <section>.
- There is NO fetch inside the component. onSend is the only way a request
leaves the browser, so auth, proxying, CORS and retries stay the host's
business and the block is testable with a function that resolves a literal.
Omit onSend and the console is a read-only request builder: no Send button is
painted at all, and the cURL snippet is still live.
- lastResponse seeds the pane and is an INITIAL value, not a controlled one, so
a parent re-render can never wipe a response the user just triggered.
- Export the pure parts: parameterKey, buildRequest, buildCurl, validateRequest,
hasIssues, responseClass, formatDuration, formatBytes, byteLength,
prettyPrintJson, shellVariableName. A request log, a test runner or a
code-generator should reuse them instead of growing a second opinion about
what this console just sent.
Behavior — four definition branches, not one plus three afterthoughts
- loading: the title still renders (it is known before the definition answers);
below it skeletonRows pulsing editor rows and a response placeholder,
aria-hidden, with aria-busy on the root.
- empty: one centred panel saying nothing is selected.
- error: a destructive-bordered panel printing errorMessage, plus a reload
button only when onRetry is passed.
- ready: request card + response card.
- status "ready" with a null operation renders the EMPTY branch: a console with
nothing to call is not ready, it is idle.
Behavior — the draft
- Values live in one Record keyed by `${in}:${name}`, NOT by name: `limit` may
legally be both a query parameter and a header, and a name-keyed map silently
fuses them.
- Each parameter starts on initialValue ?? "". The body starts on
body.template.
- The draft resets only when the operation identity or the status changes —
re-initialise during render (guarded by a stored seed string), never in an
effect, because an effect paints one frame of the previous operation's values
into the new form.
- Editors by kind: string/number -> Input (inputMode="decimal" for number);
enum with options and boolean -> a DropdownMenu radio group, with an extra
"Not set" item (value "") when the parameter is optional; an enum whose
options never arrived falls back to a text input, because a menu with nothing
in it cannot be opened and would strand the whole request.
- A secret parameter renders type="password" plus an aria-pressed reveal toggle,
and its menu trigger shows a FIXED run of bullets — a mask whose length tracks
the secret leaks the length.
Behavior — the maths
- URL: split the path on /(\{[^{}]+\})/, substitute each placeholder with
encodeURIComponent(value) of its path parameter, and keep the braces when the
parameter is missing or empty, so the preview shows exactly what is still
unfilled instead of collapsing into a silently wrong URL. Query parameters
with a non-empty value are percent-encoded and joined with "&" after a single
"?"; empty optional values are omitted, not sent blank. Join as
baseUrl.replace(/\/+$/, "") + path, adding a separator only when the path does
not start with one.
- Build the URL TWICE from one function with a target flag — "wire" (real
values, raw) and "snippet" (secrets replaced by $VAR, every other fragment
escaped for a double-quoted shell word) — so the two can never drift.
- Values are trimmed at the edges before they go on the wire (an HTTP token
cannot start with a space); the body is sent byte for byte.
- Content-Type is added only when a body is actually going out, and never
overrides one the definition already declares as a header parameter.
- cURL: `curl -X <METHOD> "<url>"` then one -H per non-empty header then -d with
the body single-quoted (escape ' as '\''). Header names and values are escaped
for double quotes; a secret prints as $NAME_UPPERCASED_WITH_UNDERSCORES. The
snippet therefore still runs once the variable is exported, and it is safe to
paste into an issue.
- validateRequest returns { fields: Record<key, sentence>; body: string | null;
unmatchedPath: string[] }: required-and-empty, a number that is not finite, a
boolean that is not "true"/"false", an enum value outside its options, a
missing required body, and a body that fails JSON.parse when the content type
matches /\bjson\b/. Empty optional values are NOT errors.
- A path placeholder no parameter declares is a blocking problem, not a warning:
nothing can ever fill it, so the URL would go out with a literal "{id}" in it.
- formatDuration: "<1 ms" below a millisecond, whole milliseconds below a
second, then seconds to two decimals. formatBytes: B / KB / MB. byteLength
counts UTF-8 bytes via TextEncoder (with a code-point fallback), never
string.length — one emoji is two code units and four bytes.
- responseClass buckets low to high — under 100 or 600+ is "unknown", then
informational / success / redirect / client error / server error — so a code
outside the range cannot fall into the last branch. Every class pairs a word
with its tint; colour never carries the state alone.
- sizeBytes null falls back to byteLength(body) and the pane SAYS it derived it.
Behavior — sending, cancelling and cleanup
- Send is one-shot per flight: the guard is a ref read AND written synchronously
inside the handler, because a state flag is only visible after a re-render and
a double click lands before that.
- Each send takes a monotonic stamp. A settle whose stamp is stale is dropped,
so a slow first response can never overwrite a newer one.
- Wrap the call as Promise.resolve().then(() => onSend(request, signal)) so a
fetcher that throws synchronously becomes a failed request rather than a
render crash.
- Cancel moves the stamp FIRST and then aborts, so a fetcher that ignores its
signal and resolves anyway can no longer paint a response the user already
walked away from. Cancelling says nothing about whether the server ran it, and
the pane admits that.
- One effect keyed on the same seed string returns a cleanup that bumps the
stamp, clears the lock and aborts the controller — so unmount AND an operation
swap both cancel the in-flight request. There are no other timers; the copy
helper owns its own and clears it on unmount.
- A rejected fetcher can throw anything; print Error.message, a non-empty string
as-is, and otherwise one honest fallback sentence. Never "[object Object]".
Behavior — response pane
- idle: one line saying nothing has been sent. sending: a spinner line plus a
pulsing placeholder. cancelled: one muted line. failed: a destructive box with
the message. done: the full pane.
- Header row: status code + statusText as a badge, the class word, the
fetcher-reported duration, the size (marked when derived).
- Pretty/raw is a pair of aria-pressed buttons. Pretty is
JSON.stringify(JSON.parse(body), null, 2); a body that will not parse LOCKS
the toggle to raw (aria-disabled plus a handler guard) and says "not JSON"
rather than showing an empty pretty pane. An empty body prints "(empty body)".
- Response headers sit behind an aria-expanded disclosure that toggles the
`hidden` attribute — never a collapsed 0fr grid track, which still holds its
tab stops and becomes an invisible keyboard trap. Put no `display:` utility on
that panel at all: any of them outranks `hidden` and the panel never hides. Key
rows by index, not by name: duplicates are legal.
- Copy buttons on the snippet and on the body report a refused clipboard
instead of faking a tick.
Behavior — keyboard and ARIA
- Root is <section aria-labelledby> pointing at the h2 title, aria-busy while
loading.
- Cmd/Ctrl+Enter anywhere inside the console sends — a handler on the root, not
a window listener, so it cannot hijack the rest of the page and needs no
cleanup. Escape cancels an in-flight request and calls stopPropagation ONLY
when it actually cancelled something, so a surrounding dialog keeps its own
Escape the rest of the time. Call the consumer's onKeyDown first and bail out
if it prevented the default.
- Tab order is document order, and the send controls sit at the TOP of the
request card: Cancel (while in flight), Send, then each parameter editor and
its reveal toggle, the body, the snippet copy, the view toggles, the header
disclosure, the body copy. This is a form, not a composite widget, so no roving
tabindex.
- Send uses aria-disabled plus a handler guard, never the native disabled
attribute: the browser blurs a focused disabled control to <body> and the
keyboard user loses their place. Pressing it while blocked is therefore still
a real press — it reveals the field messages and moves focus to the first
offending editor, then the body, then the summary.
- The summary paragraph is mounted for as long as the request is unsendable (not
only after a press) precisely so it can be focused: an element that appears in
the NEXT render cannot be focused in this one. Before the first press it
states the count in a neutral tone; a press turns it into the correction.
- When the request settles, focus is handed to Send BEFORE the Cancel button
unmounts, so a control never disappears out from under the caret.
- Each editor wires aria-invalid and an aria-describedby that joins its
description and its error message; the error id is only referenced while the
error is shown.
- One persistent live region: <span role="status" aria-atomic className="sr-only">
that speaks only when the PHASE changes (the definition branch, or the request
phase plus its stamp). The first paint is the baseline and is never announced,
so a screen reader is not read the console on load.
Rendering & styling
- Semantic tokens only, no hex / rgb / oklch anywhere: bg-card + border +
rounded-xl cards, bg-muted/50 for the URL preview, the snippet and the body
pane, text-muted-foreground for secondary text, bg-primary(/10) for GET and
success, bg-destructive(/10) for DELETE and the error classes, ring-ring
focus-visible rings with ring-offset-background. Swap the method and status
maps for var(--chart-1..5) if the console should read as a colour-coded tool.
- Method and status tints live in two objects (METHOD_TONE, CLASS_TONE) and are
the only place colour is decided. Both always pair the tint with a word.
- Everything the server can make long — a header name, a header value, a URL, a
parameter name — gets wrap-anywhere; the body and snippet panes are
max-h-56/72 overflow-auto with whitespace-pre-wrap, so a 40 KB payload scrolls
instead of stretching the page.
- Reduced motion: motion-reduce:animate-none on the skeleton pulse and the send
spinner, motion-reduce:transition-none on the disclosure chevron. Nothing
functional depends on any of it.
Customization levers
- Sub-blocks: showCurl={false} drops the snippet entirely; omit onSend and the
Send button is not painted (the builder and the snippet still work); pass an
operation with body: null and the body editor disappears rather than rendering
an empty shell; a parameter list with no headers drops that whole group.
- Density: bodyRows (8) sizes the body editor, skeletonRows (4) matches the
loading skeleton to the number of parameters you expect,
defaultResponseHeadersOpen opens the header list on first render.
- Editors: the kind -> editor mapping is one branch — add "file" or "date" there
and nothing else changes, because validation and URL building read the same
parameter list.
- Validation policy: validateRequest is one pure function. Loosen it (let an
unknown enum value through) or tighten it (require a specific header) without
touching a single component.
- Masking policy: shellVariableName decides what a secret becomes in the
snippet. Return a literal placeholder instead if your team pastes snippets
into a runner that cannot expand variables.
- Palette and shape: METHOD_TONE, CLASS_TONE and the rounded-xl / rounded-lg
pair are the whole visual identity; a branded console is a handful of edits in
two objects.
- Wiring: onSend is where a proxy, a signed request, a mock server or a recorded
fixture plugs in; onRetry is the definition fetch, not the request.Concepts
- Injected fetcher — the block never calls
fetch. It hands the host a fully composed request and anAbortSignaland waits, which is what lets the same console sit in front of a proxy, a signed gateway or a recorded fixture, and what makes it testable with a function that resolves a literal. - Two state machines — the definition has four states (loading / empty / error / ready) and the request has five (idle / sending / done / failed / cancelled). They are separate variables on purpose: a console that shares one is the one that claims no endpoint is selected while a request is still in the air.
- Latest-stamp-wins, cancel first — every send takes a monotonic stamp and a settle with a stale stamp is thrown away, so a slow first reply cannot overwrite a newer one. Cancel moves the stamp before it aborts, so even a fetcher that ignores its signal cannot paint a response the user already walked away from.
- Masked on the way out, never on the way in — the fetcher receives the real credential; the snippet receives
$AUTHORIZATION. Building the URL twice from one function with a target flag is what keeps the wire copy and the shareable copy from ever drifting apart. - Unfilled is visible, not inferred — a path placeholder with no value keeps its braces in the preview, and a placeholder no parameter declares is a blocking problem rather than a warning, because a URL containing a literal brace is a request nobody meant to send.
- Reported time, not measured time —
durationMsandsizeBytescome from the transport that actually did the work. The console reads no clock, which is why the same response renders identically on the server, after hydration, and in every screenshot.
Feature Announcement
A what's-new surface — modal dialog or anchored popover — that steps through the features a reader has not seen yet and reports every seen id plus the reason it closed.
Pricing Calculator
An interactive estimator whose billable axes re-derive the bill on every change — switching tier by itself when a cap is crossed, and naming the cheaper plan when another one wins.