FLOWDocs
Components

Feedback

Badge, StatusWidget, Toast, Spinner, Progress, Skeleton, Empty, Kbd, ErrorBoundary.

Badge

Eight variants matching button intents. Polymorphic via render.

View source on GitHub →

Usage:

import { Badge } from "@flow-industries/ui/components/badge";

<Badge>Default</Badge>
<Badge variant="brand">New</Badge>
<Badge variant="success">Live</Badge>
<Badge variant="destructive">Failing</Badge>
<Badge variant="outline">Beta</Badge>

Source:

components/ui/badge.tsx
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import { cva, type VariantProps } from "class-variance-authority";

import { cn } from "../../utils/cn";

const badgeVariants = cva(
  "group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border-[length:var(--border-width)] border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,background-color,border-color] focus-visible:border-[length:var(--border-width-focus)] focus-visible:border-focus has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive [&>svg]:pointer-events-none [&>svg]:size-3!",
  {
    variants: {
      variant: {
        default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
        secondary:
          "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
        destructive:
          "bg-destructive/10 text-destructive dark:bg-destructive/20 [a]:hover:bg-destructive/20",
        success:
          "bg-success-background text-success-foreground [a]:hover:bg-success/20 [&>svg]:text-success",
        brand: "bg-brand text-brand-foreground [a]:hover:bg-brand/90",
        outline:
          "border-secondary text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
        ghost:
          "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
        link: "text-primary underline-offset-4 hover:underline",
      },
    },
    defaultVariants: {
      variant: "default",
    },
  },
);

