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

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
Type: readonly FeedDescriptor[] Required
Collection for lists, empty content and announcements. Use [] for none.
children
Type: ReactNode Required
Your composition.
thresholds
Type: StalenessThresholds Default: PROVISIONAL_THRESHOLDS
Age boundaries.
session
Type: SessionCalendar Default: alwaysOpen
Session used for tiering.
clock
Type: Clock Default: Shared clock
Time source.
className
Type: string Optional
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.

feed
Type: FeedDescriptor Required
This item's readings.
pending
Type: PendingFeedAction | null Optional
Request state from useFeedActions.

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
Type: function Required
Row JSX in collection order; use the index for separators.
Full type for children
(feed: FeedDescriptor, index: number) => ReactNode
className
Type: string Optional
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.

feed
Type: FeedDescriptor Required
Last-message timestamp.
clock
Type: Clock Default: Group, then shared clock
Time source.
children
Type: ReactNode Default: Formatted age
Replacement content.

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
Type: readonly FeedDescriptor[] Default: Root collection
Override the collection; required outside a root.
thresholds
Type: StalenessThresholds Default: Inherited
Match the readings' age boundaries.
session
Type: SessionCalendar Default: Inherited
Match the readings' session.
clock
Type: Clock Default: Inherited
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
Type: number Default: 5000
Timeout in milliseconds, within the platform's timer range — about twenty-five days — or the timer fires early; changes affect future presses.
clock
Type: Clock Default: Group, then shared clock
Press timestamp. Timeout uses setTimeout.

Returns

actions
Type: FeedAction[]
Allowed actions in caller order.
pending
Type: PendingFeedAction | null
Action id, connection state at press and since timestamp; pass to the item.
pendingLabel
Type: string | undefined
Action label, or id if its definition disappeared.
run
Type: (actionId: string) => void
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.

hasActions
Type: boolean Required
Usually actions.length > 0 from useFeedActions.
fallbackRef
Type: RefObject<HTMLElement | null> Required
Persistent focusable target in the same document, outside the menu.

Returns

mounted
Type: boolean
Render the menu while actions exist, its trigger is focused, or it is open/finishing dismissal.
menuProps
Type: object
Spread onto DropdownMenu.
Full type for menuProps
{ open: boolean; onOpenChange(open: boolean): void }
triggerProps
Type: object
Spread onto DropdownMenuTrigger.
Full type for triggerProps
{ ref: RefObject<HTMLButtonElement | null>; onFocus(): void; onBlur(): void }
contentProps
Type: object
Spread onto DropdownMenuContent.
Full type for contentProps
{ ref: RefObject<HTMLDivElement | null> }

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.

Dismissal and focus recovery

When actions disappear, a focused trigger stays until focus leaves; an open menu stays until dismissed. Deferred recovery moves lost focus, or focus still on the disappearing menu, to the fallback. It preserves deliberate destinations and does not take focus from another document. Reopening or unmounting cancels recovery. Keyboard behavior belongs to the primitive: Base moves on Tab; Radix keeps the menu open.

interface FeedDescriptor

FeedDescriptor describes data supplied by your integration. FeedHealth does not detect gaps or replay messages.

id
Type: string Required
Identity for rows, requests and announcements.
label
Type: string Required
Feed name.
state
Type: FeedState Required
"connected", "connecting", "disconnected" or "unknown".
lane
Type: FeedLane Required
"coalesced" or "ordered".
lastMessageAt
Type: number | null Required
Epoch milliseconds; null means no timestamp.
dropped
Type: number Optional
Cumulative coalesced drop count. Details default to zero; Lane shows nonzero counts.
seq
Type: number Optional
Last applied sequence, shown in ordered-lane details.
gap
Type: object | null Optional
Open gap; since is epoch milliseconds. Omit or use null for none.
Full type for gap
{ since: number; replaying: boolean } | null
allowedActions
Type: readonly string[] Optional
Allowed action ids; omitted or empty allows none.

interface FeedAction

An action definition matched against the feed's allowedActions.

id
Type: string Required
Distinct id matching allowedActions.
label
Type: string Required
Control text and pending label.
run
Type: function Required
Receives the latest committed descriptor.
Full type for run
(feed: FeedDescriptor) => void | Promise<unknown>
destructive
Type: boolean Optional
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.