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…

"use client"

import * as React from "react"
import { Check, ChevronDown, Copy, CopyX, Loader2, TriangleAlert } from "lucide-react"
import { cn } from "@/lib/utils"

export type ErrorStateVariant = "inline" | "card" | "page"

export interface ErrorStateProps extends React.HTMLAttributes<HTMLDivElement> {
  /** Defaults to "Something went wrong". */
  title?: string
  description?: React.ReactNode
  /** "inline" = compact row (form/card footer), "card" = centered component-level panel, "page" = large route-level whitespace. Same structure, different density. */
  variant?: ErrorStateVariant

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/error-state.json

Prompt

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 narrowingerror: unknown is defensively narrowed (Error → message + stack, string → itself, object → .message or JSON.stringify, null/undefined → no message) so whatever a catch block actually throws, rendering never crashes.
  • details overrides error — passing details fully replaces the extracted-error rendering while keeping the same <details> chrome, letting a consumer show custom diagnostics without losing the scroll box.
  • Consumer-owned retryonRetry/retrying only 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 the open state 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-live mirror for assistive tech), never a silent no-op when the Clipboard API is unavailable or denied.

On This Page