function Badge({
  className,
  variant = "default",
  render,
  ...props
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
  return useRender({
    defaultTagName: "span",
    props: mergeProps<"span">(
      {
        className: cn(badgeVariants({ variant }), className),
      },
      props,
    ),
    render,
    state: {
      slot: "badge",
      variant,
    },
  });
}

export { Badge, badgeVariants };

StatusWidget

Live fleet-status pill: a colored dot plus label ("All systems operational" / "Partial outage" / …) that links to the status page. Reads the public Gatus statuses API and derives a worst-of overall state; on any error it stays "unknown" rather than breaking the footer it sits in. The dot pulses in its status color.

View source on GitHub →

Usage:

import { StatusWidget } from "@flow-industries/ui/components/status-widget";

<StatusWidget />
<StatusWidget statusUrl="https://status.flow.industries" />
// On the status page itself, fetch same-origin via its proxy:
<StatusWidget apiUrl="" />

Source:

components/ui/status-widget.tsx
import type * as React from "react";
import { useEffect, useState } from "react";

import { cn } from "../../utils/cn";

type Overall = "operational" | "degraded" | "down" | "unknown";

interface ProbeResult {
  success: boolean;
}
interface Endpoint {
  results?: ProbeResult[];
}

const META = {
  operational: { label: "All systems operational", dot: "bg-success" },
  degraded: { label: "Degraded performance", dot: "bg-orange" },
  down: { label: "Partial outage", dot: "bg-destructive" },
  unknown: { label: "Status unavailable", dot: "bg-muted-foreground" },
} satisfies Record<Overall, { label: string; dot: string }>;

const RECENT = 10;

// Mirrors the status page's worst-of derivation, kept self-contained so the widget has no app deps.
function deriveOverall(endpoints: Endpoint[]): Overall {
  const results = endpoints
    .map((e) => e.results)
    .filter((r) => r !== undefined)
    .filter((r) => r.length > 0);
  if (results.length === 0) return "unknown";
  if (results.some((r) => r.at(-1)?.success === false)) return "down";
  if (results.some((r) => r.slice(-RECENT).some((x) => !x.success))) {
    return "degraded";
  }
  return "operational";
}

/**
 * Live fleet-status pill: a colored dot + label ("All systems operational" / "Partial outage" / …)
 * that links to the status page. Reads the public Gatus statuses API; on any error it stays "unknown"
 * rather than breaking the footer it sits in.
 *
 * @param statusUrl Where the pill links to (the public status page).
 * @param apiUrl    Origin to fetch `/api/v1/endpoints/statuses` from; defaults to `statusUrl`. Pass
 *                  "" on the status page itself to use its same-origin proxy.
 */
export function StatusWidget({
  statusUrl = "https://status.flow.industries",
  apiUrl,
  className,
  ...props
}: Omit<React.ComponentProps<"a">, "href"> & {
  statusUrl?: string;
  apiUrl?: string;
}) {
  const [overall, setOverall] = useState<Overall>("unknown");
  const base = apiUrl ?? statusUrl;

  useEffect(() => {
    const ctrl = new AbortController();
    fetch(`${base}/api/v1/endpoints/statuses?page=1&pageSize=20`, {
      signal: ctrl.signal,
      headers: { Accept: "application/json" },
    })
      .then((res) => (res.ok ? res.json() : Promise.reject(res.status)))
      .then((data: Endpoint[]) => {
        if (Array.isArray(data)) setOverall(deriveOverall(data));
      })
      .catch(() => {});
    return () => ctrl.abort();
  }, [base]);

  const meta = META[overall];

  return (
    <a
      data-slot="status-widget"
      href={statusUrl}
      className={cn(
        "-m-0.5 inline-flex items-center gap-2 rounded-sm border-[length:var(--border-width)] border-transparent text-muted-foreground text-xs outline-none [transition:color_80ms_ease] hover:text-foreground focus-visible:border-focus pointer-coarse:min-h-11",
        className,
      )}
      {...props}
    >
      <span
        className="relative flex size-2 shrink-0 items-center justify-center"
        aria-hidden="true"
      >
        <span
          className={cn(
            "absolute inline-flex size-full animate-ping rounded-full opacity-75 motion-reduce:animate-none",
            meta.dot,
          )}
        />
        <span
          className={cn("relative inline-flex size-2 rounded-full", meta.dot)}
        />
      </span>
      {meta.label}
    </a>
  );
}

Toast

Stackable notifications. Uses Base UI's Toast — not sonner. Exports: ToastProvider, toast (function with .success, .error, .warning, .info helpers).

View source on GitHub →

Usage:

import { ToastProvider, toast } from "@flow-industries/ui/components/toast";

// Once, near the root:
<ToastProvider>{children}</ToastProvider>

// Anywhere:
toast.success("Changes saved");
toast.error("Something went wrong");
toast.info("Link copied");
toast.warning("Connection unstable");
toast("Title", { description: "Plain message" });

Source:

components/ui/toast.tsx
"use client";

import { Toast } from "@base-ui/react/toast";
import {
  CircleCheckIcon,
  InfoIcon,
  OctagonXIcon,
  TriangleAlertIcon,
  XIcon,
} from "lucide-react";
import type * as React from "react";

const toastManager = Toast.createToastManager();

type ToastData = {
  title?: string;
  description?: string;
  variant?: "default" | "success" | "error" | "warning" | "info";
};

function ToastProvider({ children }: { children: React.ReactNode }) {
  return (
    <Toast.Provider toastManager={toastManager} timeout={5000} limit={5}>
      {children}
      <Toast.Portal>
        <Toast.Viewport className="fixed bottom-8 right-8 z-[100] w-[360px]">
          <ToastList />
        </Toast.Viewport>
      </Toast.Portal>
    </Toast.Provider>
  );
}

function ToastList() {
  const { toasts } = Toast.useToastManager<ToastData>();

  return toasts.map((t) => {
    const variant = t.data?.variant ?? "default";
    const icon = {
      success: <CircleCheckIcon className="size-4 text-success" />,
      error: <OctagonXIcon className="size-4 text-destructive" />,
      warning: <TriangleAlertIcon className="size-4 text-orange" />,
      info: <InfoIcon className="size-4 text-blue" />,
      default: null,
    }[variant];

    const accent = {
      success: "var(--success)",
      error: "var(--destructive)",
      warning: "var(--color-orange)",
      info: "var(--color-blue)",
      default: null,
    }[variant];

    return (
      <Toast.Root
        key={t.id}
        toast={t}
        className="group pointer-events-auto absolute bottom-0 right-0 w-full select-none rounded-lg bg-background p-4 shadow-lg ring-[length:var(--border-width)] ring-primary/10 [--gap:0.75rem] [--height:var(--toast-frontmost-height,var(--toast-height))] [--offset-y:calc(var(--toast-offset-y)*-1+(var(--toast-index)*var(--gap)*-1)+var(--toast-swipe-movement-y,0px))] [--peek:0.75rem] [--scale:calc(max(0,1-(var(--toast-index)*0.1)))] [--shrink:calc(1-var(--scale))] z-[calc(1000-var(--toast-index))] h-[var(--height)] origin-bottom transition-[transform,scale,translate,opacity,height] duration-500 ease-(--ease-out-expo) [transform:translateX(var(--toast-swipe-movement-x,0px))_translateY(calc(var(--toast-swipe-movement-y,0px)-(var(--toast-index)*var(--peek))-(var(--shrink)*var(--height))))_scale(var(--scale))] data-expanded:h-[var(--toast-height)] data-expanded:[transform:translateX(var(--toast-swipe-movement-x,0px))_translateY(var(--offset-y))] data-starting-style:[transform:translateY(150%)] data-ending-style:opacity-0 data-ending-style:scale-95 data-ending-style:translate-y-4 before:pointer-events-none data-expanded:before:pointer-events-auto before:absolute before:inset-x-0 before:-top-[var(--gap)] before:h-[var(--gap)] before:content-['']"
        style={
          accent
            ? {
                backgroundColor:
                  variant === "success"
                    ? "var(--success-background)"
                    : undefined,
                backgroundImage:
                  variant === "success"
                    ? undefined
                    : `radial-gradient(circle at top left, color-mix(in oklch, ${accent} 10%, transparent) 0%, transparent 50%)`,
                boxShadow: `0 0 0 var(--border-width) color-mix(in oklch, ${accent} 25%, transparent), 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)`,
              }
            : undefined
        }
      >
        <Toast.Content className="relative flex items-center gap-3 overflow-hidden transition-opacity duration-200 ease-(--ease-out) data-behind:pointer-events-none data-behind:opacity-0 data-expanded:pointer-events-auto data-expanded:opacity-100">
          {icon && <div className="shrink-0">{icon}</div>}
          <div className="flex-1 space-y-1">
            {t.data?.title && (
              <Toast.Title className="text-sm font-medium tracking-tighter text-muted-foreground">
                {t.data.title}
              </Toast.Title>
            )}
            {t.data?.description && (
              <Toast.Description className="text-sm text-muted-foreground">
                {t.data.description}
              </Toast.Description>
            )}
          </div>
          <Toast.Close className="shrink-0 rounded-md p-1 text-muted-foreground opacity-0 transition-[opacity,scale,color] duration-150 ease-(--ease-out) hover:text-foreground group-hover:opacity-100 active:scale-95">
            <XIcon className="size-3.5" />
          </Toast.Close>
        </Toast.Content>
      </Toast.Root>
    );
  });
}

function toast(titleOrDescription: string, opts?: { description?: string }) {
  if (opts?.description) {
    toastManager.add({
      data: {
        title: titleOrDescription,
        description: opts.description,
        variant: "default",
      },
    });
  } else {
    toastManager.add({
      data: { description: titleOrDescription, variant: "default" },
    });
  }
}

toast.success = (description: string) => {
  toastManager.add({ data: { description, variant: "success" } });
};

toast.error = (description: string) => {
  toastManager.add({ data: { description, variant: "error" } });
};

toast.warning = (description: string) => {
  toastManager.add({ data: { description, variant: "warning" } });
};

toast.info = (description: string) => {
  toastManager.add({ data: { description, variant: "info" } });
};

export { ToastProvider, toast };

Spinner

Wraps lucide's Loader2Icon with animate-spin. Uses currentColor.

View source on GitHub →

Usage:

import { Spinner } from "@flow-industries/ui/components/spinner";

<Spinner />
<Spinner className="size-6 text-muted-foreground" />

Source:

components/ui/spinner.tsx
import { Loader2Icon } from "lucide-react";
import { cn } from "../../utils/cn";

function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
  return (
    <Loader2Icon
      role="status"
      aria-label="Loading"
      className={cn(
        "size-4 animate-spin motion-reduce:animate-none",
        className,
      )}
      {...props}
    />
  );
}

