Blocks

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  Ban,
  Check,
  ChevronDown,
  CircleDashed,
  Copy,
  Eye,
  EyeOff,
  LoaderCircle,
  OctagonAlert,
  Play,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/api-playground.json

Prompt

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 an AbortSignal and 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 timedurationMs and sizeBytes come 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.

On This Page