RequestReel
A scripted HTTP request/response pane that plays a sequence of API calls — each lands, holds pending, then flashes its status pill and latency, resting on the full resolved transcript.
Install
$ npx shadcn@latest add wisp.pouriah.com/r/request-reel.jsonDemo · DOMscripted
request log · live
"use client";
import * as React from "react";
import { cx, useReducedMotion, useScriptedSequence } from "@pouriahlabs/wisp-ui";
/**
* RequestReel — a scripted HTTP request/response pane.
*
* Every API product, gateway and SDK landing page has to show traffic moving,
* and everyone hand-rolls it. Hand this an array of calls and it plays them:
* a request line lands, sits *pending* for a beat, then the status pill and
* latency flash in as the response arrives.
*
* The two-beat lifecycle is the reason each call is modelled as **two** scripted
* steps rather than one — a pending hold, then a settled hold. `shown` from
* `useScriptedSequence` counts those sub-steps, so a call at row `i` occupies
* indices `2i` (in flight) and `2i+1` (resolved). A single step per call could
* only reveal a row; it could never let the reader watch a request hang and then
* resolve, which is the one thing that makes the pane read as live traffic
* instead of a static list fading in.
*
* Structural rule shared with the rest of the tier: every row is laid out up
* front at a fixed height and revealed by opacity, and each cell sits in a
* fixed-width grid column. A pending pill becoming a `200`, an empty latency
* cell filling with `142 ms` — none of it changes a column's width, so the pane
* settles its height before the first frame and nothing below it ever reflows.
*
* Because the still frame is `steps.length` and the last sub-step of every call
* is its *resolved* one, the reduced-motion frame — and the resting frame before
* each loop — is the full transcript with every call showing its final status,
* never a box caught mid-flight. `useScriptedSequence` gives us that for free,
* along with pausing off-screen and in hidden tabs.
*
* Colour is carried by the semantic direction tokens (`--wisp-up`/`--wisp-down`)
* plus `--lantern`, applied inline so the component ships no palette of its own
* and re-tunes with the theme. Status is never colour-only: the numeric code is
* rendered as text and the pill carries an `aria-label` with the reason phrase,
* and the method sits beside it as a plain label.
*/
export interface ReeledRequest {
/** HTTP method, e.g. `GET`, `POST`, `DELETE`. */
method: string;
/** Request path, e.g. `/api/users`. */
path: string;
/** Response status code, e.g. `200`, `404`, `502`. */
status: number;
/** Round-trip latency in ms, shown once the call resolves. */
latency: number;
}
export interface RequestReelProps extends React.HTMLAttributes<HTMLDivElement> {
requests?: readonly ReeledRequest[];
/** Pause before replaying, ms. `0` runs once and rests on the finished transcript. */
loop?: number;
}
/** How long a call sits pending, and how long its resolved state holds, in ms. */
const PENDING_HOLD = 460;
const SETTLED_HOLD = 820;
export const DEFAULT_REQUESTS: readonly ReeledRequest[] = [
{ method: "GET", path: "/api/users", status: 200, latency: 142 },
{ method: "POST", path: "/api/users", status: 201, latency: 88 },
{ method: "GET", path: "/api/users/42", status: 200, latency: 63 },
{ method: "PATCH", path: "/api/users/42", status: 200, latency: 111 },
{ method: "GET", path: "/api/orders?limit=20", status: 200, latency: 97 },
{ method: "DELETE", path: "/api/sessions/x9f2", status: 204, latency: 54 },
{ method: "POST", path: "/api/webhooks/stripe", status: 502, latency: 1204 },
{ method: "GET", path: "/api/reports/q3", status: 404, latency: 39 },
];
/**
* Status class as a *direction*, not a brand: a 5xx is a failure, not "off
* palette". Each maps to a token so a product re-tunes it with the theme.
*/
type Tone = "up" | "warn" | "info" | "down";
function toneOf(status: number): Tone {
if (status >= 500) return "down"; // server error — red
if (status >= 400) return "warn"; // client error — amber
if (status >= 300) return "info"; // redirect — neutral
return "up"; // 2xx — green
}
const TONE: Record<Tone, string> = {
up: "var(--wisp-up, 162 72% 52%)",
down: "var(--wisp-down, 356 64% 60%)",
warn: "var(--lantern, 42 88% 62%)",
info: "var(--wisp-ink-faint, 198 9% 44%)",
};
/** The reason phrase behind a code, so a screen reader hears more than a number. */
const REASON: Record<number, string> = {
200: "OK",
201: "Created",
202: "Accepted",
204: "No Content",
301: "Moved Permanently",
302: "Found",
304: "Not Modified",
400: "Bad Request",
401: "Unauthorized",
403: "Forbidden",
404: "Not Found",
409: "Conflict",
422: "Unprocessable Entity",
429: "Too Many Requests",
500: "Internal Server Error",
502: "Bad Gateway",
503: "Service Unavailable",
504: "Gateway Timeout",
};
// One grid template drives the head row and every data row, so the columns line
// up and no cell's content can widen its column mid-play. `minmax(0, 1fr)` lets
// the path ellipsis rather than push the status and latency columns around.
const GRID = "4.7em minmax(0, 1fr) 3.6em 5.4em";
export function RequestReel({
requests = DEFAULT_REQUESTS,
loop = 2600,
className,
style,
...props
}: RequestReelProps) {
const host = React.useRef<HTMLDivElement>(null);
const viewport = React.useRef<HTMLDivElement>(null);
const track = React.useRef<HTMLDivElement>(null);
const still = useReducedMotion();
// Two steps per call — pending, then settled — so `shown` can express both
// beats of a request's life, not just its arrival.
const timings = React.useMemo(
() => requests.flatMap(() => [{ hold: PENDING_HOLD }, { hold: SETTLED_HOLD }]),
[requests],
);
const shown = useScriptedSequence(host, { steps: timings, loop });
const [offset, setOffset] = React.useState(0);
// The header stays put; only the rows scroll, so the follow measures the
// newest revealed row against its own clipped viewport rather than the whole
// pane. A row is "arrived" on its first sub-step, so its index is `shown - 1`
// halved and floored — the last row with any beat shown.
const measure = React.useCallback(() => {
const viewportEl = viewport.current;
const trackEl = track.current;
if (!viewportEl || !trackEl) return;
const arrived = Math.ceil(shown / 2);
const last = trackEl.children[arrived - 1] as HTMLElement | undefined;
if (!last) {
setOffset(0);
return;
}
const bottom = last.offsetTop + last.offsetHeight;
setOffset(Math.max(0, bottom - viewportEl.clientHeight));
}, [shown]);
const latestMeasure = React.useRef(measure);
latestMeasure.current = measure;
React.useLayoutEffect(() => {
measure();
}, [measure, requests]);
React.useEffect(() => {
const viewportEl = viewport.current;
if (!viewportEl) return;
const resizeObserver = new ResizeObserver(() => latestMeasure.current());
resizeObserver.observe(viewportEl);
return () => resizeObserver.disconnect();
}, []);
return (
<div
ref={host}
aria-label="Simulated API request activity"
className={cx("wisp-req", className)}
style={{
display: "flex",
flexDirection: "column",
overflow: "hidden",
fontFamily: "var(--wisp-mono, ui-monospace, SFMono-Regular, Menlo, monospace)",
fontSize: "calc(0.6875rem * var(--wisp-type-scale, 1))",
lineHeight: 1.5,
fontVariantNumeric: "tabular-nums",
color: "var(--wisp-req-muted, currentColor)",
// Merged last so a caller can hand the pane a bounded height — the
// full-screen viewer does, which is what lets the rows below scroll
// rather than grow the box.
...style,
}}
{...props}
>
{/* An endpoint bar with a breathing "live" dot — the difference between a
pane that is receiving traffic and a screenshot of one. `wisp-pulse` is
the shared utility, so the reduced-motion contract already stills it. */}
<div
style={{
display: "flex",
alignItems: "center",
gap: "0.5em",
marginBottom: "0.5rem",
opacity: 0.75,
}}
>
<span
className="wisp-pulse"
aria-hidden
style={{
width: "6px",
height: "6px",
borderRadius: "50%",
background: "hsl(var(--wisp-up, 162 72% 52%))",
}}
/>
<span style={{ flex: "none" }}>api.example.com</span>
<span style={{ marginLeft: "auto", opacity: 0.7 }}>{requests.length} calls</span>
</div>
{/* Column labels. Fixed to the same grid as the rows so nothing drifts. */}
<div
aria-hidden
style={{
display: "grid",
gridTemplateColumns: GRID,
gap: "0 0.6rem",
fontSize: "calc(0.5312rem * var(--wisp-type-scale, 1))",
letterSpacing: "0.13em",
textTransform: "uppercase",
opacity: 0.55,
paddingBottom: "6px",
marginBottom: "2px",
borderBottom: "1px solid hsl(var(--wisp-ink-faint, 198 9% 44%) / 0.2)",
}}
>
<span>Method</span>
<span>Path</span>
<span>Status</span>
<span style={{ textAlign: "right" }}>Latency</span>
</div>
{/* The rows get their own clipped viewport so a long run — or a full-screen
viewer's extra calls — scrolls the traffic under the fixed header
instead of growing the pane. `minHeight: 0` lets this flex child
actually shrink to the pane so `overflow` has something to clip. */}
<div
ref={viewport}
style={{ position: "relative", flex: "1 1 auto", minHeight: 0, overflow: "hidden" }}
>
<div
ref={track}
style={{
transform: offset ? `translateY(${-offset}px)` : undefined,
transition: still ? "none" : "transform .42s cubic-bezier(.2,.8,.2,1)",
}}
>
{requests.map((req, i) => {
// Derive both beats from a single counter: the row is in flight the
// moment its first sub-step lands, and resolved once its second does.
const revealed = shown >= 2 * i + 1;
const settled = shown >= 2 * i + 2;
const channels = TONE[toneOf(req.status)];
const reason = REASON[req.status] ?? "";
return (
<div
key={`${i}-${req.method}-${req.path}`}
style={{
display: "grid",
gridTemplateColumns: GRID,
gap: "0 0.6rem",
alignItems: "center",
minHeight: "1.95em",
// Reveal is compositor-only (opacity + a small lift), and off under
// reduced motion — where every row is already `revealed` on mount.
opacity: revealed ? 1 : 0,
transform: revealed ? "none" : "translateY(4px)",
transition: still ? "none" : "opacity 0.34s ease, transform 0.34s ease",
}}
>
<span
style={{
flex: "none",
letterSpacing: "0.02em",
color: "var(--wisp-req-ink, currentColor)",
opacity: 0.95,
whiteSpace: "nowrap",
}}
>
{req.method}
</span>
<span
style={{
minWidth: 0,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
opacity: 0.8,
}}
>
{req.path}
</span>
{/* The status cell holds a fixed-metrics pill in both states — a
pending placeholder and the resolved code are the same box, so
the flip never nudges the latency column. */}
<span style={{ justifySelf: "start" }}>
{settled ? (
// Keyed remount so `wisp-line-in` plays its one-shot pop as the
// response lands — the "flash" that says the call resolved.
<span
key="settled"
className="wisp-line-in"
aria-label={reason ? `${req.status} ${reason}` : String(req.status)}
title={reason ? `${req.status} ${reason}` : String(req.status)}
style={pill({
color: `hsl(${channels})`,
borderColor: `hsl(${channels} / 0.45)`,
background: `hsl(${channels} / 0.1)`,
})}
>
{req.status}
</span>
) : (
<span
key="pending"
className="wisp-pulse"
aria-label="Pending"
style={pill({
color: "hsl(var(--wisp-ink-faint, 198 9% 44%))",
borderColor: "hsl(var(--wisp-ink-faint, 198 9% 44%) / 0.4)",
borderStyle: "dashed",
background: "transparent",
letterSpacing: "0.08em",
})}
>
{"•••"}
</span>
)}
</span>
<span
style={{
justifySelf: "end",
textAlign: "right",
opacity: settled ? 0.75 : 0,
color: "var(--wisp-req-ink, currentColor)",
}}
>
{/* Reserved even when empty — the cell's width comes from the grid,
so the value appearing can't shift anything. */}
{settled ? `${req.latency} ms` : " "}
</span>
</div>
);
})}
</div>
</div>
</div>
);
}
/** Shared pill box metrics, so the pending and resolved states never differ in size. */
function pill(extra: React.CSSProperties): React.CSSProperties {
return {
display: "inline-flex",
alignItems: "center",
padding: "calc(1px * var(--wisp-type-scale, 1)) calc(5px * var(--wisp-type-scale, 1))",
borderWidth: "1px",
borderStyle: "solid",
lineHeight: 1.3,
...extra,
};
}
Certified against the contract
- ✓Reduced motion paints one composed still frame — never a blank box
- ✓Pauses off-screen and in hidden tabs
- ✓Re-reads design tokens when the theme changes
- ✓SSR-safe: no hydration mismatch, no layout shift
- ✓Decorative layers are aria-hidden and pointer-events-none
- ✓Device pixel ratio clamped
- ✓Zero runtime dependencies beyond React