Fallback Notice
An inline "answered by a backup model" notice — the failed primary and the model that answered as a chip chain, reason-class copy, and a one-shot retry-with-primary button that suppresses itself when a retry cannot help.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/fallback-notice.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "FallbackNotice" component (lucide-react for
icons) that annotates an answer produced by a BACKUP model after the primary one
refused the request. The framing decides everything: a fallback is a failure that
succeeded — the answer exists, so the notice is informational and small, never a
destructive error card.
Contract
- forwardRef<HTMLDivElement>, extends React.HTMLAttributes<HTMLDivElement> minus
children; spread the rest on the root; merge className with cn().
- primary: string — the model the request was aimed at, which did not answer.
- fallback: string — the model that actually produced the answer.
- reason?: "overloaded" | "unavailable" | "rate_limited" | "timeout" |
"context_length" | "unsupported" | "unknown" (default "unknown").
- via?: string[] — models tried between the two; every one of them failed too.
- eventId?: string — identity of this fallback event (see the arm key).
- summary?: string — replaces the class summary. Plain string, because the live
region speaks it.
- detail?: React.ReactNode — replaces the explanation; `false` drops it.
- variant?: "default" | "compact" (default "default").
- retryable?: boolean — force the retry affordance on or off.
- retrying?: boolean — consumer-owned in-flight flag.
- onRetryPrimary?: () => void — re-runs the request on the primary.
- action?: { label; href } | { label; onClick } — ONE extra exit, as a union so a
button that goes nowhere is a compile error, not a support ticket.
- dismissible?: boolean (defaults to true when onDismiss is passed);
onDismiss?: () => void.
- announce?: boolean (default true); labels?: Partial<Labels> for every string.
Behavior — the reason class is the input
- Keep one RECIPES table keyed by reason: { short, summary(primary),
detail(primary, fallback), icon, retryable }. Copy, icon and whether a retry
exists are looked up, never branched in JSX; adding a reason is one row.
- retryable is FALSE for context_length and unsupported: the same request can
never fit a smaller context window and can never gain a capability, so a retry
button there is a button guaranteed to reproduce the fallback. Render the
`action` exit instead. The `retryable` prop overrides the class either way.
- The retry affordance appears only when onRetryPrimary exists AND the class (or
the override) says a retry is plausible.
- Suggested copy: capacity — "returns within a minute, retry then"; quota — "an
early retry spends another request on the same limit"; timeout — "the primary
may have finished anyway, a retry can be billed twice"; context — "it has to
get smaller first"; capability — "retrying changes nothing until the request
changes".
Behavior — one event, one retry
- Derive an arm key from `${eventId}|${reason}|${primary}|${via}|${fallback}`.
It identifies ONE fallback event.
- Everything that happens once per event is keyed on it: the retry lock, the
dismissal and the announcement. When it changes, all three re-arm — and the
re-arm happens DURING RENDER (React's adjust-state pattern: compare with a
prev-key state), not in an effect, which would paint one frame of the previous
event's locked button first.
- Pressing retry sets a `fired` state, calls onRetryPrimary once and locks the
button; the label goes retry → "Retrying with X…" (while `retrying`) →
"Retry sent". Guard inside the handler and mark the button aria-disabled — do
NOT use the native disabled attribute: a disabled button leaves the tab order,
so a keyboard user parked on it loses focus to <body> the instant it locks.
- The consumer decides what a successful retry means: normally it replaces the
answer and unmounts the notice. If the primary fails again, it hands over a new
eventId, which unlocks the button for the new event.
- Dismissal is self-service: the component hides itself (returns null) and calls
onDismiss so the caller can persist the choice. It is scoped to the event —
any number of re-renders keep it hidden, a new eventId brings it back, because
the NEXT fallback is news again.
Behavior — screen readers
- One sr-only <span role="status" aria-live="polite" aria-atomic="true">, mounted
from the first render but EMPTY, filled on the next animation frame (a live
region that already holds content when it enters the DOM is announced
unreliably). Cancel the frame on unmount.
- It speaks once per event: "Answered by <fallback>. <summary>". Keep a
latest-ref of the copy so re-worded labels cannot re-announce a fallback the
user has already heard; the effect depends on the arm key alone. Wrap the text
in a node keyed by a counter so identical words re-announce on a new event.
- announce={false} when the surrounding message list already owns a live region.
- The arrows between chips are decorative (aria-hidden); each chip carries an
sr-only prefix instead — "Primary model, did not answer:", "Also tried, also
failed:", "Answered by:" — so the hop survives without the glyphs.
Rendering & styling
- Semantic tokens only: bg-muted/40 + border for the notice body (info tone, and
visibly not the answer's own bg-card), bg-primary/10 + text-primary for the
reason icon, border-primary/30 + bg-primary/10 for the model that answered,
border-dashed + bg-muted/60 + text-muted-foreground for every model that did
not, text-muted-foreground for the explanation, ring for focus. No destructive,
no hex, no rgb(), no oklch().
- Chips are wrapping boxes with min-w-0 and wrap-anywhere, never nowrap pills: a
provider-qualified id (accounts/…/llama-v3p1-405b-instruct) must wrap instead
of widening the message column the notice is annotating.
- variant="default": a small card — icon bubble, headline, chip chain, one
paragraph (bold summary + muted explanation), then a control row indented to
the text column. variant="compact": one wrapping line — icon, chain, the
two-word reason, the same controls, dismiss pushed to the end. Same DOM
skeleton, same role, same state machine; only density differs.
- Root is role="group" + aria-labelledby the headline (sr-only in compact), with
data-reason / data-variant / data-state="idle|retrying|sent" for styling hooks.
- Motion: one 220ms fade-and-lift entrance from keyframes shipped in a hoisted
<style href precedence> element, plus a spinning retry icon while in flight.
Both are motion-reduce:[animation:none] / motion-reduce:animate-none — with
animation off, the notice and every label still read exactly the same.
Customization levers
- Reason table: add rows (region_failover, cost_cap, byok_missing) or rewrite the
copy; each row owns its icon and its retryable verdict, so nothing else moves.
- Density: default vs compact is the main knob; below that, the paddings
(p-3 / px-2.5 py-1.5) and text-xs / text-sm are the only spacing values.
- Tone: swap bg-muted/40 for bg-card to make it a raised card, or for
bg-primary/5 to tint it; keep it away from destructive tokens unless your
product treats a fallback as an incident.
- Chip emphasis: the "answered by" chip is the only highlighted element — flip
the treatment if you would rather emphasise what failed, or drop the mono font
for a friendlier read.
- Blocks you can drop: the chain (headline + summary alone still work), the
explanation (detail={false}), the action, the dismiss button, the live region.
- Chain depth: cap `via` in the consumer if a long chain is noise — showing the
first hop and "+3 more" is a one-line change in the chain renderer.
- Copy: every string is in one labels object, including the announcement builder,
so translation and product voice never touch the logic.Concepts
- Degraded but successful — a fallback is the one failure the user is not blocked by, so the notice is info-toned and subordinate to the answer it annotates; treating it like an error card teaches people to dismiss the only line that says "this is not the model you picked".
- Reason class recipe — the reason is the single input: it looks up the sentence, the icon and the verdict on whether a retry is worth offering, so a new failure mode is one row in a table instead of another branch in the layout.
- Suppressed retry — for a request that overflowed the context window or needed a capability the primary lacks, retrying is guaranteed to reproduce the fallback; the button is not rendered at all and the exit becomes "make the request smaller" or "read which models can".
- One-shot lock per event — the retry leaves at most once per fallback event; the button locks on press and only a new event key (a new
eventId, a different pair of models, a different reason) unlocks it, so an impatient double-press cannot bill two completions. - Event-scoped dismissal — closing the notice hides this fallback, not the feature: the component keeps it hidden across re-renders of the same event and shows itself again on the next one, because a second fallback is news again.
- Quiet announcement — the live region mounts empty and is filled one frame later, speaking "answered by X, because Y" exactly once per event; re-worded labels or a parent re-render never re-announce, and the visible text stands on its own afterwards.
Sandbox Status
A sandbox lifecycle card — provisioning through reclaimed, CPU / memory meters, an uptime clock that freezes on teardown, and restart / tear-down behind a confirm and a one-shot lock.
Persona Editor
An authoring form for one assistant persona: a seed-derived face with a deterministic shuffle, trait chips that refuse rather than truncate, a tone select, budgeted instructions, a preview bubble recomposed on every keystroke, and a save bar that names which fields moved.