Data Display

Webhook Log

A live monospace webhook delivery stream that pauses on hover and offscreen; new rows glow with a decaying highlight and status codes are tinted by class.

Install

npx shadcn@latest add @paragon/webhook-log

webhook-log.tsx

"use client";

import * as React from "react";
import { useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

export interface WebhookEvent {
  id: string;
  method: string;
  /** Event type / route, e.g. "charge.succeeded". */
  event: string;
  status: number;
  /** Response latency in ms. */
  latency: number;
}

export interface WebhookLogProps extends React.ComponentProps<"div"> {
  /** Event pool cycled deterministically into the stream. */
  events?: WebhookEvent[];
  /** ms between synthetic deliveries. */
  interval?: number;
  /** Max rows kept in view before the oldest scrolls off. */
  maxRows?: number;
}

const POOL: WebhookEvent[] = [
  { id: "evt_1", method: "POST", event: "charge.succeeded", status: 200, latency: 42 },
  { id: "evt_2", method: "POST", event: "customer.created", status: 200, latency: 31 },
  { id: "evt_3", method: "POST", event: "invoice.finalized", status: 202, latency: 88 },
  { id: "evt_4", method: "POST", event: "charge.refunded", status: 200, latency: 56 },
  { id: "evt_5", method: "POST", event: "subscription.updated", status: 500, latency: 512 },
  { id: "evt_6", method: "POST", event: "payout.paid", status: 200, latency: 47 },
  { id: "evt_7", method: "POST", event: "dispute.created", status: 429, latency: 19 },
  { id: "evt_8", method: "POST", event: "checkout.completed", status: 200, latency: 63 },
  { id: "evt_9", method: "POST", event: "invoice.payment_failed", status: 404, latency: 27 },
  { id: "evt_10", method: "POST", event: "customer.deleted", status: 204, latency: 22 },
];

function statusTint(status: number) {
  if (status >= 500) return "text-destructive";
  if (status >= 400) return "text-warning";
  if (status >= 300) return "text-muted-foreground";
  return "text-success";
}

interface Row extends WebhookEvent {
  key: number;
  /** Deterministic clock label seeded per row. */
  time: string;
}

/**
 * A live webhook delivery stream. New rows enter at the bottom, glow once with
 * a decaying highlight, then settle; the oldest row scrolls off once the buffer
 * is full. The stream pauses while hovered (so you can read a line) and while
 * scrolled offscreen (via IntersectionObserver — no work when unseen). Status
 * codes are tinted by class: 2xx success, 3xx muted, 4xx warning, 5xx
 * destructive. Under reduced motion the highlight is a static tint with no
 * decay animation. Timestamps derive from a mounted base clock, never
 * Date.now() during render.
 */
export function WebhookLog({
  events = POOL,
  interval = 1400,
  maxRows = 7,
  className,
  ...props
}: WebhookLogProps) {
  const reduced = useReducedMotion();
  const [rows, setRows] = React.useState<Row[]>([]);
  const [paused, setPaused] = React.useState(false);
  const containerRef = React.useRef<HTMLDivElement>(null);
  const [visible, setVisible] = React.useState(true);
  const counter = React.useRef(0);
  // Base clock captured once, on the client, after mount — never during render.
  const baseTime = React.useRef<number | null>(null);

  React.useEffect(() => {
    baseTime.current = Date.now();
  }, []);

  // Pause when scrolled out of view — no synthetic work off-screen.
  React.useEffect(() => {
    const node = containerRef.current;
    if (!node || typeof IntersectionObserver === "undefined") return;
    const io = new IntersectionObserver(
      ([entry]) => setVisible(entry.isIntersecting),
      { threshold: 0.1 },
    );
    io.observe(node);
    return () => io.disconnect();
  }, []);

  React.useEffect(() => {
    if (paused || !visible) return;
    const tick = () => {
      const n = counter.current++;
      const template = events[n % events.length];
      const t = (baseTime.current ?? 0) + n * interval;
      const time = new Intl.DateTimeFormat("en-US", {
        hour: "2-digit",
        minute: "2-digit",
        second: "2-digit",
        hour12: false,
      }).format(new Date(t));
      setRows((prev) => {
        const next = [...prev, { ...template, key: n, time }];
        return next.slice(-maxRows);
      });
    };
    tick();
    const id = setInterval(tick, interval);
    return () => clearInterval(id);
  }, [paused, visible, events, interval, maxRows]);

  return (
    <div
      ref={containerRef}
      data-slot="webhook-log"
      onPointerEnter={() => setPaused(true)}
      onPointerLeave={() => setPaused(false)}
      onFocusCapture={() => setPaused(true)}
      onBlurCapture={() => setPaused(false)}
      className={cn(
        "w-full overflow-hidden rounded-xl bg-card font-mono text-xs text-card-foreground shadow-border",
        className,
      )}
      {...props}
    >
      <style href="paragon-webhook-log" precedence="paragon">{`
        @keyframes pg-webhook-decay {
          from { background-color: color-mix(in oklch, var(--color-primary) 12%, transparent); }
          to { background-color: transparent; }
        }
        @media (prefers-reduced-motion: reduce) {
          [data-webhook-row] { animation: none !important; }
        }
      `}</style>
      <div className="flex items-center justify-between border-b px-3 py-2">
        <span className="font-sans text-[11px] font-medium text-muted-foreground">
          Webhook deliveries
        </span>
        <span
          className={cn(
            "inline-flex items-center gap-1.5 font-sans text-[11px] transition-colors duration-(--duration-fast)",
            paused ? "text-muted-foreground" : "text-success",
          )}
        >
          <span
            className={cn(
              "size-1.5 rounded-full",
              paused ? "bg-muted-foreground/50" : "bg-success",
            )}
          />
          {paused ? "Paused" : "Live"}
        </span>
      </div>
      <div className="h-[196px] overflow-hidden">
        <div className="flex h-full flex-col justify-end">
          {rows.map((row) => (
            <div
              key={row.key}
              data-webhook-row
              className="flex items-center gap-3 px-3 py-1 whitespace-nowrap tabular-nums"
              style={
                reduced
                  ? undefined
                  : { animation: "pg-webhook-decay 1200ms var(--ease-out) forwards" }
              }
            >
              <span className="text-muted-foreground/70">{row.time}</span>
              <span className="w-10 shrink-0 text-muted-foreground">
                {row.method}
              </span>
              <span className="min-w-0 flex-1 truncate text-foreground">
                {row.event}
              </span>
              <span className={cn("w-8 shrink-0 text-right", statusTint(row.status))}>
                {row.status}
              </span>
              <span className="w-14 shrink-0 text-right text-muted-foreground">
                {row.latency}ms
              </span>
            </div>
          ))}
        </div>
      </div>
    </div>
  );
}