export { Spinner };

Progress

Determinate progress bar. Exports: Progress, ProgressTrack, ProgressIndicator, ProgressLabel, ProgressValue.

View source on GitHub →

Usage:

import { Progress, ProgressLabel, ProgressValue } from "@flow-industries/ui/components/progress";

<Progress value={42}>
  <ProgressLabel>Uploading</ProgressLabel>
  <ProgressValue />
</Progress>

Source:

components/ui/progress.tsx
"use client";

import { Progress as ProgressPrimitive } from "@base-ui/react/progress";

import { cn } from "../../utils/cn";

function Progress({
  className,
  children,
  value,
  ...props
}: ProgressPrimitive.Root.Props) {
  return (
    <ProgressPrimitive.Root
      value={value}
      data-slot="progress"
      className={cn("flex flex-wrap gap-3", className)}
      {...props}
    >
      {children}
      <ProgressTrack>
        <ProgressIndicator />
      </ProgressTrack>
    </ProgressPrimitive.Root>
  );
}

function ProgressTrack({ className, ...props }: ProgressPrimitive.Track.Props) {
  return (
    <ProgressPrimitive.Track
      className={cn(
        "relative flex h-1 w-full items-center overflow-x-hidden rounded-full bg-muted",
        className,
      )}
      data-slot="progress-track"
      {...props}
    />
  );
}

