Error state
A failure placeholder with a retry button and the raw error tucked behind a toggle.
A client component — the details toggle holds state. It is an alert region, and its icon is a triangle in the danger tone, so the failure never reads by color alone. onRetry takes the primary action slot; pass anything else as secondaryAction. showDetails is off by default, so raw error text never leaks into a production UI unasked.
Install
$
npx shadcn@latest add @vibra/error-stateNeeds the @vibra registry in your components.json — set it up once.
Examples
"use client"
import * as React from "react"
import { ErrorState } from "@/components/ui/error-state"
const QUERY_FAILURE = new Error(
'FATAL: connection to server at "warehouse-eu-1" (10.0.4.21), port 5432 failed: timeout expired'
)
export default function ErrorStateDemo() {
const [attempts, setAttempts] = React.useState(1)
return (
<ErrorState
className="w-full max-w-md"
title="Could not load revenue"
description={`The warehouse did not answer in time. Nothing was lost — ${attempts} attempt${attempts === 1 ? "" : "s"} so far.`}
error={QUERY_FAILURE}
showDetails
onRetry={() => setAttempts((count) => count + 1)}
retryText="Run it again"
/>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| title | React.ReactNode | Something went wrong | What failed, in the reader's terms rather than the system's. |
| description | React.ReactNode | a line about retrying | What it means for their data and what to do about it. |
| error | unknown | — | Whatever was thrown or returned; only ever shown behind the details toggle. |
| onRetry | () => void | — | Renders a retry button that calls it. |
| retryText | string | Retry | Label for the retry button. |
| showDetails | boolean | false | Offers a toggle that reveals the error message in a monospaced panel. |
| action | React.ReactNode | — | Used as the primary action when there is no onRetry. |
| secondaryAction | React.ReactNode | — | A quieter way out, placed beside the retry button. |
| size | "sm" | "default" | "default" | Passed through to the underlying empty state. |
| variant | "default" | "dashed" | "default" | Passed through to the underlying empty state. |
Dependencies
Registry
npm
Source
"use client"
import * as React from "react"
import { ChevronDownIcon, RotateCwIcon, TriangleAlertIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { EmptyState, type EmptyStateProps } from "@/components/ui/empty-state"
/** `error.message` when there is one, the value itself otherwise — never throws on an odd shape. */
function errorMessage(error: unknown): string {
if (error && typeof error === "object" && "message" in error) {
const message = (error as { message?: unknown }).message
if (message !== undefined && message !== null) return String(message)
}
return String(error)
}
export type ErrorStateProps = Omit<EmptyStateProps, "icon" | "title"> & {
title?: React.ReactNode
/** Whatever was thrown or returned; only ever shown behind the details toggle. */
error?: unknown
onRetry?: () => void
retryText?: string
/** Offers a toggle that reveals the raw error text — keep it off in production UIs. */
showDetails?: boolean
}
function ErrorState({
className,
title = "Something went wrong",
description = "The request did not complete. Retrying often clears it.",
error,
onRetry,
retryText = "Retry",
showDetails = false,
action,
children,
...props
}: ErrorStateProps) {
const [detailsOpen, setDetailsOpen] = React.useState(false)
const detailsId = React.useId()
const hasDetails = showDetails && error !== undefined && error !== null
return (
<EmptyState
data-slot="error-state"
role="alert"
// The media tile belongs to <EmptyState>; tinting it from here keeps the
// danger tone in one place without widening that component's API.
className={cn(
"[&_[data-slot=empty-icon]]:bg-danger-muted [&_[data-slot=empty-icon]]:text-danger",
className
)}
icon={<TriangleAlertIcon />}
title={title}
description={description}
action={
onRetry ? (
<Button type="button" variant="outline" onClick={onRetry}>
<RotateCwIcon aria-hidden="true" />
{retryText}
</Button>
) : (
action
)
}
{...props}
>
{children}
{hasDetails ? (
<div data-slot="error-state-details" className="flex w-full max-w-sm flex-col items-center gap-2">
<Button
type="button"
variant="ghost"
size="sm"
aria-expanded={detailsOpen}
aria-controls={detailsId}
onClick={() => setDetailsOpen((open) => !open)}
className="text-muted-foreground"
>
<ChevronDownIcon
aria-hidden="true"
className={cn("transition-transform duration-(--duration-fast) ease-(--ease-standard)", detailsOpen && "rotate-180")}
/>
{detailsOpen ? "Hide details" : "Show details"}
</Button>
{detailsOpen ? (
<pre
id={detailsId}
className="max-h-40 w-full overflow-auto rounded-md bg-muted p-3 text-start font-mono text-xs whitespace-pre-wrap text-muted-foreground"
>
{errorMessage(error)}
</pre>
) : null}
</div>
) : null}
</EmptyState>
)
}
export { ErrorState, errorMessage }