FeedHealth
Display connection state, data age and lane health. Pass feeds to the root and compose their readings, tooltips and controls from public parts.
import {
FeedHealth,
FeedHealthList,
FeedHealthItem,
FeedHealthIndicator,
FeedHealthTier,
FeedAge,
FeedHealthTooltipTrigger,
FeedHealthTooltipContent,
FeedHealthDetails,
FeedHealthAnnouncer,
type FeedDescriptor,
} from "@/components/ui/feed-health"
import { Tooltip } from "@/components/ui/tooltip"
const feeds: FeedDescriptor[] = [
{
id: "md",
label: "Market data",
state: "connected",
lane: "coalesced",
lastMessageAt: Date.now(),
},
]
export default function FeedHealthDemo() {
return (
<FeedHealth feeds={feeds} className="w-fit max-w-full">
<FeedHealthList className="flex-wrap">
{(feed) => (
<FeedHealthItem feed={feed}>
<Tooltip>
<FeedHealthTooltipTrigger>
<span className="font-medium">{feed.label}</span>
<FeedHealthIndicator className="order-first" />
<FeedHealthTier />
<FeedAge feed={feed} />
</FeedHealthTooltipTrigger>
<FeedHealthTooltipContent>
<FeedHealthDetails />
</FeedHealthTooltipContent>
</Tooltip>
</FeedHealthItem>
)}
</FeedHealthList>
<FeedHealthAnnouncer />
</FeedHealth>
)
}
Installation
pnpm dlx shadcn@latest add tradecn/ui/feed-health#v2.0.0
npx shadcn@latest add tradecn/ui/feed-health#v2.0.0
yarn dlx shadcn@latest add tradecn/ui/feed-health#v2.0.0
bunx --bun shadcn@latest add tradecn/ui/feed-health#v2.0.0
-
Install the dependencies:
pnpm add cnnpm install cnyarn add cnbun add cn -
Add the shadcn components it composes:
pnpm dlx shadcn@latest add badge spinner tooltipnpx shadcn@latest add badge spinner tooltipyarn dlx shadcn@latest add badge spinner tooltipbunx --bun shadcn@latest add badge spinner tooltip -
Copy the files into your project:
components/ui/ feed-health.tsx import { cn } from "cn" import { Fragment, createContext, useCallback, useContext, useEffect, useId, useLayoutEffect, useRef, useState, type ComponentProps, type ReactNode, type RefObject } from "react" import { Badge } from "@/components/ui/badge" import { Spinner } from "@/components/ui/spinner" import { TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" import { useNow } from "@/hooks/use-clock" import { createClock, sharedClock, type Clock } from "@/lib/clock" // Callers own the rows, tooltips and controls. Items coordinate one feed's readings; only // those items, standalone ages and the explicit collection announcer subscribe to the clock. export type FeedState = "connected" | "connecting" | "disconnected" | "unknown" export type FeedLane = "coalesced" | "ordered" export type Tier = "live" | "aging" | "stale" | "offline" | "closed" export interface FeedDescriptor { id: string label: string state: FeedState /** `coalesced` lanes drop stale ticks and count them; `ordered` lanes never drop and can have gaps. */ lane: FeedLane /** Wall clock of the last message, ms since epoch. */ lastMessageAt: number | null /** Messages dropped so far (coalesced lane). */ dropped?: number /** Last sequence number applied (ordered lane). */ seq?: number /** A sequence gap is open (ordered lane). */ gap?: { since: number; replaying: boolean } | null /** What the server allows on this feed now, by id. No list means nothing may. */ allowedActions?: readonly string[] } export interface FeedAction { /** Matched against each feed's `allowedActions`. */ id: string label: string /** Given the feed as it was when pressed. A returned promise settles the pending mark when it resolves or rejects. */ run: (feed: FeedDescriptor) => void | Promise<unknown> destructive?: boolean } /** The actions a feed offers now: yours, in your order, kept to the ids in its `allowedActions`. */ export function feedActionsFor(feed: FeedDescriptor, actions: readonly FeedAction[] | undefined): FeedAction[] { if (!actions?.length || !feed.allowedActions?.length) return [] return actions.filter((action) => feed.allowedActions!.includes(action.id)) } /** An action out on a feed: which, the state the feed was in, and when. It clears when the state moves, when `pendingMs` lapses, or when the run's promise settles. */ export interface PendingFeedAction { action: string state: FeedState since: number } export interface StalenessThresholds { agingMs: number staleMs: number } /** Placeholders until the people who trade on the data agree on real ones. */ export const PROVISIONAL_THRESHOLDS: StalenessThresholds = { agingMs: 2000, staleMs: 10_000 } export interface SessionCalendar { status(now: number): "open" | "closed" | "pre" | "post" | "holiday" } export const alwaysOpen: SessionCalendar = { status: () => "open" } /** Pure tiering. A disconnect is offline whatever the hour; a closed session is closed, not stale. */ export function stalenessTier(feed: FeedDescriptor, now: number, thresholds: StalenessThresholds = PROVISIONAL_THRESHOLDS, session: SessionCalendar = alwaysOpen): Tier { if (feed.state === "disconnected") return "offline" const status = session.status(now) if (status === "closed" || status === "holiday") return "closed" if (feed.lastMessageAt === null) return "aging" const age = now - feed.lastMessageAt if (age < thresholds.agingMs) return "live" if (age < thresholds.staleMs) return "aging" return "stale" } /** "now", "12s", "3m", "2h". */ export function formatAge(ms: number | null): string { if (ms === null || !Number.isFinite(ms)) return "–" if (ms < 1000) return "now" if (ms < 60_000) return `${Math.floor(ms / 1000)}s` if (ms < 3_600_000) return `${Math.floor(ms / 60_000)}m` return `${Math.floor(ms / 3_600_000)}h` } function formatFeedAge(feed: FeedDescriptor, now: number) { return formatAge(feed.lastMessageAt === null ? null : Math.max(0, now - feed.lastMessageAt)) } // The clock lives in lib/clock so a countdown ticks on the same interval. Exported from here still, so nothing that imported it moves. export { createClock } export type { Clock } const TIER_CLASS: Record<Tier, string> = { live: "text-foreground", aging: "text-stale", // On its tint the text is the foreground, a lane's stale gap, its drop count, and the pending mark inside included: // stale text on the stale tint drops below 4.5 to 1 in light mode, and muted text can. The tint is a background // image, so a badge style's `dark:` background can't paint over it. stale: "bg-[linear-gradient(var(--stale-soft),var(--stale-soft))] text-foreground [&_.text-up]:text-inherit [&_.text-down]:text-inherit [&_.text-flat]:text-inherit [&_.text-stale]:text-inherit [&_.text-expiring]:text-inherit [&_.text-destructive]:text-inherit [&_.text-primary]:text-inherit [&_.text-muted-foreground]:text-inherit", offline: "text-destructive", closed: "text-muted-foreground", } const STATE_DOT: Record<FeedState, string> = { connected: "bg-up", connecting: "animate-pulse bg-stale", disconnected: "bg-destructive", unknown: "bg-muted-foreground", } export interface FeedHealthOptions { thresholds?: StalenessThresholds session?: SessionCalendar clock?: Clock } const OptionsContext = createContext<FeedHealthOptions>({}) function useOptions(options: FeedHealthOptions) { const inherited = useContext(OptionsContext) return { thresholds: options.thresholds ?? inherited.thresholds ?? PROVISIONAL_THRESHOLDS, session: options.session ?? inherited.session ?? alwaysOpen, clock: options.clock ?? inherited.clock ?? sharedClock(), } } const FeedsContext = createContext<readonly FeedDescriptor[] | null>(null) export interface FeedHealthProps extends ComponentProps<"div">, FeedHealthOptions { feeds: readonly FeedDescriptor[] children: ReactNode } /** Supply a collection and compose its lists, readings and one announcer. No clock subscription lives here. */ export function FeedHealth({ feeds, thresholds, session, clock, className, ...props }: FeedHealthProps) { const options = useOptions({ thresholds, session, clock }) if (!feeds) throw new Error("FeedHealth requires feeds; pass [] for an empty collection") return ( <OptionsContext value={options}> <FeedsContext value={feeds}> <TooltipProvider> <div role="group" aria-label={props["aria-labelledby"] ? undefined : "Feed health"} data-slot="tradecn-feed-health" className={cn("flex min-w-0 items-center gap-1 lining-nums tabular-nums", className)} {...props} /> </TooltipProvider> </FeedsContext> </OptionsContext> ) } export interface FeedHealthListProps extends Omit<ComponentProps<"div">, "children"> { children: (feed: FeedDescriptor, index: number) => ReactNode } /** Render the nearest group's feeds in order, preserving each row's identity by feed.id. */ export function FeedHealthList({ children, className, ...props }: FeedHealthListProps) { const feeds = useContext(FeedsContext) if (!feeds) throw new Error("FeedHealthList must be inside FeedHealth") return <div data-slot="tradecn-feed-health-list" className={cn("flex min-w-0 items-center gap-1", className)} {...props}> {feeds.map((feed, index) => <Fragment key={feed.id}>{children(feed, index)}</Fragment>)} </div> } /** Caller-owned content shown only when the nearest group's collection is empty. */ export function FeedHealthEmpty({ className, ...props }: ComponentProps<"div">) { const feeds = useContext(FeedsContext) if (!feeds) throw new Error("FeedHealthEmpty must be inside FeedHealth") if (feeds.length > 0) return null return <div data-slot="tradecn-feed-health-empty" className={cn("text-xs text-muted-foreground", className)} {...props} /> } interface FeedContextValue { feed: FeedDescriptor tier: Tier now: number pending?: PendingFeedAction | null descriptionId: string } const FeedContext = createContext<FeedContextValue | null>(null) function useFeed() { const feed = useContext(FeedContext) if (!feed) throw new Error("FeedHealth parts must be inside FeedHealthItem") return feed } export interface FeedHealthItemProps extends ComponentProps<"div"> { feed: FeedDescriptor pending?: PendingFeedAction | null } /** One feed's context and presentation. Its children can be a strip, a card, or application markup. */ export function FeedHealthItem({ feed, pending, className, ...props }: FeedHealthItemProps) { const options = useOptions({}) const now = useNow(options.clock) const tier = stalenessTier(feed, now, options.thresholds, options.session) const descriptionId = useId() return ( <FeedContext value={{ feed, tier, now, pending, descriptionId }}> <div data-slot="tradecn-feed-health-item" data-feed={feed.id} data-tier={tier} data-state={feed.state} data-pending={pending?.action} className={cn("inline-flex min-w-0 items-center gap-1.5 rounded text-xs lining-nums tabular-nums", className)} {...props} /> </FeedContext> ) } /** Use inside your Tooltip, paired with FeedHealthTooltipContent. */ export function FeedHealthTooltipTrigger({ className, ...props }: ComponentProps<typeof TooltipTrigger>) { const { descriptionId, tier } = useFeed() return <TooltipTrigger aria-describedby={descriptionId} className={cn("inline-flex min-w-0 items-center gap-1.5 rounded px-1.5 py-0.5 outline-none focus-visible:ring-2 focus-visible:ring-ring/40", TIER_CLASS[tier], className)} {...props} /> } /** A described tooltip in either primitive base. The caller supplies its content. */ export function FeedHealthTooltipContent(props: ComponentProps<typeof TooltipContent>) { const { descriptionId } = useFeed() return <TooltipContent id={descriptionId} role="tooltip" {...props} /> } /** The state word accompanies its dot even when the tier badge is visually hidden. */ export function FeedHealthIndicator({ className, children, ...props }: ComponentProps<"span">) { const { feed } = useFeed() return <span data-slot="tradecn-feed-health-indicator" className={cn("inline-flex items-center", className)} {...props}> <span aria-hidden className={cn("size-1.5 rounded-full", STATE_DOT[feed.state])} /> {children === undefined ? <span className="sr-only">{feed.state}</span> : children} </span> } export function FeedHealthTier({ className, children, ...props }: ComponentProps<typeof Badge>) { const { tier } = useFeed() return <Badge variant="outline" data-slot="tradecn-feed-health-tier" className={cn("h-4 px-1 text-xs uppercase", TIER_CLASS[tier], className)} {...props}>{children === undefined ? tier : children}</Badge> } export interface FeedAgeProps extends ComponentProps<"span"> { feed: FeedDescriptor clock?: Clock } /** A self-ticking age. Hidden from assistive technology; mount one announcer for tier changes. */ export function FeedAge({ feed, clock, className, children, ...props }: FeedAgeProps) { const options = useOptions({ clock }) const now = useNow(options.clock) return <span aria-hidden data-numeric="" className={cn("lining-nums tabular-nums", className)} {...props}>{children === undefined ? formatFeedAge(feed, now) : children}</span> } /** Optional inline drop count or gap age. Detailed sequence and gap state are in FeedHealthDetails. */ export function FeedHealthLane({ className, children, ...props }: ComponentProps<"span">) { const { feed, now } = useFeed() const dropped = feed.lane === "coalesced" && Boolean(feed.dropped) const gap = feed.lane === "ordered" ? feed.gap : null if (children === undefined && !dropped && !gap) return null return <span data-slot="tradecn-feed-health-lane" data-numeric="" className={cn("inline-flex items-center gap-1 lining-nums tabular-nums", gap ? "text-stale" : "text-muted-foreground", className)} {...props}> {children === undefined ? <> {dropped && `drop ${feed.dropped!.toLocaleString()}`} {gap && <>{gap.replaying && <Spinner aria-hidden className="size-3" />}{`gap ${formatAge(Math.max(0, now - gap.since))}`}<span className="sr-only">{gap.replaying ? " replaying" : " open"}</span></>} </> : children} </span> } /** Replaceable metadata formatting. Children append caller-owned fields, such as a pending label. */ export function FeedHealthDetails({ className, children, ...props }: ComponentProps<"dl">) { const { feed, tier } = useFeed() return <dl data-slot="tradecn-feed-health-details" className={cn("grid grid-cols-[auto_auto] gap-x-3 gap-y-0.5 text-left text-xs lining-nums tabular-nums", className)} {...props}> <dt>State</dt><dd>{feed.state}</dd> <dt>Data</dt><dd>{tier}</dd> <dt>Last message</dt><dd>{feed.lastMessageAt === null ? "–" : new Date(feed.lastMessageAt).toLocaleTimeString()}</dd> {feed.lane === "ordered" && feed.seq !== undefined && <><dt>Sequence</dt><dd>{feed.seq.toLocaleString()}</dd></>} {feed.lane === "coalesced" && <><dt>Dropped</dt><dd>{(feed.dropped ?? 0).toLocaleString()}</dd></>} {feed.gap && <><dt>Gap</dt><dd>{feed.gap.replaying ? "replaying" : "open"}</dd></>} {children} </dl> } /** Put the caller's pending label wherever it belongs; omission uses the action id. */ export function FeedHealthPending({ className, children, ...props }: ComponentProps<"span">) { const { pending } = useFeed() if (!pending) return null return <span data-slot="tradecn-feed-health-pending" data-feed-pending={pending.action} className={cn("inline-flex items-center gap-1 text-muted-foreground", className)} {...props}><Spinner aria-hidden className="size-3" />{children === undefined ? pending.action : children}</span> } export interface FeedHealthAnnouncerProps extends Omit<ComponentProps<"span">, "children">, FeedHealthOptions { feeds?: readonly FeedDescriptor[] } /** Mount once per collection, even when the same feeds have several visual presentations. */ export function FeedHealthAnnouncer({ feeds: suppliedFeeds, thresholds, session, clock, className, ...props }: FeedHealthAnnouncerProps) { const inheritedFeeds = useContext(FeedsContext) const feeds = suppliedFeeds ?? inheritedFeeds if (!feeds) throw new Error("FeedHealthAnnouncer needs feeds or a FeedHealth parent") const options = useOptions({ thresholds, session, clock }) const now = useNow(options.clock) const tiers = new Map(feeds.map((feed) => [feed.id, stalenessTier(feed, now, options.thresholds, options.session)])) const [seen, setSeen] = useState(tiers) const [message, setMessage] = useState({ text: "", revision: 0 }) if (seen.size !== tiers.size || feeds.some((feed) => seen.get(feed.id) !== tiers.get(feed.id))) { const changes = feeds.filter((feed) => seen.get(feed.id) !== tiers.get(feed.id)).map((feed) => { const tier = tiers.get(feed.id)! return `${feed.label} ${tier}${tier === "stale" || tier === "aging" ? `, ${formatFeedAge(feed, now)}` : ""}` }) setSeen(tiers) // A new addition may repeat the previous words after a removal. Replace the message node // so the persistent live region receives an addition even when its text is identical. if (changes.length) setMessage({ text: changes.join(". "), revision: message.revision + 1 }) } return <span aria-live="polite" aria-atomic="true" data-slot="tradecn-feed-health-announcer" className={cn("sr-only", className)} {...props}>{message.text && <span key={message.revision}>{message.text}</span>}</span> } export interface UseFeedActionsOptions { /** A pending request expires after this many milliseconds. Changes affect future presses. */ pendingMs?: number /** Timestamp source; this hook does not subscribe. */ clock?: Clock } interface FeedRequest { feedId: string pending: PendingFeedAction timer: ReturnType<typeof setTimeout> } /** One owner per feed. Share its result across controls or views; run rechecks current permissions. */ export function useFeedActions(feed: FeedDescriptor, actions: readonly FeedAction[], { pendingMs = 5000, clock }: UseFeedActionsOptions = {}) { const options = useOptions({ clock }) const [request, setRequest] = useState<FeedRequest | null>(null) const active = useRef<FeedRequest | null>(null) const latest = useRef({ feed, actions, pendingMs, clock: options.clock }) const mounted = useRef(false) useLayoutEffect(() => { mounted.current = true return () => { mounted.current = false if (active.current) clearTimeout(active.current.timer) active.current = null // Activity and Suspense can clean up effects while retaining this state. setRequest(null) } }, []) useLayoutEffect(() => { latest.current = { feed, actions, pendingMs, clock: options.clock } if (active.current && (active.current.feedId !== feed.id || active.current.pending.state !== feed.state)) { clearTimeout(active.current.timer) active.current = null setRequest(null) } }, [feed, actions, pendingMs, options.clock]) const run = useCallback((actionId: string) => { const current = latest.current const action = current.actions.find((candidate) => candidate.id === actionId) if (!mounted.current || active.current || !action || !current.feed.allowedActions?.includes(actionId)) return const clear = () => { if (active.current !== next) return clearTimeout(next.timer) active.current = null setRequest(null) } const next: FeedRequest = { feedId: current.feed.id, pending: { action: actionId, state: current.feed.state, since: current.clock.sample?.() ?? current.clock.now() }, timer: setTimeout(clear, current.pendingMs), } active.current = next setRequest(next) try { const result = action.run(current.feed) if (result) Promise.resolve(result).then(clear, clear) } catch { clear() } }, []) const pending = request?.feedId === feed.id && request.pending.state === feed.state ? request.pending : null return { actions: feedActionsFor(feed, actions), pending, pendingLabel: pending ? (actions.find((action) => action.id === pending.action)?.label ?? pending.action) : undefined, run } } export interface UseFeedActionMenuOptions { /** Whether the caller has any menu actions to render. */ hasActions: boolean /** A persistent, focusable element to receive focus when the empty menu disappears. */ fallbackRef: RefObject<HTMLElement | null> } /** Retain a caller-owned menu through permission loss and recover focus after dismissal. */ export function useFeedActionMenu({ hasActions, fallbackRef }: UseFeedActionMenuOptions) { const [state, setState] = useState<"closed" | "open" | "closing">("closed") const [focused, setFocused] = useState(false) const trigger = useRef<HTMLButtonElement>(null) const content = useRef<HTMLDivElement>(null) useEffect(() => { if (state !== "closing") return // Keep the trigger mounted until the primitive and browser finish moving focus. const restoreFocus = setTimeout(() => { const ownerDocument = trigger.current?.ownerDocument ?? fallbackRef.current?.ownerDocument if (!hasActions && ownerDocument?.hasFocus()) { const active = ownerDocument.activeElement if (active === ownerDocument.body || active === trigger.current || content.current?.contains(active)) fallbackRef.current?.focus() } setState("closed") }, 0) return () => clearTimeout(restoreFocus) }, [state, hasActions, fallbackRef]) return { mounted: hasActions || state !== "closed" || focused, menuProps: { open: state === "open", onOpenChange: (open: boolean) => setState(open ? "open" : "closing") }, triggerProps: { ref: trigger, onFocus: () => setFocused(true), onBlur: () => setFocused(false) }, contentProps: { ref: content }, } }hooks/use-clock.ts import { useCallback, useSyncExternalStore } from "react" import { sharedClock, type Clock } from "@/lib/clock" /** The clock's time. The caller re-renders on each tick and on nothing else. The shared one-second clock unless given another. */ export function useNow(clock?: Clock): number { const c = clock ?? sharedClock() // Called through the clock, not detached from it, so a class-based Clock keeps its `this`. const subscribe = useCallback((cb: () => void) => c.subscribe(cb), [c]) const now = useCallback(() => c.now(), [c]) return useSyncExternalStore(subscribe, now, now) }lib/clock.ts // A clock shared by everything that ticks: one interval, running only while someone listens. // // Feed ages and countdowns both want "now" once a second, and a screen with forty of them wants one // timer, not forty. `now()` is the time of the last tick and is stable between ticks, which is what // useSyncExternalStore needs from a snapshot: two reads with no tick between them agree. export interface Clock { subscribe(cb: () => void): () => void /** The time of the last tick; stable between ticks. */ now(): number /** Read the source for an event timestamp without changing the tick snapshot or starting a timer. */ sample?(): number } /** One interval shared by every subscriber; it runs only while someone is listening. An interval that is not a positive number of milliseconds a timer can hold, up to 2^31 − 1, ticks once a second. */ export function createClock(intervalMs = 1000, source: () => number = Date.now): Clock { // A zero, negative, NaN, or overlong interval (a timer holds 2^31 − 1 ms and fires at once past it) would run as // fast as the timer allows and re-render every subscriber each time. const every = intervalMs > 0 && intervalMs <= 2_147_483_647 ? intervalMs : 1000 let current = source() let timer: ReturnType<typeof setInterval> | null = null const listeners = new Set<() => void>() return { now: () => current, sample: source, subscribe(cb) { listeners.add(cb) if (timer === null) { current = source() timer = setInterval(() => { current = source() for (const l of listeners) l() }, every) } return () => { listeners.delete(cb) if (!listeners.size && timer !== null) { clearInterval(timer) timer = null } } }, } } let shared: Clock | null = null /** The one-second clock every tradecn item ticks on unless it is given another. Made on first use. */ export function sharedClock(): Clock { return (shared ??= createClock(1000)) } -
Add the tokens to your stylesheet:
:root { --stale: oklch(0.52 0.13 75); --stale-soft: oklch(0.52 0.13 75 / 16%); --up: oklch(0.52 0.13 165); } .dark { --stale: oklch(0.8 0.15 80); --stale-soft: oklch(0.8 0.15 80 / 16%); --up: oklch(0.74 0.15 165); }
Usage
import {
FeedHealth,
FeedHealthList,
FeedHealthItem,
FeedHealthIndicator,
FeedHealthTier,
FeedAge,
FeedHealthTooltipTrigger,
FeedHealthTooltipContent,
FeedHealthDetails,
FeedHealthAnnouncer,
type FeedDescriptor,
} from "@/components/ui/feed-health"
import { Tooltip } from "@/components/ui/tooltip"
const feeds: FeedDescriptor[] = [
{
id: "md",
label: "Market data",
state: "connected",
lane: "coalesced",
lastMessageAt: Date.now(),
},
]
export function MarketDataHealth() {
return (
<FeedHealth feeds={feeds} className="w-fit max-w-full">
<FeedHealthList className="flex-wrap">
{(feed) => (
<FeedHealthItem feed={feed}>
<Tooltip>
<FeedHealthTooltipTrigger>
<span className="font-medium">{feed.label}</span>
<FeedHealthIndicator className="order-first" />
<FeedHealthTier />
<FeedAge feed={feed} />
</FeedHealthTooltipTrigger>
<FeedHealthTooltipContent>
<FeedHealthDetails />
</FeedHealthTooltipContent>
</Tooltip>
</FeedHealthItem>
)}
</FeedHealthList>
<FeedHealthAnnouncer />
</FeedHealth>
)
}
The sample timestamp is set at module load. Its connected feed becomes aging after two seconds and stale after ten, on the next clock tick. Replace descriptors as real messages arrive; this component does not connect feeds or publish messages.
Composition
FeedHealth (feeds)
├── FeedHealthList (children(feed, index))
│ └── FeedHealthItem
│ ├── Tooltip (optional)
│ │ ├── FeedHealthTooltipTrigger
│ │ │ ├── Caller label and FeedHealthIndicator
│ │ │ ├── FeedHealthTier and FeedAge
│ │ │ └── FeedHealthLane and FeedHealthPending
│ │ └── FeedHealthTooltipContent
│ │ └── FeedHealthDetails
│ └── Caller controls using useFeedActions
├── FeedHealthEmpty (optional caller content)
└── FeedHealthAnnouncer (one per collection)
Empty collection
Toggle Include market data to switch between no feeds and an offline feed. FeedHealthEmpty shows your message for feeds={[]}. The checkbox stays mounted so focus survives either change.
import { useState } from "react"
import { FeedHealth, FeedHealthList, FeedHealthEmpty, FeedHealthItem, FeedHealthIndicator, FeedHealthTier, FeedHealthAnnouncer, type FeedDescriptor } from "@/components/ui/feed-health"
const feeds: FeedDescriptor[] = [
{ id: "md", label: "Market data", state: "disconnected", lane: "coalesced", lastMessageAt: null },
]
export default function FeedHealthEmptyDemo() {
const [configured, setConfigured] = useState(false)
return (
<>
<div data-demo-controls className="text-xs">
<label className="inline-flex items-center gap-2"><input type="checkbox" checked={configured} onChange={(event) => setConfigured(event.target.checked)} />Include market data</label>
</div>
<FeedHealth feeds={configured ? feeds : []} className="w-fit max-w-full">
<FeedHealthList className="flex-wrap">{(feed) => (
<FeedHealthItem feed={feed}>
<span className="font-medium">{feed.label}</span><FeedHealthIndicator className="order-first" /><FeedHealthTier />
</FeedHealthItem>
)}</FeedHealthList>
<FeedHealthEmpty>No feeds configured.</FeedHealthEmpty>
<FeedHealthAnnouncer />
</FeedHealth>
</>
)
}
Lanes and compact display
Install separator for this layout. This example shows coalesced drops and an ordered gap, with a fixed clock for the gap age. Close RFQ gap removes the report. Compact hides the tier badge with sr-only, keeping its word available to assistive technology — the visible cue is then color alone, so keep a visible tier word or age nearby for sighted users where the tier matters.
import { useState } from "react"
import { FeedHealth, FeedHealthList, FeedHealthItem, FeedHealthIndicator, FeedHealthTier, FeedAge, FeedHealthLane, FeedHealthTooltipTrigger, FeedHealthTooltipContent, FeedHealthDetails, FeedHealthAnnouncer, type Clock, type FeedDescriptor } from "@/components/ui/feed-health"
import { Tooltip } from "@/components/ui/tooltip"
import { Separator } from "@/components/ui/separator"
// Freeze the sample so its lane details stay comparable while changing the presentation.
const now = Date.parse("2026-09-22T14:00:00Z")
const clock: Clock = { now: () => now, subscribe: () => () => { } }
export default function FeedHealthLanesDemo() {
const [compact, setCompact] = useState(false)
const [hasGap, setHasGap] = useState(true)
const feeds: FeedDescriptor[] = [
{ id: "md", label: "Market data", state: "connected", lane: "coalesced", lastMessageAt: now - 1000, dropped: 7 },
{ id: "rfq", label: "RFQ", state: "connected", lane: "ordered", lastMessageAt: now - 1000, seq: 42, gap: hasGap ? { since: now - 3000, replaying: true } : null },
]
return (
<>
<div data-demo-controls className="flex flex-wrap gap-2 text-xs">
<label className="inline-flex items-center gap-2"><input type="checkbox" checked={compact} onChange={(event) => setCompact(event.target.checked)} />Compact</label>
<button type="button" className="rounded border px-2 py-1" onClick={() => setHasGap(!hasGap)}>{hasGap ? "Close RFQ gap" : "Open RFQ gap"}</button>
</div>
<FeedHealth feeds={feeds} clock={clock} className="w-fit max-w-full">
<FeedHealthList className="flex-wrap">{(feed, index) => <div className="inline-flex items-center gap-1">
{index > 0 && <Separator orientation="vertical" className="h-3" />}
<FeedHealthItem feed={feed}>
<Tooltip>
<FeedHealthTooltipTrigger>
<span className="font-medium">{feed.label}</span><FeedHealthIndicator className="order-first" />
<FeedHealthTier className={compact ? "sr-only" : undefined} />
<FeedAge feed={feed} /><FeedHealthLane />
</FeedHealthTooltipTrigger>
<FeedHealthTooltipContent><FeedHealthDetails /></FeedHealthTooltipContent>
</Tooltip>
</FeedHealthItem>
</div>}</FeedHealthList>
<FeedHealthAnnouncer />
</FeedHealth>
</>
)
}
Actions and replies
Install dropdown-menu. Reconnect receives a simulated reply after 1.2 seconds and becomes Resubscribe. Resubscribing advances the sequence without changing connection state, then removes all actions. Reopen the menu while it is pending to try empty-menu dismissal. Disconnect RFQ resets the sample.
import { useEffect, useRef, useState } from "react"
import { FeedHealth, FeedHealthItem, FeedHealthIndicator, FeedHealthTier, FeedAge, FeedHealthLane, FeedHealthTooltipTrigger, FeedHealthTooltipContent, FeedHealthDetails, FeedHealthAnnouncer, FeedHealthPending, useFeedActions, useFeedActionMenu, type FeedAction, type FeedDescriptor } from "@/components/ui/feed-health"
import { Tooltip } from "@/components/ui/tooltip"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
const disconnected: FeedDescriptor = { id: "rfq", label: "RFQ", state: "disconnected", lane: "ordered", lastMessageAt: null, seq: 0, allowedActions: ["reconnect"] }
export default function FeedHealthActionsDemo() {
const [feed, setFeed] = useState(disconnected)
const [busy, setBusy] = useState(false)
const [message, setMessage] = useState("Disconnected.")
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => () => {
if (timer.current !== null) clearTimeout(timer.current)
}, [])
// Stand in for a server reply; returning the promise lets the hook settle pending.
const request = (current: FeedDescriptor) => new Promise<void>((resolve) => {
setBusy(true)
setMessage("Waiting for reply.")
timer.current = setTimeout(() => {
timer.current = null
setFeed({ ...current, state: "connected", lastMessageAt: Date.now(), seq: (current.seq ?? 0) + 1, allowedActions: current.state === "disconnected" ? ["resubscribe"] : [] })
setBusy(false)
setMessage(current.state === "disconnected" ? "Reconnected." : "Resubscribed. No actions are allowed now.")
resolve()
}, 1200)
})
const actions: FeedAction[] = [
{ id: "reconnect", label: "Reconnect", run: request },
{ id: "resubscribe", label: "Resubscribe", run: request },
]
const { actions: offered, pending, pendingLabel, run } = useFeedActions(feed, actions)
const reading = useRef<HTMLButtonElement>(null)
const menu = useFeedActionMenu({ hasActions: offered.length > 0, fallbackRef: reading })
return (
<>
<div data-demo-controls className="text-xs">
<button type="button" className="rounded border px-2 py-1 disabled:opacity-50" disabled={busy || feed.state === "disconnected"} onClick={() => { setFeed(disconnected); setMessage("Disconnected.") }}>Disconnect RFQ</button>
</div>
<div className="w-fit max-w-full space-y-2">
<FeedHealth feeds={[feed]} className="flex-wrap">
<FeedHealthItem feed={feed} pending={pending}>
<Tooltip>
<FeedHealthTooltipTrigger ref={reading}>
<span className="font-medium">{feed.label}</span><FeedHealthIndicator className="order-first" />
<FeedHealthTier />
<FeedAge feed={feed} /><FeedHealthLane /><FeedHealthPending>{pendingLabel}</FeedHealthPending>
</FeedHealthTooltipTrigger>
<FeedHealthTooltipContent><FeedHealthDetails>{pending && <><dt>Pending</dt><dd>{pendingLabel}</dd></>}</FeedHealthDetails></FeedHealthTooltipContent>
</Tooltip>
{menu.mounted && <DropdownMenu {...menu.menuProps}>
<DropdownMenuTrigger {...menu.triggerProps} aria-label={`Actions: ${feed.label}`} data-feed-actions={feed.id} className="rounded px-1 text-muted-foreground outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring/40">⋮</DropdownMenuTrigger>
<DropdownMenuContent {...menu.contentProps} align="start">
{offered.length === 0 && <DropdownMenuItem disabled>No actions available.</DropdownMenuItem>}
{offered.map((action) => <DropdownMenuItem key={action.id} data-feed-action={action.id} disabled={Boolean(pending)} className={action.destructive ? "text-destructive" : undefined} onClick={() => run(action.id)}>{action.label}</DropdownMenuItem>)}
</DropdownMenuContent>
</DropdownMenu>}
</FeedHealthItem>
<FeedHealthAnnouncer />
</FeedHealth>
<p role="status" className="text-xs text-muted-foreground">{message}</p>
</div>
</>
)
}
A card with inline controls
Install button. This layout puts metadata inline, application fields in the details, and actions and navigation in the footer. Pending buttons use aria-disabled to keep focus; removing a focused button returns focus to the heading. Resubscribe remains available in this recipe.
import { useCallback, useEffect, useRef, useState } from "react"
import { FeedHealth, FeedHealthItem, FeedHealthIndicator, FeedHealthTier, FeedHealthLane, FeedHealthDetails, FeedHealthAnnouncer, FeedHealthPending, useFeedActions, type FeedAction, type FeedDescriptor } from "@/components/ui/feed-health"
import { Button } from "@/components/ui/button"
const disconnected: FeedDescriptor = { id: "rfq", label: "RFQ", state: "disconnected", lane: "ordered", lastMessageAt: null, seq: 0, allowedActions: ["reconnect"] }
export default function FeedHealthCardDemo() {
const [feed, setFeed] = useState(disconnected)
const [busy, setBusy] = useState(false)
const [message, setMessage] = useState("Disconnected.")
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
const heading = useRef<HTMLHeadingElement>(null)
const actionRef = useCallback((button: HTMLButtonElement | null) => {
if (!button) return
return () => {
if (document.activeElement === button) heading.current?.focus()
}
}, [])
useEffect(() => () => {
if (timer.current !== null) clearTimeout(timer.current)
}, [])
// Stand in for a server reply; returning the promise lets the hook settle pending.
const request = (current: FeedDescriptor) => new Promise<void>((resolve) => {
setBusy(true)
setMessage("Waiting for reply.")
timer.current = setTimeout(() => {
timer.current = null
setFeed({ ...current, state: "connected", lastMessageAt: Date.now(), seq: (current.seq ?? 0) + 1, allowedActions: ["resubscribe"] })
setBusy(false)
setMessage(current.state === "disconnected" ? "Reconnected." : "Resubscribed.")
resolve()
}, 1200)
})
const actions: FeedAction[] = [
{ id: "reconnect", label: "Reconnect", run: request },
{ id: "resubscribe", label: "Resubscribe", run: request },
]
const { actions: offered, pending, pendingLabel, run } = useFeedActions(feed, actions)
return (
<>
<div data-demo-controls className="text-xs">
<button type="button" className="rounded border px-2 py-1 disabled:opacity-50" disabled={busy || feed.state === "disconnected"} onClick={() => { setFeed(disconnected); setMessage("Disconnected.") }}>Disconnect RFQ</button>
</div>
<div className="w-fit max-w-full space-y-2">
<FeedHealth feeds={[feed]} className="flex-wrap">
<FeedHealthItem feed={feed} pending={pending} className="w-72 max-w-full flex-col items-stretch gap-3 border p-3">
<header className="flex items-center justify-between gap-3">
<h3 ref={heading} tabIndex={-1} className="inline-flex items-center gap-2 rounded font-medium outline-none focus-visible:ring-2 focus-visible:ring-ring/40">{feed.label}<FeedHealthIndicator className="order-first" /></h3>
<FeedHealthTier />
</header>
<FeedHealthDetails><dt>Venue</dt><dd>Chicago</dd>{pending && <><dt>Pending</dt><dd>{pendingLabel}</dd></>}</FeedHealthDetails>
<FeedHealthLane />
<footer className="flex flex-wrap items-center gap-2">
<FeedHealthPending>{pendingLabel}</FeedHealthPending>
{offered.map((action) => <Button key={action.id} ref={actionRef} variant="outline" size="sm" className="aria-disabled:opacity-50" aria-disabled={Boolean(pending)} onClick={() => run(action.id)}>{action.label}</Button>)}
<a href="https://tradecn.dev/docs/feed-health/" target="_blank" rel="noreferrer" className="underline underline-offset-4">Feed API</a>
</footer>
</FeedHealthItem>
<FeedHealthAnnouncer />
</FeedHealth>
<p role="status" className="text-xs text-muted-foreground">{message}</p>
</div>
</>
)
}
API Reference
Parts forward their underlying element's props, refs, className and events. Children are caller-owned except where a part supplies defaults below; the list takes a callback and the announcer excludes children.
<FeedHealth />
A div that supplies the collection, shared options and tooltip provider. Nested roots inherit omitted options but own their collections.
feeds- Collection for lists, empty content and announcements. Use
[]for none. children- Your composition.
thresholds- Age boundaries.
session- Session used for tiering.
clock- Time source.
className- Override the horizontal flex layout. Text size is inherited.
Omitting feeds is a type error and throws at runtime. A direct single-item layout still needs feeds={[feed]} on the root. Filter or limit this array before passing it. The group's default name is Feed health; override it with aria-label or aria-labelledby.
<FeedHealthItem />
A div that supplies one feed's readings and pending state to its children.
The item sets small text and data-feed, data-state, data-tier, data-pending. Tier colors belong to the badge and tooltip trigger, leaving application content untinted. On the stale tier's tint, text inside the trigger takes the foreground, a lane's gap, its drop count, and the pending marker included: state-colored and muted text on that tint can fall below 4.5 to 1. The reset covers the text-up, text-down, text-flat, text-stale, text-expiring, text-destructive, text-primary, and text-muted-foreground classes; any other color class you put inside, such as text-muted-foreground/70, keeps its color.
<FeedHealthList />
Renders a div from the nearest root's feeds, keyed by feed.id. Keep ids unique and stable. Put hooks in a row component returned by the callback.
children- Row JSX in collection order; use the index for separators.
Full type for children
(feed: FeedDescriptor, index: number) => ReactNode className- Arrange rows with
flex-wrap, column or grid classes. Default is horizontal flex.
Multiple lists may share a collection; mount one announcer outside them. An empty list still renders its container. The list owns no action state and throws outside FeedHealth.
Row identity and cleanup
Matching ids preserve row state through reordering or replacement; removing an id unmounts its row.
<FeedHealthEmpty />
A div shown only when the nearest root's collection is empty. Offline feeds are nonempty. It supplies muted small text, no default content, live region or focus management, and throws outside FeedHealth. Place it beside the list; an empty list never calls its row callback. Keep a focus target mounted when removing rows with focused controls or open menus.
Readings
These parts require FeedHealthItem. Indicator, Tier, Lane and Pending use default text only when children is omitted or undefined; null or false suppresses that text. Keep a non-color cue when replacing indicator or tier content.
<FeedHealthIndicator />
A span containing the dot and a screen-reader connection-state word. Children replace the word.
<FeedHealthTier />
A badge containing the tier word. Use className="sr-only" for compact display, leaving color the only visible cue — pair it with a visible word or age where the tier matters.
<FeedHealthLane />
A span containing nonzero coalesced drops or ordered gap age, a replay spinner and an accessible replay/open word. With omitted children, no data renders nothing. Inside the tooltip trigger, as the composition above places it, the gap age and drop count are part of the focused button's name, which changes as they do; give the trigger a fixed aria-label that starts with its visible label when a screen reader shouldn't hear each change.
<FeedHealthDetails />
A description list containing state, tier, local last-message time and lane metadata. Children append dt/dd pairs, such as a pending label. Details can also be inline or replaced with your own markup.
<FeedHealthPending />
A span containing a spinner and action id while pending. Pass pendingLabel as children for the action label.
<FeedHealthTooltipTrigger /> and <FeedHealthTooltipContent />
Wrap your shadcn TooltipTrigger and TooltipContent. The trigger adds tier color, focus style and a description link; the content adds the matching id and tooltip role. Both take caller content.
Put the tooltip pair inside your shadcn Tooltip and FeedHealth provider. Preserve their matching id/description attributes when overriding props. Put the label first for its accessible name; order-first places the indicator visually first.
<FeedAge />
A span that also works outside an item. It defaults to aria-hidden so age ticks are not read aloud. Custom children replace the formatted age, including explicit null or false.
Render feed health on the client. The shared clock advances only while something in a browser subscribes, so server-rendered ages, tiers, and data-tier go stale and mismatch on hydration — for items as much as for this span.
Age formatting
Future timestamps clamp to zero. formatAge(ms) returns now below one second, then whole seconds, minutes or hours (12s, 3m, 2h); null and nonfinite input display –.
<FeedHealthAnnouncer />
FeedHealthAnnouncer is a span with a polite, atomic live region and no children prop. Mount once per collection, including when several lists display it.
feeds- Override the collection; required outside a root.
thresholds- Match the readings' age boundaries.
session- Match the readings' session.
clock- Match the readings' time source.
Announcement timing
It initializes empty, then batches additions and tier changes; aging/stale messages include the age. Every tier change is announced, a recovery to live included, so a feed whose ticks straddle agingMs announces each crossing; set agingMs in thresholds above the interval between the feed's messages. Age-only ticks, label changes, reordering and removals leave the last message unchanged. Repeated wording still inserts a new message node. An idle clock refreshing on subscription can produce a tier change during mounting.
useFeedActions(feed, actions, options?)
Owns pending state without subscribing to the clock. Use one owner per feed and share its result between views. Outside the group, pass its custom clock explicitly.
feed: FeedDescriptor: the current feed, including its allowed action ids.actions: readonly FeedAction[]: your action definitions, in display order.
options?: UseFeedActionsOptions
Optional third argument. Omit it to use the defaults below.
pendingMs- Timeout in milliseconds, within the platform's timer range — about twenty-five days — or the timer fires early; changes affect future presses.
clock- Press timestamp. Timeout uses
setTimeout.
Returns
actions- Allowed actions in caller order.
pending- Action id, connection state at press and
sincetimestamp; pass to the item. pendingLabel- Action label, or id if its definition disappeared.
run- Rechecks definitions and permissions; blocks duplicate presses while pending.
feedActionsFor(feed, actions) is the pure filter, preserving order and labels. Missing actions or allowed ids yields none.
Pending lifecycle
Pending clears on state/id change, promise settlement, a synchronous throw, timeout or effect cleanup (unmount or Activity/Suspense hiding). Returning void or changing permissions does not clear it; an older request cannot clear a newer one. Clearing pending does not cancel the request. Keep the hook owner outside a hidden boundary to preserve pending and duplicate protection there.
The action hook does not change the tier. Errors and rejections are caught but not displayed; report failures yourself. Return your request's promise and update the descriptor from its reply.
Disable pending controls; aria-disabled preserves inline-button focus while the hook blocks duplicates. The hook manages no menus or focus. Use useFeedActionMenu for menus or a persistent fallback for disappearing buttons, as in the card. Use feed.id keys when mapping rows yourself.
useFeedActionMenu(options)
Needs neither FeedHealth context nor useFeedActions and imports no menu primitive.
options: UseFeedActionMenuOptions
Required argument with both fields below.
Returns
Compose your own refs/focus handlers with the returned ones. Supply empty-menu content and disabled/destructive styling. A reading button or heading with tabIndex={-1} can be the fallback.
interface FeedDescriptor
FeedDescriptor describes data supplied by your integration. FeedHealth does not detect gaps or replay messages.
id- Identity for rows, requests and announcements.
label- Feed name.
state"connected","connecting","disconnected"or"unknown".lane"coalesced"or"ordered".lastMessageAt- Epoch milliseconds; null means no timestamp.
dropped- Cumulative coalesced drop count. Details default to zero; Lane shows nonzero counts.
seq- Last applied sequence, shown in ordered-lane details.
gap- Open gap;
sinceis epoch milliseconds. Omit or use null for none.Full type for gap
{ since: number; replaying: boolean } | null allowedActions- Allowed action ids; omitted or empty allows none.
interface FeedAction
An action definition matched against the feed's allowedActions.
id- Distinct id matching
allowedActions. label- Control text and pending label.
run- Receives the latest committed descriptor.
Full type for run
(feed: FeedDescriptor) => void | Promise<unknown> destructive- Metadata for caller styling or confirmation.
Tiers
stalenessTier(feed, now, thresholds?, session?) is pure and uses the component's defaults. First matching condition wins:
| Tier | Condition |
|---|---|
offline |
Disconnected, regardless of session. |
closed |
Session is closed or holiday. |
aging |
No message timestamp. |
live |
Age < agingMs. |
aging |
Age < staleMs. |
stale |
Otherwise. |
Equality reaches the next tier. Connecting and unknown feeds follow the same age rules as connected feeds; drops and gaps do not affect tiers.
Lanes
Coalesced lanes count dropped stale ticks; ordered lanes must not drop messages and report gap age and replay status. Details show gap status whenever gap exists, even for a coalesced lane.
For row-store's useStoreMeta, map lane, dropped, lastBatchAt → lastMessageAt, and seq ?? undefined. Supply identity, label and connection state. Its boolean gap needs an opening timestamp and replay state from your integration.
Thresholds and the session
StalenessThresholds requires both numeric millisecond values: agingMs and staleMs. PROVISIONAL_THRESHOLDS is { agingMs: 2000, staleMs: 10_000 }. Agree real boundaries with users and supply 0 <= agingMs < staleMs; the component does not validate or reorder them.
SessionCalendar.status(now) receives epoch milliseconds and returns "open", "closed", "pre", "post" or "holiday". Pre/post still age normally. session-calendar builds calendars from sessions, holidays and early closes in the venue's zone.
interface Clock
Only items, ages and announcers subscribe; ticks do not rerender the root, list callback or their parents. The shared one-second interval stops after its last subscriber leaves and is shared with other ticking components.
Clock methods and event timestamps
createClock(intervalMs = 1000, source = Date.now) returns a Clock with now(): number and subscribe(cb): () => void. It caches epoch milliseconds between ticks and refreshes on the first subscription after inactivity. Optional sample(): number reads the source without changing the snapshot or starting a timer. Action timestamps use sample() when available, otherwise now().
Installed primitives
Includes badge, spinner, tooltip, the shared clock, and missing stale/up tokens. Add separator, dropdown-menu or button for layouts that use them.
Updating from the closed widget? See the FeedHealth migration guide.
Built on shadcn's badge, spinner, and tooltip.
This page is docs/feed-health.md at v2.0.0.