# Error Page

> A 404, 500 or offline screen with honest wording and a way out that can work: one sentence, beside places that still work, or a plain readout for developer tools.

A shadcn/ui block from the [@miseui](https://miseui.junsu4621.workers.dev/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/miseui/error-page).

- Page, with a live preview: https://shadcn.exchange/r/miseui/error-page
- Screenshot: https://shadcn.exchange/shot/miseui/error-page/light.png
- Registry item: https://miseui.junsu4621.workers.dev/r/error-page.json
- Categories: blocks
- Listed: 2026-11-05, source last changed 2026-11-05

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/miseui/error-page.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `lucide-react`
- Registry items: `utils`, `https://miseui.junsu4621.workers.dev/r/button.json`, `https://miseui.junsu4621.workers.dev/r/clipboard.json`

## Files

### `registry/miseui/blocks/error-page.tsx`

```tsx
import type * as React from "react"
import { cn } from "@/registry/miseui/lib/utils"
import { ERROR_COPY, resolveErrorCode, type ErrorCopy, type ErrorPageCode, type ErrorPageLink } from "./error-page-copy"
import { ErrorActions, ErrorNews, ErrorReference } from "./error-page-parts"
import { ErrorSplit } from "./error-page-split"
import { reloadPage, useRetry } from "./error-page-state"
import { ErrorTerminal } from "./error-page-terminal"

type ErrorPageVariant = "centered" | "split" | "terminal"

interface ErrorPageProps extends Omit<React.ComponentProps<"section">, "title"> {
  /** `centered` one sentence and a way out, `split` beside places that still work, `terminal` a plain readout for developer tools. Anything else is `centered`. */
  variant?: ErrorPageVariant
  /** Decides the wording and the way out. Another 5xx reads as 500, and anything else as 404. */
  code?: ErrorPageCode | 404 | 500
  title?: string
  /** Continues the title, one tone quieter. Say what happened and what can be done, and do not guess at a cause. */
  detail?: string
  /** The default reloads the page. Return a promise to get the waiting state; a rejection says it did not work. */
  onRetry?: () => void | Promise<void>
  homeHref?: string
  homeLabel?: string
  /** Null removes the link. */
  helpHref?: string | null
  /** Something to quote to support, such as a request id. */
  reference?: string
  /** `split`: your own places that still work. */
  links?: readonly ErrorPageLink[]
  /** `terminal`: the address in the readout. Left out, it reads the current one. */
  path?: string
  /** `terminal`: when it happened. Pass it from the server, or leave it out. */
  time?: string
}

function ErrorHeading({ copy, title, detail }: { copy: ErrorCopy; title?: string; detail?: string }) {
  return (
    <h1 className="text-display text-3xl leading-[1.12]! text-balance @3xl:text-4xl">
      {title ?? copy.title} <span className="text-muted-foreground">{detail ?? copy.detail}</span>
    </h1>
  )
}

function ErrorCentered({ className, children, ...props }: React.ComponentProps<"section">) {
  return (
    <section data-slot="error-page" data-variant="centered" className={cn("@container flex w-full flex-col bg-muted dark:bg-background", className)} {...props}>
      <div className="flex min-h-[640px] flex-1 flex-col items-center justify-center gap-6 px-4 py-12 text-center">
        <div className="flex w-full max-w-2xl min-w-0 flex-col items-center gap-6">{children}</div>
      </div>
    </section>
  )
}

function ErrorPage({ variant, code: codeProp = "404", title, detail, onRetry = reloadPage, homeHref = "/", homeLabel = "Go to the home page", helpHref = "#support", reference, links, path, time, ...props }: ErrorPageProps) {
  const code = resolveErrorCode(codeProp)
  const copy = ERROR_COPY[code]
  const retry = useRetry(onRetry)
  const centered = variant !== "split" && variant !== "terminal"
  const body = (
    <>
      <ErrorHeading copy={copy} title={title} detail={detail} />
      <div className={cn("flex max-w-full min-w-0 flex-col gap-3", centered && "items-center")}>
        <ErrorActions code={code} retry={retry} homeHref={homeHref} homeLabel={homeLabel} helpHref={helpHref} centered={centered} />
        <ErrorNews code={code} retry={retry} />
        {centered ? <p className="text-[13px] text-muted-foreground">{copy.name}</p> : null}
        {reference && variant !== "terminal" ? <ErrorReference reference={reference} /> : null}
      </div>
    </>
  )
  if (variant === "split") return <ErrorSplit copy={copy} list={links ?? copy.list} {...props}>{body}</ErrorSplit>
  if (variant === "terminal") return <ErrorTerminal code={code} copy={copy} path={path} time={time} reference={reference} {...props}>{body}</ErrorTerminal>
  return <ErrorCentered {...props}>{body}</ErrorCentered>
}

export { ErrorPage, type ErrorPageCode, type ErrorPageLink, type ErrorPageProps, type ErrorPageVariant }
```

### `registry/miseui/blocks/error-page-copy.ts`

```ts
type ErrorPageCode = "404" | "500" | "offline"

interface ErrorPageLink {
  label: string
  description?: string
  /** Without it the row is only something to read, such as a thing to check. */
  href?: string
}

interface ErrorCopy {
  /** The name of the error, for the small line and the readout. */
  name: string
  title: string
  /** Says what happened and what can be done. Blames nobody, and does not claim a cause nobody knows. */
  detail: string
  /** One plain sentence for the terminal readout. */
  readout: string
  /** Heads the list on `split`. */
  listTitle: string
  list: readonly ErrorPageLink[]
}

const ERROR_COPY: Record<ErrorPageCode, ErrorCopy> = {
  "404": {
    name: "Error 404",
    title: "We could not find this page.",
    detail: "It may have moved, or the link may be out of date.",
    readout: "Nothing is published at this address.",
    listTitle: "These pages are still here.",
    list: [
      { label: "Invoices", description: "Everything you have sent and drafted.", href: "#invoices" },
      { label: "Customers", description: "The people and companies you bill.", href: "#customers" },
      { label: "Help centre", description: "Search the guides.", href: "#help" },
    ],
  },
  "500": {
    name: "Error 500",
    title: "Something went wrong on our side.",
    detail: "The page did not load. Nothing you did caused this, and trying again may work.",
    readout: "The server could not finish this request.",
    listTitle: "If it keeps happening.",
    list: [
      { label: "Status page", description: "See whether we already know.", href: "#status" },
      { label: "Contact support", description: "Include the reference, if there is one.", href: "#support" },
    ],
  },
  offline: {
    name: "No connection",
    title: "This page needs a connection.",
    detail: "It could not reach the network from this device. Check your connection, then try again.",
    readout: "The request did not reach the network.",
    listTitle: "Worth checking.",
    list: [{ label: "Wi-Fi or mobile data is on" }, { label: "Airplane mode is off" }, { label: "A VPN or firewall is not blocking the page" }],
  },
}

const SERVER_ERROR = /^5\d\d$/

/** A plain string lookup would hand back Object.prototype members for a value such as "constructor". */
function resolveErrorCode(code: unknown): ErrorPageCode {
  const text = String(code)
  if (text === "offline" || text === "404" || text === "500") return text
  // Any other 5xx is still our side; anything else keeps the wording that assumes the least.
  return SERVER_ERROR.test(text) ? "500" : "404"
}

export { ERROR_COPY, resolveErrorCode, type ErrorCopy, type ErrorPageCode, type ErrorPageLink }
```

### `registry/miseui/blocks/error-page-parts.tsx`

```tsx
import { ArrowLeft, Loader2 } from "lucide-react"
import { cn } from "@/registry/miseui/lib/utils"
import { Button } from "@/registry/miseui/ui/button"
import { Clipboard } from "@/registry/miseui/ui/clipboard"
import type { ErrorPageCode } from "./error-page-copy"
import { useOnline, type Retry } from "./error-page-state"

const LINK_TOUCH_CLASS = "relative underline-offset-4 outline-none hover:underline focus-visible:rounded-sm focus-visible:ring-[3px] focus-visible:ring-ring/50 pointer-coarse:after:absolute pointer-coarse:after:-inset-x-1 pointer-coarse:after:-inset-y-3"

interface ErrorActionsProps {
  code: ErrorPageCode
  retry: Retry
  homeHref: string
  homeLabel: string
  /** Null removes the link. */
  helpHref: string | null
  centered: boolean
}

function RetryButton({ retry, primary }: { retry: Retry; primary: boolean }) {
  return (
    <Button
      variant={primary ? "mise" : "outline"}
      aria-busy={retry.retrying || undefined}
      aria-disabled={retry.retrying || undefined}
      onClick={() => void retry.retry()}
      className="pointer-coarse:h-10"
    >
      {retry.retrying ? <Loader2 aria-hidden="true" className="animate-spin motion-reduce:animate-none" /> : null}
      {retry.retrying ? "Trying again" : "Try again"}
    </Button>
  )
}

function BackButton() {
  return (
    <Button variant="ghost" onClick={() => window.history.back()} className="pointer-coarse:h-10">
      <ArrowLeft aria-hidden="true" />
      Go back
    </Button>
  )
}

/** Each error gets the way out that can work: a 404 cannot be retried, and an offline device cannot follow a link home. */
function ErrorActions({ code, retry, homeHref, homeLabel, helpHref, centered }: ErrorActionsProps) {
  const home = (
    <Button asChild variant={code === "404" ? "mise" : "outline"} className="pointer-coarse:h-10">
      <a href={homeHref}>{homeLabel}</a>
    </Button>
  )
  return (
    <>
      <div className={cn("flex flex-wrap items-center gap-2", centered && "justify-center")}>
        {code === "404" ? home : <RetryButton retry={retry} primary />}
        {code === "500" ? home : <BackButton />}
      </div>
      {helpHref && code !== "offline" ? (
        <p className="text-[13px] text-muted-foreground">
          Still stuck?{" "}
          <a href={helpHref} className={cn("font-medium text-foreground", LINK_TOUCH_CLASS)}>
            Contact support
          </a>
        </p>
      ) : null}
    </>
  )
}

/** Mounted from the start so it is a live region before there is news. The wording only reports what the device says. */
function ErrorNews({ code, retry }: { code: ErrorPageCode; retry: Retry }) {
  const online = useOnline()
  const connection = online ? "Your device reports a connection now. Try again." : "Your device reports no connection yet."
  const message = retry.failed ? "That did not work either. Give it a minute, then try again." : code === "offline" ? connection : ""
  return (
    <p role="status" className="text-[13px] leading-5 text-pretty text-muted-foreground empty:sr-only">
      {message}
    </p>
  )
}

/** For quoting to support. Copy sits beside it, because nobody should have to retype a request id. */
function ErrorReference({ reference, className }: { reference: string; className?: string }) {
  return (
    <div className={cn("flex min-w-0 items-center gap-1 text-[13px] text-muted-foreground", className)}>
      <span className="min-w-0 break-all">
        Reference <span className="font-mono text-foreground">{reference}</span>
      </span>
      <Clipboard value={reference} label="Copy reference" />
    </div>
  )
}

export { ErrorActions, ErrorNews, ErrorReference, type ErrorActionsProps }
```

### `registry/miseui/blocks/error-page-split.tsx`

```tsx
import type * as React from "react"
import { ArrowRight } from "lucide-react"
import { cn } from "@/registry/miseui/lib/utils"
import type { ErrorCopy, ErrorPageLink } from "./error-page-copy"

function ListRow({ link }: { link: ErrorPageLink }) {
  const body = (
    <span className="min-w-0 flex-1">
      <span className="block text-sm font-medium">{link.label}</span>
      {link.description ? <span className="block text-[13px] text-pretty text-muted-foreground">{link.description}</span> : null}
    </span>
  )
  if (!link.href) return <div className="flex min-h-10 items-center px-4 py-3">{body}</div>
  return (
    <a href={link.href} className="group flex min-h-10 items-center gap-3 rounded-2xl px-4 py-3 outline-none transition-colors duration-(--duration-fast) ease-(--ease-mise) hover:bg-accent/60 focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:ring-inset">
      {body}
      <ArrowRight aria-hidden="true" className="size-4 shrink-0 text-muted-foreground transition-[translate] duration-(--duration-fast) ease-(--ease-mise) group-hover:translate-x-0.5 motion-reduce:transition-none" />
    </a>
  )
}

interface ErrorSplitProps extends React.ComponentProps<"section"> {
  copy: ErrorCopy
  list: readonly ErrorPageLink[]
  /** Heading, actions and notes. */
  children: React.ReactNode
}

/** For a product with many ways in: beside the apology, the places that still work, so nobody has to start from the top. */
function ErrorSplit({ copy, list, className, children, ...props }: ErrorSplitProps) {
  return (
    <section data-slot="error-page" data-variant="split" className={cn("@container flex w-full flex-col bg-muted dark:bg-background", className)} {...props}>
      <div className="flex min-h-[640px] flex-1 items-center px-4 py-12 @3xl:px-8">
        <div className="mx-auto grid w-full max-w-5xl grid-cols-[minmax(0,1fr)] items-center gap-8 @3xl:grid-cols-2 @3xl:gap-12">
          <div className="flex min-w-0 flex-col items-start gap-6">{children}</div>
          <div className="flex min-w-0 flex-col gap-3">
            <h2 className="text-title px-1 text-lg text-balance">{copy.listTitle}</h2>
            <ul className="flex flex-col divide-y rounded-2xl border bg-background dark:bg-card">
              {list.map((link) => (
                <li key={link.label}>
                  <ListRow link={link} />
                </li>
              ))}
            </ul>
          </div>
        </div>
      </div>
    </section>
  )
}

export { ErrorSplit }
```

### `registry/miseui/blocks/error-page-state.ts`

```ts
import { useState, useSyncExternalStore } from "react"

const noSubscribe = () => () => undefined

function subscribeToConnection(notify: () => void) {
  window.addEventListener("online", notify)
  window.addEventListener("offline", notify)
  return () => {
    window.removeEventListener("online", notify)
    window.removeEventListener("offline", notify)
  }
}

/** The server cannot know, so it renders what the error itself says: no connection. */
function useOnline() {
  return useSyncExternalStore(subscribeToConnection, () => navigator.onLine, () => false)
}

/** Read from the browser and not faked: the readout shows the address this page is really on. */
function usePathname() {
  return useSyncExternalStore(noSubscribe, () => window.location.pathname, () => "")
}

function reloadPage() {
  window.location.reload()
}

function useRetry(onRetry: () => void | Promise<void>) {
  const [retrying, setRetrying] = useState(false)
  const [failed, setFailed] = useState(false)
  const retry = async () => {
    if (retrying) return
    setRetrying(true)
    setFailed(false)
    try {
      await onRetry()
    } catch {
      setFailed(true)
    } finally {
      setRetrying(false)
    }
  }
  return { retrying, failed, retry }
}

type Retry = ReturnType<typeof useRetry>

export { reloadPage, useOnline, usePathname, useRetry, type Retry }
```

### `registry/miseui/blocks/error-page-terminal.tsx`

```tsx
import type * as React from "react"
import { cn } from "@/registry/miseui/lib/utils"
import { Clipboard } from "@/registry/miseui/ui/clipboard"
import type { ErrorCopy, ErrorPageCode } from "./error-page-copy"
import { usePathname } from "./error-page-state"

interface ReadoutRow {
  term: string
  value: string
}

/** Real values only, and a row with nothing to say is left out. A prompt or a command here would be something to type that does nothing. */
function readoutRows(code: ErrorPageCode, copy: ErrorCopy, path: string, time: string | undefined, reference: string | undefined): readonly ReadoutRow[] {
  const rows: readonly (ReadoutRow | null)[] = [
    { term: "status", value: code },
    { term: "reason", value: copy.readout },
    path ? { term: "path", value: path } : null,
    time ? { term: "time", value: time } : null,
    reference ? { term: "reference", value: reference } : null,
  ]
  return rows.filter((row) => row !== null)
}

interface ErrorTerminalProps extends React.ComponentProps<"section"> {
  code: ErrorPageCode
  copy: ErrorCopy
  /** The address shown in the readout. Left out, it reads the current one. */
  path?: string
  time?: string
  reference?: string
  /** Heading, actions and notes. */
  children: React.ReactNode
}

/** For a developer tool, where people want the facts to paste into a ticket. Plain text, selectable, in one block with a copy button. */
function ErrorTerminal({ code, copy, path, time, reference, className, children, ...props }: ErrorTerminalProps) {
  const pathname = usePathname()
  const rows = readoutRows(code, copy, path ?? pathname, time, reference)
  const text = rows.map((row) => `${row.term}: ${row.value}`).join("\n")
  return (
    <section data-slot="error-page" data-variant="terminal" className={cn("@container flex w-full flex-col bg-background", className)} {...props}>
      <div className="flex min-h-[640px] flex-1 items-center justify-center px-4 py-12">
        <div className="flex w-full max-w-xl min-w-0 flex-col items-start gap-6">
          {children}
          <div className="relative w-full min-w-0 rounded-2xl bg-muted p-4 pe-12">
            <dl data-testid="error-readout" className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-4 gap-y-1 font-mono text-[13px] leading-5">
              {rows.map((row) => (
                <div key={row.term} className="col-span-2 grid grid-cols-subgrid">
                  <dt className="text-muted-foreground">{row.term}</dt>
                  <dd className="min-w-0 wrap-break-word">{row.value}</dd>
                </div>
              ))}
            </dl>
            <Clipboard value={text} label="Copy these details" className="absolute end-2 top-2" />
          </div>
        </div>
      </div>
    </section>
  )
}

export { ErrorTerminal, readoutRows }
```