function ProgressIndicator({
  className,
  ...props
}: ProgressPrimitive.Indicator.Props) {
  return (
    <ProgressPrimitive.Indicator
      data-slot="progress-indicator"
      className={cn(
        "h-full bg-primary transition-[width] duration-500 ease-(--ease-out-expo)",
        className,
      )}
      {...props}
    />
  );
}

function ProgressLabel({ className, ...props }: ProgressPrimitive.Label.Props) {
  return (
    <ProgressPrimitive.Label
      className={cn("text-sm font-medium", className)}
      data-slot="progress-label"
      {...props}
    />
  );
}

function ProgressValue({ className, ...props }: ProgressPrimitive.Value.Props) {
  return (
    <ProgressPrimitive.Value
      className={cn(
        "ml-auto text-sm text-muted-foreground tabular-nums",
        className,
      )}
      data-slot="progress-value"
      {...props}
    />
  );
}

export {
  Progress,
  ProgressIndicator,
  ProgressLabel,
  ProgressTrack,
  ProgressValue,
};

Skeleton

Pulse placeholder.

View source on GitHub →

Usage:

import { Skeleton } from "@flow-industries/ui/components/skeleton";

<Skeleton className="h-4 w-48" />
<Skeleton className="h-10 w-full rounded-full" />

Source:

components/ui/skeleton.tsx
import { cn } from "../../utils/cn";

function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="skeleton"
      className={cn(
        "animate-pulse rounded-md bg-muted motion-reduce:animate-none",
        className,
      )}
      {...props}
    />
  );
}

export { Skeleton };

Empty

Empty-state primitive. Exports: Empty, EmptyHeader, EmptyTitle, EmptyDescription, EmptyContent, EmptyMedia.

View source on GitHub →

Usage:

import {
  Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription, EmptyContent,
} from "@flow-industries/ui/components/empty";
import { SearchIcon } from "lucide-react";

<Empty>
  <EmptyHeader>
    <EmptyMedia variant="icon"><SearchIcon /></EmptyMedia>
    <EmptyTitle>No results</EmptyTitle>
    <EmptyDescription>Try a broader query.</EmptyDescription>
  </EmptyHeader>
  <EmptyContent>
    <Button>Clear filters</Button>
  </EmptyContent>
</Empty>

Source:

components/ui/empty.tsx
import { cva, type VariantProps } from "class-variance-authority";

import { cn } from "../../utils/cn";

