Feedback
Badge, StatusWidget, Toast, Spinner, Progress, Skeleton, Empty, Kbd, ErrorBoundary.
Badge
Eight variants matching button intents. Polymorphic via render.
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:
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.
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:
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).
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:
"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.
Usage:
import { Spinner } from "@flow-industries/ui/components/spinner";
<Spinner />
<Spinner className="size-6 text-muted-foreground" />Source:
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.
Usage:
import { Progress, ProgressLabel, ProgressValue } from "@flow-industries/ui/components/progress";
<Progress value={42}>
<ProgressLabel>Uploading</ProgressLabel>
<ProgressValue />
</Progress>Source:
"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.
Usage:
import { Skeleton } from "@flow-industries/ui/components/skeleton";
<Skeleton className="h-4 w-48" />
<Skeleton className="h-10 w-full rounded-full" />Source:
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.
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:
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.
Usage:
import { Kbd, KbdGroup } from "@flow-industries/ui/components/kbd";
<p>Press <KbdGroup><Kbd>⌘</Kbd><Kbd>K</Kbd></KbdGroup> to open.</p>Source:
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.
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:
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,
};