Skip to content

ErrorState

With the financial reassurance sentence.
Implemented
Status
Implemented
Family
8 of 13
Registry
@lopifi/error-state
npx shadcn@latest add @lopifi/theme @lopifi/error-state

The component, rendering

Real data, the shipped component. Switch the theme to check the claims the spec makes below.

Preview

The four-layer spec

Couldn't load transactions. ⓘ        ← the technical account, one press away
No transactions were affected.       ← the financial reassurance
[Try again]  [Contact support]

The reassurance sentence is the most important line, and it is the only prose that stays on screen. In a financial product, an error makes users worry that something happened to their money. Saying explicitly that nothing did is worth more than the retry button — and worth more than the paragraph explaining the outage.

The detail — which endpoint, which timeout, which missing rate — is real and belongs to the person who asks for it, so it rides an InfoTip on the title rather than a third paragraph. An InfoTip and not a Tooltip: it opens on press, so the account of the failure survives touch.

Variants: network · permission · not-found · server · stale-data. Each states what happened, what it means for their money, and what to do.

Lifted verbatim from System & states. The doc and this page are the same paragraphs, so they cannot disagree.

What lands in your repository

One component is never one file. Everything below is copied into your project, where you own it.

Files

components/ledger/error-state.tsx

Lopifi dependencies · 5
buttoncniconsinfo-tiptooltip
npm packages
@base-ui-components/reactclass-variance-authorityclsxtailwind-merge
Registry entry
/r/error-state.json

The whole component

No compiled bundle, no black box. This is the file the registry copies, and the file you will edit.

components/ledger/error-state.tsx
import * as React from "react";
import { cn } from "@/lib/ledger/cn";
import { Button } from "@/components/ledger/button";
import {
  ExclamationTriangleIcon,
  LockClosedIcon,
  MagnifyingGlassMinusIcon,
  ServerStackIcon,
  SignalSlashIcon,
} from "@/components/ledger/icons";
import { InfoTip } from "@/components/ledger/info-tip";

type ErrorVariant = "network" | "permission" | "not-found" | "server" | "stale-data";

const ICONS = {
  network: SignalSlashIcon,
  "not-found": MagnifyingGlassMinusIcon,
  permission: LockClosedIcon,
  server: ServerStackIcon,
  "stale-data": ExclamationTriangleIcon,
} as const;

export interface ErrorStateProps extends React.ComponentPropsWithoutRef<"div"> {
  /**
   * The technical account of what failed. It is the least urgent thing on the
   * screen and the longest, so it lives behind the ⓘ beside the title — an
   * InfoTip rather than a Tooltip, because it opens on press and so survives
   * touch.
   */
  detail: string;
  onRetry?: () => void;
  onSupport?: () => void;
  /**
   * The one line of prose that stays visible. An error in a financial product
   * makes users worry something happened to their money; saying explicitly
   * that nothing did is worth more than the retry button — and worth more
   * than the paragraph explaining the outage.
   */
  reassurance: string;
  retryLabel?: string;
  title: string;
  variant?: ErrorVariant;
}

export const ErrorState = React.forwardRef<HTMLDivElement, ErrorStateProps>(
  (
    {
      className,
      variant = "network",
      title,
      detail,
      reassurance,
      onRetry,
      retryLabel = "Try again",
      onSupport,
      ...props
    },
    ref,
  ) => {
    const Icon = ICONS[variant];
    return (
      <div
        className={cn(
          "flex flex-col items-center gap-4 rounded-xl border border-border bg-surface px-6 py-12 text-center",
          className,
        )}
        ref={ref}
        role="alert"
        {...props}
      >
        <div className="grid size-12 place-items-center rounded-md bg-negative-subtle">
          <Icon aria-hidden="true" className="size-5 text-negative" />
        </div>
        <div className="max-w-[360px] space-y-1.5">
          <p className="text-title-sm font-medium">
            {title}
            <InfoTip
              about={`What happened: ${title}`}
              className="text-left"
              triggerClassName="ml-1.5 align-[-1px]"
            >
              {detail}
            </InfoTip>
          </p>
          <p className="text-body text-text-secondary">{reassurance}</p>
        </div>
        <div className="flex gap-2">
          {onRetry ? (
            <Button onClick={onRetry} variant="primary">
              {retryLabel}
            </Button>
          ) : null}
          {onSupport ? (
            <Button onClick={onSupport} variant="secondary">
              Contact support
            </Button>
          ) : null}
        </div>
      </div>
    );
  },
);
ErrorState.displayName = "ErrorState";

The accounting it encodes

Concepts this component renders. Each one is defined once, in the lexicon, and used identically in the code and the copy.

Also in System & states

See also

A component is specified in one place, defined in another, and running in a third. All three, from here.

Elsewhere in the system

Where ErrorState sits in the rest of the system — the live section it renders in, the calculators that compute what it displays, and the ledger payloads it is fed from.

Components are copied into your repository and meant to be edited — rounding policy, fiscal calendar and compliance copy differ per company.

Told when System & states changes

Components are copied into your repo, so a fix here does not reach you on its own. One email when a component in this family changes in a way that needs a migration — and nothing when it does not.

One address, no list rental, unsubscribe in one click.

Works in your QuickBooks Online or Xero file

Get your books closed every month

Lopifi is building AI bookkeeping that keeps your books closed on US GAAP as transactions happen, with a CPA review every quarter, for $200 a month. We’re not open yet.

$200/month when we open. No card now.