function Empty({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="empty"
      className={cn(
        "flex w-full min-w-0 flex-1 flex-col items-center justify-center gap-4 rounded-xl border-[length:var(--border-width)] border-dashed p-6 text-center text-balance",
        className,
      )}
      {...props}
    />
  );
}

function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="empty-header"
      className={cn("flex max-w-sm flex-col items-center gap-2", className)}
      {...props}
    />
  );
}

const emptyMediaVariants = cva(
  "mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
  {
    variants: {
      variant: {
        default: "bg-transparent",
        icon: "flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground [&_svg:not([class*='size-'])]:size-4",
      },
    },
    defaultVariants: {
      variant: "default",
    },
  },
);

function EmptyMedia({
  className,
  variant = "default",
  ...props
}: React.ComponentProps<"div"> & VariantProps<typeof emptyMediaVariants>) {
  return (
    <div
      data-slot="empty-icon"
      data-variant={variant}
      className={cn(emptyMediaVariants({ variant, className }))}
      {...props}
    />
  );
}

function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="empty-title"
      className={cn("text-sm font-medium tracking-tight", className)}
      {...props}
    />
  );
}

function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
  return (
    <div
      data-slot="empty-description"
      className={cn(
        "text-sm/relaxed text-muted-foreground [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
        className,
      )}
      {...props}
    />
  );
}

function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="empty-content"
      className={cn(
        "flex w-full max-w-sm min-w-0 flex-col items-center gap-2.5 text-sm text-balance",
        className,
      )}
      {...props}
    />
  );
}

export {
  Empty,
  EmptyContent,
  EmptyDescription,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
};

Kbd

Keyboard-key display. Exports: Kbd, KbdGroup.

View source on GitHub →

Usage:

import { Kbd, KbdGroup } from "@flow-industries/ui/components/kbd";

<p>Press <KbdGroup><Kbd>⌘</Kbd><Kbd>K</Kbd></KbdGroup> to open.</p>

Source:

components/ui/kbd.tsx
import { cn } from "../../utils/cn";

function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
  return (
    <kbd
      data-slot="kbd"
      className={cn(
        "pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-sm bg-muted px-1 font-sans text-xs font-medium text-muted-foreground select-none in-data-[slot=tooltip-content]:bg-background/20 in-data-[slot=tooltip-content]:text-background dark:in-data-[slot=tooltip-content]:bg-background/10 [&_svg:not([class*='size-'])]:size-3",
        className,
      )}
      {...props}
    />
  );
}

function KbdGroup({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <kbd
      data-slot="kbd-group"
      className={cn("inline-flex items-center gap-1", className)}
      {...props}
    />
  );
}

export { Kbd, KbdGroup };

ErrorBoundary

A class error boundary plus the ErrorFallback it renders by default — a centered Logo, title, subtitle, a retry button and a home link, with an optional collapsible stack trace (showDetails). Pass a fallback node or render-prop to customize it, onError to log, and resetKeys to recover automatically when a value changes (e.g. the current route). ErrorFallback focuses its heading on mount so screen readers announce the error. Exports: ErrorBoundary, ErrorFallback.

View source on GitHub →

Usage:

import { ErrorBoundary, ErrorFallback } from "@flow-industries/ui/components/error-boundary";

<ErrorBoundary
  onError={(error, info) => report(error, info)}
  fallback={({ error, reset }) => (
    <ErrorFallback error={error} reset={reset} showDetails />
  )}
>
  <App />
</ErrorBoundary>

Source:

components/ui/error-boundary.tsx
import {
  Component,
  type ErrorInfo,
  type ReactNode,
  useEffect,
  useRef,
} from "react";
import { cn } from "../../utils/cn";
import { Logo } from "../logo";
import { Button } from "./button";
import { Subtitle, Title } from "./typography";

type ErrorFallbackProps = {
  error?: unknown;
  reset?: () => void;
  title?: string;
  description?: string;
  homeHref?: string;
  homeLabel?: string;
  retryLabel?: string;
  showDetails?: boolean;
  autoFocus?: boolean;
  className?: string;
};

