# API 1: request explorer

> An API explorer: a method, an address, headers and a JSON body, then the answer with its status in words, time and size and the body as a tree; a broken body is caught before sending, with history and Copy as cURL.

A shadcn/ui block from the [@ballmac](https://ui.ballmac.com/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/ballmac/api-1).

- Page, with a live preview: https://shadcn.exchange/r/ballmac/api-1
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/ballmac/api-1-light-27afb7092539d72a.png
- Registry item: https://ui.ballmac.com/r/api-1.json
- Categories: blocks, api
- Listed: 2026-10-18, source last changed 2026-10-18

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/ballmac/api-1.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `lucide-react`
- Registry items: `utils`, `https://ui.ballmac.com/r/button.json`, `https://ui.ballmac.com/r/copy-button.json`, `https://ui.ballmac.com/r/input.json`, `https://ui.ballmac.com/r/json-viewer.json`, `https://ui.ballmac.com/r/select.json`, `https://ui.ballmac.com/r/skeleton.json`, `https://ui.ballmac.com/r/state-view.json`, `https://ui.ballmac.com/r/textarea.json`

## Files

### `registry/ballmac/components/blocks/api-1/api-1.tsx`

```tsx
// Ballmac UI: API 1. https://ui.ballmac.com/blocks/api-1
"use client"

import * as React from "react"
import { CircleAlert, History, Loader2, Plus, Send, Terminal, X } from "lucide-react"

import { Button } from "@/components/ballmac/button"
import { CopyButton } from "@/components/ballmac/copy-button"
import { Input } from "@/components/ballmac/input"
import { JsonViewer } from "@/components/ballmac/json-viewer"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ballmac/select"
import { Skeleton } from "@/components/ballmac/skeleton"
import { frameProps, StateView, type StateProps } from "@/components/ballmac/state-view"
import { Textarea } from "@/components/ballmac/textarea"
import { cn } from "@/lib/utils"

type Api1Method = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"
type Api1Pair = { id: string; key: string; value: string }
type Api1Request = { method: Api1Method; url: string; headers: Api1Pair[]; body: string }
type Api1Response = { status: number; statusText: string; ms: number; headers: Record<string, string>; body: unknown }

type Api1Props = Omit<React.ComponentProps<"section">, "title"> &
  StateProps & {
    title?: string
    /** Starting request. */
    defaultRequest?: Partial<Api1Request>
    /** Called to send the request. Return the response; reject for a network failure. Without it a built-in demo API answers. */
    onSend?: (request: Api1Request) => Promise<Api1Response>
    /** Show a response at first, for showing that screen. */
    defaultResponse?: Api1Response
}

const methods: Api1Method[] = ["GET", "POST", "PUT", "PATCH", "DELETE"]
const methodDot: Record<Api1Method, string> = { GET: "bg-chart-2", POST: "bg-chart-1", PUT: "bg-amber-500", PATCH: "bg-amber-500", DELETE: "bg-destructive" }

const users = [
  { id: "usr_1", name: "Mara Ellison", email: "mara@acme.com", role: "owner" },
  { id: "usr_2", name: "Theo Nakamura", email: "theo@acme.com", role: "admin" },
  { id: "usr_3", name: "Priya Shah", email: "priya@acme.com", role: "member" },
]

// A tiny pretend API so the block works with no server. It checks the same things a real one would.
async function demoApi(r: Api1Request): Promise<Api1Response> {
  const path = r.url.replace(/^https?:\/\/[^/]+/, "").split("?")[0]!
  const authed = r.headers.some((h) => h.key.toLowerCase() === "authorization" && h.value.trim().length > 7)
  const base = { headers: { "content-type": "application/json", "x-request-id": "req_8f3a1c" } }
  const ms = 90 + ((path.length * 37) % 140)
  await new Promise((res) => setTimeout(res, 350))
  if (!authed) return { ...base, status: 401, statusText: "Unauthorized", ms, body: { error: { code: "missing_credentials", message: "Add an Authorization header with a Bearer token." } } }
  if (path === "/v1/users" && r.method === "GET") return { ...base, status: 200, statusText: "OK", ms, body: { data: users, has_more: false } }
  const one = path.match(/^\/v1\/users\/(\w+)$/)
  if (one && r.method === "GET") {
    const u = users.find((x) => x.id === one[1])
    return u ? { ...base, status: 200, statusText: "OK", ms, body: u } : { ...base, status: 404, statusText: "Not Found", ms, body: { error: { code: "not_found", message: `No user with id ${one[1]}.` } } }
  }
  if (path === "/v1/users" && r.method === "POST") {
    let b: unknown
    try {
      b = JSON.parse(r.body || "{}")
    } catch {
      return { ...base, status: 400, statusText: "Bad Request", ms, body: { error: { code: "invalid_json", message: "The body is not valid JSON." } } }
    }
    const o = b as { name?: string; email?: string }
    if (!o.email) return { ...base, status: 422, statusText: "Unprocessable Entity", ms, body: { error: { code: "invalid_request", message: "email is required.", param: "email" } } }
    return { ...base, status: 201, statusText: "Created", ms, body: { id: "usr_4", name: o.name ?? "", email: o.email, role: "member" } }
  }
  return { ...base, status: 404, statusText: "Not Found", ms, body: { error: { code: "not_found", message: `No route for ${r.method} ${path}.` } } }
}

const pair = (id: string, key = "", value = ""): Api1Pair => ({ id, key, value })

function Skel() {
  return (
    <div aria-hidden="true" className="grid gap-4">
      <Skeleton className="h-12 rounded-xl" />
      <Skeleton className="h-64 rounded-2xl" />
    </div>
  )
}

const statusTone = (s: number) => (s >= 500 ? "bg-destructive/10 border-destructive/40" : s >= 400 ? "bg-amber-500/15 border-amber-500/40" : "bg-chart-2/10 border-chart-2/30")

/**
 * An API explorer: choose a method, type an address, add headers and a JSON body, send, and read the answer: its status in words, how long it
 * took, how big it was, and the body as a collapsible tree. A broken body is caught before sending, every request is kept in a history you can go
 * back to, and "Copy as cURL" writes the matching command. A built-in demo API answers when no handler is passed.
 */
function Api1({ title = "API explorer", defaultRequest, onSend, defaultResponse, state = "ready", onRetry, errorState, className, ...props }: Api1Props) {
  const id = React.useId()
  const [req, setReq] = React.useState<Api1Request>({ method: "GET", url: "https://api.acme.com/v1/users", headers: [pair("h1", "Authorization", "Bearer sk_test_4eC39HqLyjWDarjtT1zdp7dc"), pair("h2", "Accept", "application/json")], body: '{\n  "name": "Sam Okafor",\n  "email": "sam@acme.com"\n}', ...defaultRequest })
  // Ids for added rows count up from a ref, so they are the same on the server and in the browser.
  const nextId = React.useRef(3)
  const [tab, setTab] = React.useState<"headers" | "body">("headers")
  const [resTab, setResTab] = React.useState<"body" | "headers">("body")
  const [res, setRes] = React.useState<Api1Response | null>(defaultResponse ?? null)
  const [sending, setSending] = React.useState(false)
  const [failure, setFailure] = React.useState<string>()
  const [history, setHistory] = React.useState<{ req: Api1Request; status: number | null }[]>([])
  const hasBody = req.method !== "GET" && req.method !== "DELETE"

  const bodyError = React.useMemo(() => {
    if (!hasBody || !req.body.trim()) return null
    try {
      JSON.parse(req.body)
      return null
    } catch (e) {
      return e instanceof Error ? e.message.replace(/^JSON\.parse: /, "") : "Not valid JSON"
    }
  }, [hasBody, req.body])
  const urlError = /^https?:\/\/\S+$/.test(req.url.trim()) ? null : "Enter a full address that starts with https://"

  const curl = React.useMemo(() => {
    const parts = [`curl -X ${req.method} '${req.url}'`, ...req.headers.filter((h) => h.key.trim()).map((h) => `  -H '${h.key}: ${h.value}'`)]
    if (hasBody && req.body.trim()) parts.push(`  -d '${req.body.replace(/\s*\n\s*/g, " ").replace(/'/g, "'\\''")}'`)
    return parts.join(" \\\n")
  }, [req, hasBody])

  async function send() {
    if (urlError || bodyError || sending) return
    setSending(true)
    setFailure(undefined)
    try {
      const r = await (onSend ?? demoApi)(req)
      setRes(r)
      setHistory((h) => [{ req, status: r.status }, ...h].slice(0, 6))
    } catch (e) {
      setRes(null)
      setFailure(e instanceof Error ? e.message : "The request could not be sent.")
      setHistory((h) => [{ req, status: null }, ...h].slice(0, 6))
    } finally {
      setSending(false)
    }
  }
  const setPair = (pid: string, p: Partial<Api1Pair>) => setReq((r) => ({ ...r, headers: r.headers.map((h) => (h.id === pid ? { ...h, ...p } : h)) }))
  const size = res ? new Blob([JSON.stringify(res.body)]).size : 0

  const content = (
    <div className="grid gap-6">
      <form onSubmit={(e) => { e.preventDefault(); void send() }} noValidate>
        <div className="bg-card flex flex-col gap-2 rounded-2xl border p-2 sm:flex-row">
          <Select value={req.method} onValueChange={(v) => setReq({ ...req, method: v as Api1Method })}>
            <SelectTrigger aria-label="Method" className="w-full font-mono font-semibold sm:w-32">
              <SelectValue />
            </SelectTrigger>
            <SelectContent>
              {methods.map((m) => (
                <SelectItem key={m} value={m} className="font-mono font-semibold">
                  {m}
                </SelectItem>
              ))}
            </SelectContent>
          </Select>
          <Input dir="ltr" aria-label="Address" aria-invalid={!!urlError} value={req.url} onChange={(e) => setReq({ ...req, url: e.target.value })} className="flex-1 font-mono text-sm" spellCheck={false} />
          <Button type="submit" disabled={sending || !!urlError || !!bodyError}>
            {sending ? <Loader2 aria-hidden="true" className="animate-spin motion-reduce:animate-none" /> : <Send aria-hidden="true" className="rtl:-scale-x-100" />}
            {sending ? "Sending" : "Send"}
          </Button>
        </div>
        {urlError ? <p role="alert" className="text-destructive mt-2 ps-2 text-sm">{urlError}</p> : null}
      </form>

      <div className="grid items-start gap-6 lg:grid-cols-2">
        <div className="bg-card min-w-0 rounded-2xl border">
          <div role="tablist" aria-label="Request" className="flex gap-1 border-b px-3 pt-3">
            {([["headers", `Headers · ${req.headers.filter((h) => h.key.trim()).length}`], ["body", "Body"]] as const).map(([k, l]) => (
              <button key={k} type="button" role="tab" aria-selected={tab === k} onClick={() => setTab(k)} className={cn("focus-visible:ring-ring/50 -mb-px rounded-t-md border-b-2 px-3.5 py-2 text-sm font-medium outline-none focus-visible:ring-[3px]", tab === k ? "border-foreground" : "text-muted-foreground border-transparent")}>
                {l}
              </button>
            ))}
          </div>
          <div role="tabpanel" className="p-4">
            {tab === "headers" ? (
              <div className="grid gap-2.5">
                {req.headers.map((h, i) => (
                  <div key={h.id} className="flex items-center gap-2">
                    <Input dir="ltr" aria-label={`Header ${i + 1} name`} value={h.key} onChange={(e) => setPair(h.id, { key: e.target.value })} placeholder="Name" className="w-2/5 font-mono text-xs" spellCheck={false} />
                    <Input dir="ltr" aria-label={`Header ${i + 1} value`} value={h.value} onChange={(e) => setPair(h.id, { value: e.target.value })} placeholder="Value" className="flex-1 font-mono text-xs" spellCheck={false} />
                    <Button type="button" variant="ghost" size="icon" aria-label={`Remove header ${i + 1}`} onClick={() => setReq((r) => ({ ...r, headers: r.headers.filter((x) => x.id !== h.id) }))}>
                      <X aria-hidden="true" />
                    </Button>
                  </div>
                ))}
                <Button type="button" variant="outline" size="sm" className="w-fit" onClick={() => setReq((r) => ({ ...r, headers: [...r.headers, pair(`h${nextId.current++}`)] }))}>
                  <Plus aria-hidden="true" />
                  Add header
                </Button>
              </div>
            ) : hasBody ? (
              <div>
                <Textarea dir="ltr" aria-label="Request body (JSON)" aria-invalid={!!bodyError} value={req.body} onChange={(e) => setReq({ ...req, body: e.target.value })} rows={9} spellCheck={false} className="font-mono text-xs" />
                {bodyError ? (
                  <p role="alert" className="text-destructive mt-2 flex items-start gap-2 text-sm">
                    <CircleAlert aria-hidden="true" className="mt-0.5 size-4 shrink-0" />
                    The body is not valid JSON: {bodyError}
                  </p>
                ) : null}
              </div>
            ) : (
              <p className="text-muted-foreground py-6 text-center text-sm">{req.method} requests do not send a body.</p>
            )}
          </div>
          <div className="flex items-center justify-between gap-3 border-t px-4 py-3">
            <p className="text-muted-foreground flex items-center gap-1.5 text-xs">
              <Terminal aria-hidden="true" className="size-3.5" />
              Same request as a command
            </p>
            <CopyButton getValue={() => curl} label="Copy as cURL" copiedLabel="Copied" variant="outline" />
          </div>
        </div>

        <div className="bg-card min-w-0 rounded-2xl border" aria-live="polite" aria-busy={sending}>
          {sending ? (
            <div className="grid gap-3 p-6" aria-label="Waiting for a response">
              <Skeleton className="h-6 w-40" />
              <Skeleton className="h-44" />
            </div>
          ) : failure ? (
            <div role="alert" className="p-8 text-center">
              <span aria-hidden="true" className="bg-destructive/10 mx-auto flex size-12 items-center justify-center rounded-full">
                <CircleAlert className="size-6" />
              </span>
              <p className="mt-4 font-semibold">No response</p>
              <p className="text-muted-foreground mt-1 text-sm text-pretty">{failure}</p>
            </div>
          ) : res ? (
            <>
              <div className="flex flex-wrap items-center gap-x-4 gap-y-2 border-b px-4 py-3 text-sm">
                <span className={cn("rounded-md border px-2.5 py-1 font-mono font-semibold tabular-nums", statusTone(res.status))}>
                  {res.status} {res.statusText}
                </span>
                <span className="text-muted-foreground tabular-nums">{res.ms} ms</span>
                <span className="text-muted-foreground tabular-nums">{size >= 1024 ? `${(size / 1024).toFixed(1)} KB` : `${size} B`}</span>
              </div>
              <div role="tablist" aria-label="Response" className="flex gap-1 px-3 pt-3">
                {(["body", "headers"] as const).map((k) => (
                  <button key={k} type="button" role="tab" aria-selected={resTab === k} onClick={() => setResTab(k)} className={cn("focus-visible:ring-ring/50 -mb-px rounded-t-md border-b-2 px-3.5 py-2 text-sm font-medium capitalize outline-none focus-visible:ring-[3px]", resTab === k ? "border-foreground" : "text-muted-foreground border-transparent")}>
                    {k}
                  </button>
                ))}
              </div>
              <div role="tabpanel" className="max-h-96 overflow-auto border-t p-4">
                {resTab === "body" ? (
                  <JsonViewer value={res.body} label="Response body" defaultExpandedDepth={3} />
                ) : (
                  <dl className="grid gap-2 font-mono text-xs" dir="ltr">
                    {Object.entries(res.headers).map(([k, v]) => (
                      <div key={k} className="flex gap-3">
                        <dt className="text-muted-foreground w-40 shrink-0">{k}</dt>
                        <dd className="break-all">{v}</dd>
                      </div>
                    ))}
                  </dl>
                )}
              </div>
            </>
          ) : (
            <div className="p-10 text-center">
              <span aria-hidden="true" className="bg-muted mx-auto flex size-12 items-center justify-center rounded-full">
                <Send className="size-5 rtl:-scale-x-100" />
              </span>
              <p className="mt-4 font-semibold">Send a request</p>
              <p className="text-muted-foreground mt-1 text-sm text-pretty">The answer appears here with its status, how long it took and its body.</p>
            </div>
          )}
        </div>
      </div>

      {history.length ? (
        <div className="bg-card rounded-2xl border">
          <h2 className="flex items-center gap-2 border-b px-5 py-3.5 text-sm font-semibold">
            <History aria-hidden="true" className="size-4" />
            History
          </h2>
          <ul className="divide-y">
            {history.map((h, i) => (
              <li key={i}>
                <button type="button" onClick={() => setReq(h.req)} className="hover:bg-accent/40 focus-visible:ring-ring/50 flex w-full items-center gap-3 px-5 py-2.5 text-start text-sm outline-none transition-colors focus-visible:ring-[3px] focus-visible:ring-inset motion-reduce:transition-none">
                  <span className="flex w-16 items-center gap-2 font-mono text-xs font-semibold">
                    <span aria-hidden="true" className={cn("size-2 rounded-full", methodDot[h.req.method])} />
                    {h.req.method}
                  </span>
                  <span className="min-w-0 flex-1 truncate font-mono text-xs" dir="ltr">
                    {h.req.url.replace(/^https?:\/\//, "")}
                  </span>
                  <span className="text-muted-foreground font-mono text-xs tabular-nums">{h.status ?? "No reply"}</span>
                  <span className="sr-only">Use this request again</span>
                </button>
              </li>
            ))}
          </ul>
        </div>
      ) : null}
    </div>
  )

  return (
    <section data-slot="api-1" data-state={state} aria-labelledby={`${id}-title`} className={cn("mx-auto w-full max-w-6xl px-4 py-10 sm:px-6", className)} {...frameProps(props)}>
      <header className="mb-6">
        <h1 id={`${id}-title`} className="text-3xl font-semibold tracking-[-0.035em] text-balance">
          {title}
        </h1>
        <p className="text-muted-foreground mt-2 max-w-xl text-pretty">Try a request without leaving the page.</p>
      </header>
      <StateView state={state === "loading" || state === "error" ? state : "ready"} onRetry={onRetry} label="Loading the explorer" loading={<Skel />} error={{ title: errorState?.title ?? "The explorer could not be loaded", description: errorState?.description ?? "Check your connection and try again.", ...errorState }}>
        {content}
      </StateView>
    </section>
  )
}

export { Api1, type Api1Props, type Api1Request, type Api1Response, type Api1Method }
```
