AI

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  ArrowRight,
  ArrowUpRight,
  CircleAlert,
  Gauge,
  Puzzle,
  RotateCw,
  Scissors,
  Server,
  ServerCrash,
  TimerOff,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/fallback-notice.json

Prompt

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.

On This Page