function ErrorFallback({
  error,
  reset,
  title = "Something went wrong",
  description = "This page hit an error. You can try again or head back home.",
  homeHref = "/",
  homeLabel = "Back to home",
  retryLabel = "Try again",
  showDetails = false,
  autoFocus = true,
  className,
}: ErrorFallbackProps) {
  const headingRef = useRef<HTMLHeadingElement>(null);

  useEffect(() => {
    if (autoFocus) headingRef.current?.focus({ preventScroll: true });
  }, [autoFocus]);

  const details =
    showDetails && error !== undefined
      ? error instanceof Error
        ? (error.stack ?? error.message)
        : String(error)
      : null;

  return (
    <section
      role="alert"
      data-slot="error-fallback"
      className={cn(
        "flex w-full flex-col items-center justify-center gap-6 p-6 text-center text-balance",
        className,
      )}
    >
      <Logo size={40} />
      <div className="flex max-w-md flex-col items-center gap-2">
        <Title
          ref={headingRef}
          tabIndex={-1}
          size="md"
          className="outline-none"
        >
          {title}
        </Title>
        <Subtitle size="md">{description}</Subtitle>
      </div>
      <div className="flex flex-wrap items-center justify-center gap-3">
        <Button
          className="pointer-coarse:min-h-11"
          onClick={() => (reset ? reset() : window.location.reload())}
        >
          {retryLabel}
        </Button>
        <Button
          variant="secondary"
          className="pointer-coarse:min-h-11"
          nativeButton={false}
          render={<a href={homeHref} />}
        >
          {homeLabel}
        </Button>
      </div>
      {details && (
        <details className="w-full max-w-lg text-left">
          <summary className="cursor-pointer rounded-sm border-[length:var(--border-width)] border-transparent text-sm text-muted-foreground outline-none focus-visible:border-focus">
            Error details
          </summary>
          <pre className="mt-2 max-h-64 overflow-auto rounded-lg bg-muted p-3 font-mono text-xs leading-relaxed text-foreground/80 whitespace-pre-wrap break-words">
            {details}
          </pre>
        </details>
      )}
    </section>
  );
}

type ErrorBoundaryFallback =
  | ReactNode
  | ((props: { error: unknown; reset: () => void }) => ReactNode);

type ErrorBoundaryProps = {
  children: ReactNode;
  fallback?: ErrorBoundaryFallback;
  // biome-ignore lint/plugin: React can catch any thrown JavaScript value.
  onError?: (error: unknown, info: ErrorInfo) => void;
  onReset?: () => void;
  resetKeys?: unknown[];
};

type ErrorBoundaryState = { hasError: boolean; error: unknown };

function resetKeysChanged(previous?: unknown[], next?: unknown[]) {
  if (previous === next) return false;
  if (!previous || !next || previous.length !== next.length) return true;
  return previous.some((key, index) => !Object.is(key, next[index]));
}

class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
  state: ErrorBoundaryState = { hasError: false, error: null };

  // biome-ignore lint/plugin: React can catch any thrown JavaScript value.
  static getDerivedStateFromError(error: unknown): ErrorBoundaryState {
    return { hasError: true, error };
  }

  // biome-ignore lint/plugin: React can catch any thrown JavaScript value.
  componentDidCatch(error: unknown, info: ErrorInfo) {
    this.props.onError?.(error, info);
  }

  componentDidUpdate(previousProps: ErrorBoundaryProps) {
    if (
      this.state.hasError &&
      resetKeysChanged(previousProps.resetKeys, this.props.resetKeys)
    ) {
      this.reset();
    }
  }

  reset = () => {
    this.props.onReset?.();
    this.setState({ hasError: false, error: null });
  };

  render() {
    const { hasError, error } = this.state;
    if (!hasError) return this.props.children;

    const { fallback } = this.props;
    if (fallback instanceof Function) {
      return fallback({ error, reset: this.reset });
    }
    if (fallback !== undefined) return fallback;
    return <ErrorFallback error={error} reset={this.reset} />;
  }
}

export {
  ErrorBoundary,
  type ErrorBoundaryProps,
  ErrorFallback,
  type ErrorFallbackProps,
};

On this page