Display streaming performance stats — TTFT, total time, tok/s, and chunk count — as a badge with hover popover.
This component is experimental. The API and displayed metrics may change in future versions. When used with the Vercel AI SDK, token counts and tok/s are estimated client-side and may be inaccurate — see Accuracy below.
Getting Started
Add message-timing
npx shadcn@latest add @assistant-ui/message-timingThe @assistant-ui namespace resolves the Radix or Base UI flavor from your project's style through the style-aware registry entry in components.json. Without that entry, add by direct URL instead:
npx shadcn@latest add https://r.assistant-ui.com/base/message-timing.jsonMain Component
npm install @assistant-ui/react"use client";import { useMessageTiming } from "@assistant-ui/react";import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger,} from "@/components/ui/tooltip";import { cn } from "@/lib/utils";import type { FC } from "react";const formatTimingMs = (ms: number | undefined): string => { if (ms === undefined) return "—"; if (ms < 1000) return `${Math.round(ms)}ms`; return `${(ms / 1000).toFixed(2)}s`;};/** * Shows streaming stats (TTFT, total time, tok/s, chunks) as a badge with a * hover/focus tooltip. Renders nothing until the stream completes. * * Place it inside `ActionBarPrimitive.Root` in your `thread.tsx` so it * inherits the action bar's autohide behaviour: * * ```tsx * import { MessageTiming } from "@/components/assistant-ui/message-timing"; * * <ActionBarPrimitive.Root > * <ActionBarPrimitive.Copy /> * <ActionBarPrimitive.Reload /> * <MessageTiming /> // <-- add this * </ActionBarPrimitive.Root> * ``` * * @param side - Side of the tooltip relative to the badge trigger. * @default "right" */export const MessageTiming: FC<{ className?: string; side?: "top" | "right" | "bottom" | "left";}> = ({ className, side = "right" }) => { const timing = useMessageTiming(); if (timing?.totalStreamTime === undefined) return null; return ( <TooltipProvider> <Tooltip> <TooltipTrigger render={ <button type="button" data-slot="message-timing-trigger" aria-label="Message timing" className={cn( "text-muted-foreground hover:bg-accent hover:text-accent-foreground flex items-center rounded-md p-1 font-mono text-xs tabular-nums transition-colors", className, )} /> } > {formatTimingMs(timing.totalStreamTime)} </TooltipTrigger> <TooltipContent side={side} sideOffset={8} data-slot="message-timing-popover" className="bg-popover text-popover-foreground rounded-lg border px-3 py-2 [&_span>svg]:hidden!" > <div className="grid min-w-35 gap-1.5 text-xs"> {timing.firstTokenTime !== undefined && ( <div className="flex items-center justify-between gap-4"> <span className="text-muted-foreground">First token</span> <span className="font-mono tabular-nums"> {formatTimingMs(timing.firstTokenTime)} </span> </div> )} <div className="flex items-center justify-between gap-4"> <span className="text-muted-foreground">Total</span> <span className="font-mono tabular-nums"> {formatTimingMs(timing.totalStreamTime)} </span> </div> {timing.tokensPerSecond !== undefined && ( <div className="flex items-center justify-between gap-4"> <span className="text-muted-foreground">Speed</span> <span className="font-mono tabular-nums"> {timing.tokensPerSecond.toFixed(1)} tok/s </span> </div> )} <div className="flex items-center justify-between gap-4"> <span className="text-muted-foreground">Chunks</span> <span className="font-mono tabular-nums"> {timing.totalChunks} </span> </div> </div> </TooltipContent> </Tooltip> </TooltipProvider> );};shadcn/ui dependencies
npm install @base-ui/react"use client";import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip";import { cn } from "@/lib/utils";function TooltipProvider({ delay = 0, ...props}: TooltipPrimitive.Provider.Props) { return ( <TooltipPrimitive.Provider data-slot="tooltip-provider" delay={delay} {...props} /> );}function Tooltip({ ...props }: TooltipPrimitive.Root.Props) { return <TooltipPrimitive.Root data-slot="tooltip" {...props} />;}function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) { return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;}function TooltipContent({ className, side = "top", sideOffset = 4, align = "center", alignOffset = 0, children, ...props}: TooltipPrimitive.Popup.Props & Pick< TooltipPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset" >) { return ( <TooltipPrimitive.Portal> <TooltipPrimitive.Positioner align={align} alignOffset={alignOffset} side={side} sideOffset={sideOffset} className="isolate z-50" > <TooltipPrimitive.Popup data-slot="tooltip-content" className={cn( "bg-foreground text-background data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs has-data-[slot=kbd]:pr-1.5 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm", className, )} {...props} > {children} <TooltipPrimitive.Arrow data-slot="tooltip-arrow" className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" /> </TooltipPrimitive.Popup> </TooltipPrimitive.Positioner> </TooltipPrimitive.Portal> );}export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };This adds a /components/assistant-ui/message-timing.tsx file to your project.
Use in your application
Place MessageTiming inside ActionBarPrimitive.Root in your thread.tsx. It will inherit the action bar's auto-hide behaviour and only renders after the stream completes.
import { ActionBarPrimitive } from "@assistant-ui/react";
import { MessageTiming } from "@/components/assistant-ui/message-timing";
const AssistantActionBar: FC = () => {
return (
<ActionBarPrimitive.Root
hideWhenRunning
autohide="not-last"
>
<ActionBarPrimitive.Copy />
<ActionBarPrimitive.Reload />
<MessageTiming />
</ActionBarPrimitive.Root>
);
};What It Shows
The badge displays totalStreamTime inline and reveals a popover on hover with the full breakdown:
| Metric | Description |
|---|---|
| First token | Time from request start to first text chunk (TTFT) |
| Total | Total wall-clock time from start to stream end |
| Speed | Output tokens per second (hidden for very short messages) |
| Chunks | Number of stream chunks received |
Accuracy
Timing accuracy depends on how your backend is connected.
Data Stream (accurate)
When using the Data Stream protocol on the backend (via assistant-stream), token counts come directly from the model's usage data sent in step-finish chunks. The tokensPerSecond metric is exact whenever your backend reports outputTokens.
Vercel AI SDK (estimated)
When using the AI SDK integration (useChatRuntime), token counts are estimated client-side using a 4 characters per token approximation. This can overcount significantly for short messages.
API Reference
MessageTiming component
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | Additional class names on the root element |
side | "top" | "right" | "bottom" | "left" | "right" | Side of the tooltip relative to the badge |
Renders null until totalStreamTime is available (i.e., while streaming or for user messages).
For the underlying useMessageTiming() hook, field definitions, and runtime-specific setup (LocalRuntime, ExternalStore, etc.), see the Message Timing guide.
Related
- Message Timing guide —
useMessageTiming()hook, runtime support table, and custom timing UI - Thread — The action bar context that
MessageTimingis typically placed inside