Feedback
Error State
An error panel — inline/card/page density, safe unknown-error extraction, a collapsible stack trace, a copyable error code and a real retry pending state.
Preview in your theme
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/error-state.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "ErrorState" component using
lucide-react for icons (no other runtime dependencies).
Contract
- Export a forwardRef div extending React.HTMLAttributes<HTMLDivElement>;
spread remaining props on the root and merge className via cn().
- Props: title = "Something went wrong"; description?: ReactNode;
variant = "inline" | "card" | "page" (default "card"); icon?: ReactNode
(default a lucide TriangleAlert glyph); error?: unknown (anything a catch
block might throw); details?: ReactNode (custom technical-details content,
takes priority over `error` when provided); code?: string (an error code /
request id); onRetry?: () => void; retrying?: boolean (default false);
actions?: ReactNode (secondary actions slot); supportHref?: string.
Behavior
- The title + description wrap in a role="alert" region so assistive tech
announces the failure as soon as it mounts (or as soon as the content
changes on re-render) — no extra wiring needed at the call site.
- error: unknown is narrowed defensively, never thrown from inside the
component: Error instance → its message (or name if message is empty) plus
its stack; string → used as-is; any other object → its own `.message`
string field if present, else JSON.stringify (falling back to a generic
"no additional details" string if that fails or yields "{}"); null/
undefined → no message at all. Whatever the shape, rendering never throws.
- The technical-details block renders only when `details` was passed OR
`error` was passed (including explicitly as null) — never for a plain
title+description panel with neither. `details` fully replaces the
extracted `error` rendering when both are given.
- Technical details use a native <details>/<summary> (no JS state needed —
expand/collapse and keyboard access come from the browser). The expanded
body is a max-h-48 overflow-y-auto box; the stack renders in
font-mono text-xs with whitespace-pre-wrap and break-words, so an
arbitrarily long trace scrolls inside its own box instead of stretching
the card or overflowing horizontally.
- code renders next to a copy button. Clicking it writes to the clipboard;
on success show a visible "Copied" label (plus an aria-live mirror) for
2 seconds then reset. On failure (insecure context, denied permission, or
no Clipboard API) show a visible "Copy failed" label in the destructive
color — never fail silently. Clear the reset timer on unmount and whenever
a new copy starts.
- Retry is a plain button wired to onRetry; while retrying is true it is
disabled, sets aria-busy="true", and shows a spinning Loader2 plus a
"Retrying…" label. The component owns no timers and no fail/succeed logic
itself — retrying is entirely consumer-driven, so the same button works
whether the caller's retry always fails, sometimes fails, or always
succeeds.
- actions renders as an unstyled slot next to Retry; supportHref renders a
real <a> reading "Contact support" — never a placeholder href.
Rendering & styling
- Semantic tokens only, no hardcoded colors: inline's tinted strip is
border-destructive/30 + bg-destructive/10; every variant's icon chip is
text-destructive (card/page additionally sit inside a bg-destructive/10
rounded-full circle); the card container is border + bg-card; the copy
button's success/failure confirmation text uses text-chart-2 /
text-destructive. cn() merges the consumer's className everywhere.
- variant selects density purely through five Record<Variant, string>
lookup tables (container layout/tint, icon chip size/tint, icon glyph
size, title type scale, description type scale/max-width) — the JSX tree
is identical across all three variants, only the class strings differ.
- No animation beyond Tailwind's built-in animate-spin on the retry
spinner, which is already motion-reduce:animate-none — nothing else to
gate behind prefers-reduced-motion.
Customization levers
- Icon: pass any node to override the default TriangleAlert, globally or
per instance — it always renders inside the same per-variant chip.
- Add a variant: add one key to each of the five lookup tables
(container/icon-chip/icon-size/title/description) — existing variants and
the render tree stay untouched.
- Retry copy: the "Retry" / "Retrying…" strings are the only hardcoded
labels; swap them at the call site by not passing onRetry and rendering
your own button through the actions slot instead.
- Technical details body: pass `details` (any ReactNode — a key/value list,
a "copy stack" button, structured diagnostics) to fully replace the
default message+stack rendering while keeping the same <details> chrome
and scroll box.
- Density: override the root's padding/gap via className for a tighter
inline strip or a roomier page hero; the max-h-48 scroll box on technical
details is the only other sizing knob worth touching.Concepts
- Safe unknown narrowing —
error: unknownis defensively narrowed (Error→ message + stack, string → itself, object →.messageorJSON.stringify, null/undefined → no message) so whatever acatchblock actually throws, rendering never crashes. detailsoverrideserror— passingdetailsfully replaces the extracted-error rendering while keeping the same<details>chrome, letting a consumer show custom diagnostics without losing the scroll box.- Consumer-owned retry —
onRetry/retryingonly wire a button's pending look; the fail/succeed state machine lives entirely in the caller, so the same component works for a retry that always fails, sometimes fails, or always succeeds. - Density via lookup, not branching — all three variants render the identical JSX tree; only five
Record<Variant, string>tables (container, icon chip, icon size, title, description) differ, so adding a variant never touches existing ones. - Native disclosure — the technical-details panel is a plain
<details>/<summary>, so expand/collapse, keyboard access and theopenstate come from the browser for free, no JS state required. - Visible clipboard fallback — copying the error code shows a visible "Copied" or "Copy failed" label (plus an
aria-livemirror for assistive tech), never a silent no-op when the Clipboard API is unavailable or denied.