Primitives
Animated Number Avatar Badge Button Button Group Toggle Toggle Group Checkbox Chip Countdown Divider Fab Grid Icon Image Input Search Input Textarea Label Loader Marquee Masked Input Message Bubble Notification Banner Offline Banner Progress Progress Ring Radio Group Range Slider Rating Separator Skeleton Slider Stack Stepper Switch Text Theme Toggle Ticker Typing IndicatorComposed
Accordion Achievement Badge Action Bar Action Sheet Action Swap Activity Rings Add to Cart Button Address Card Agenda List Alert Amount Keypad Animated Icons App Bar Audio Message Audio Recorder Autocomplete Avatar Group Balance Display Bar List Barcode Before After Biometric Gate Bloom Menu Bottom Sheet Paywall Sheet Side Sheet Breadcrumb Calendar Calendar Strip Card Carousel Cart Item Character Charts Chat Composer Chat List Item Choice Card Circle Reveal Coach Marks Collapsible Collapsible Header Color Picker Combobox Command Palette Comment Thread Confetti Consent Sheet Context Menu Copy Field Country Picker Coupon Input Currency Input Data Table Date Range Picker Day Schedule Dialog Dock Donut Chart Drawer Driver Card Dropdown Menu Drum Picker Emoji Picker Empty State Error State Event Card Expandable Control Fader Feature Gate File Picker Filter Bar Follow Button Form Gauge Glass Glass Cluster Glass Dock Morph Glass Fab Morph Glass Lab Glass Orb Glass Search Morph Glass Sheet Edge Gradient Button Headline New Heatmap Image Cropper Image Viewer Kanban Card Keyboard Avoiding Keyboard Toolbar Leaderboard Level Progress Lightbox Like Button Line Chart List Reorderable List Swipeable Row Location Card Location Search Map Sheet Map View Markdown View Media Controls Media Picker Media Tile Mention Input Meter Bar Mini Player Mood Slider Morphing Modal Multi Select Navigation Rail Notification Item Number Input Onboarding Pager Order Card Order Summary OTP Input Pagination Paper Clip New Parallax Header Pass Card Password Strength Payment Button Payment Card Payment Method Permission Primer Phone Input PIN Pad Polaroid New Poll Popover Post Card Postage Stamp New Price Tag Pricing Table Product Card Product Gallery Profile Header Promo Banner Prompt Input Property Card Pull To Refresh QR Code Quick Actions Quick Replies Rating Summary Reaction Picker Resend Timer Review Card Review Prompt Ride Option Save Button Scan Frame Scroll Spy Tabs Seat Map Section Header Section Index Segmented Control Select Share Button Share Sheet Shutter Button Signature Pad Slide to Confirm Slot Picker Smart Banner Social Login Button Sparkline Splash Screen Split Button Stat Card Stateful Button Status Dot Step Flow Steps Sticker New Stopwatch Stories Progress Stories Viewer Story Ring Streak Counter Streaming Text Success Seal Swipe Deck Tabs Tag Input Tape New Text Reveal Thinking Indicator Time Picker Timeline Timer Ring Tip Selector Toast Toolbar Tooltip Track Row Transaction Row Trip Progress Variant Picker Video Controls Waveform Weather Card Weekday Picker Zoom TransitionScreens
Account Switcher Biometric Setup Delete Account Forgot Password Grow Auth Login Login — Dark Stage Login — Dense Work Login — Editorial Login — Illustrated Login — Minimal Ink Login — Social Login — Video Loop Food Onboarding Onboarding Onboarding Dark Objects Onboarding Editorial Onboarding Illustrated Onboarding Personalize Onboarding Photo Carousel OTP Verification Passkey — Create Password Reset Permission — Notifications Phone Verification Onboarding Quiz Session Devices Signup Sign-up Chat Sign-up Photo Sheet Social Auth Split Auth Verify Identity Welcome AI Chat AI Assistant Channels List Chat Info Chat List Chat Media Chat Thread Create Group Group Info Message Requests New Message Kept Messages Accessibility Activity Log Appearance Authenticator Blocked Accounts Export Your Data Downloads Edit Profile Edit Profile — Rows Emergency Call Empty States Set Error State Send Feedback Device Locator Help Center Home — Bento Home — Dark Home — Editorial Home Feed Invite Friends Language & Region Linked Accounts Not Found (404) Notification Settings Notifications Password Vault Paywall Paywall — Comparison Paywall — Dark Stage Paywall — Editorial Paywall — Photo Paywall — Trial Timeline Privacy Profile Profile — Gamified Profile — Minimal Profile — Photo Hero QR Scanner Report Issue Saved Items Phone Time Search Search — Command Palette Search — Visual Account Health Settings Settings — Dark Device Settings — Search Storage & Data Terms & Privacy Dating Chat Dating Filters Profile Discover Its A Match Into You Matches Profile Detail Profile Editor Courier Matching Delivery History Delivery Support Package Schedule Parcel Tracking Proof Of Delivery Quick Aisle Quick Home Send Package Substitutions Add Payment Address Book Cart Categories Checkout Checkout — Steps Checkout Success Delivery Filters & Sort Flash Sale Gift Cards Loyalty Points Order Cancel Order Detail Orders Pay Later Checkout Payment Methods Product Detail Product Detail — Hero Product Grid Product Reviews Promo Codes Recently Viewed Return Items Search Results Shop Assistant Store Home Tip & Rate Courier Order Tracking Wishlist Write a Review Event Checkin Event Hero Events Discover My Tickets Seat Selection Ticket Checkout Add Card Bank Accounts Dashboard — Bento Bill Pay Budgets Card Details Crypto Portfolio Finance Dashboard Dashboard — Dark Exchange Currency Expense List Invoice Detail Manage Cards Plan Management Savings Goals Send Money Spending Analytics Split Bill Subscriptions Token Detail Token Swap Transactions Wallet Home Wallet Movers Courier Tracking Food Cart Food Home Food Orders Grocery List Recipe Detail Restaurant — Dark Restaurant Detail Activity Rings Fitness Home Habit Quest Habit Tracker Meal Planner Med Store Meditation Player Mood Check-in Nutrition Home Period Tracker Prior Auth Prior Auth Cases Quest Complete Run Tracking Rx Scan Sleep Tracker Telehealth Booking Walk Map Water Tracker Workout Complete Workout Detail Workout Session Applications Apply Company Interview Schedule Job Detail Job Feed Resume Saved Jobs Create Listing Item Chat Listing Detail Make Offer Market Checkout Market Home Market Search My Listings Offers Inbox Seller Profile Audiobook Player Camera Gallery Albums Game Store Mini Player Music Player Photo Editor Photo Gallery Playlist Detail Podcast Player Clips Feed Serene Home Sound Mixer Stories Video Call Video Player Article Reader Course Detail Event Detail Memory Onboarding Icon Lab Leaderboard Lesson Player Match Detail News Editorial News Home Plant Care Prediction Live Prediction Welcome Smart Home Standings Ticket Wallet Topics Follow Weather Detail Add Task Sheet Alarm Clock Calendar Day Calendar Month Doc Scanner File Manager Focus Timer Journal Kanban Board Mail Inbox Mail Thread Note Editor Notes List Reminders Task List Time Blocks Agent Profile Compare Homes Mortgage Calculator Property Detail Rental Application Saved Homes Schedule Tour Search List Search Map Complete Destination Driver Arriving Ev Charging History Home In Trip Matching Options Parking Receipt Safety Schedule Scooter Unlock Booking Confirmed Booking Review Booking Slots My Bookings Pro On The Way Provider Detail Provider List Rate Service Services Home Arc Create Arc Detail Arc Onboarding Avatar Creator Comments Sheet Create Post Explore Grid Followers List Hashtag Feed Mentions Post Detail Post Hero Social Profile Boarding Pass Booking Confirm Car Rental Trip Discover Flight Live Flight Preferences Flight Results Flight Search Hotel Detail Live Transit Trip — Map Day Trip — Rideshare Request Seat Select Travel Home Trip Itinerary Trip OnboardingNothing matches — try another word.
Error State
Error screen or section for offline, not found, server and permission problems, optionally over a faint outline of what failed. It can retry on a countdown and hand smoothly back to your content.
Preview
import * as React from "react";
import { forwardRef } from "react";
import { Text, View, type ViewProps } from "react-native";
import Animated, {
Easing,
useAnimatedStyle,
useSharedValue,
withSequence,
withSpring,
withTiming,
} from "react-native-reanimated";
import {
BAND_STAGGER,
announce,
cn,
createVariant,
ease,
spring,
tempo,
timing,
useHaptic,
useReducedMotion,
useTheme,
Sheen,
SwitchOn,
} from "@lumioui/core";
import { Button } from "../button";
import { Icon, type IconName } from "../icon";
/**
* ErrorState — what a screen or section shows when it can't: offline,
* not found, server error, permission denied. A glyph disc, a plain title,
* one line of help and a retry that tracks itself.
*
* <ErrorState kind="offline" onRetry={refetch} />
* <ErrorState kind="server" layout="inline" autoRetry={10} onRetry={refetch} />
* <ErrorState kind="offline" structure={<BoardShape />} onRetry={refetch} onRecover={show} />
*
* It switches on like everything Lumio: glyph, then copy, then actions,
* 120ms apart. Pass `structure` — the dim exact layout of the content that
* failed — and the error is laid over it instead of a blank canvas. When a
* retry succeeds and `onRecover` is set, the error dims (timing.recede), one
* sheen passes across the dim structure, then `onRecover` fires so the real
* blocks can switch on in its place (40ms rows).
*
* `onRetry` may return a Promise: the button spins while it runs; a rejection
* flickers the glyph (a 4px kick into spring.shake, dipping to 0.6), buzzes a
* warning and shows `retryFailedMessage`. `autoRetry` counts down and
* retries on its own ("Retrying in 8s"), the seconds swapping in place.
* Expo Go compatible.
*/
export type ErrorKind = "offline" | "not-found" | "server" | "permission" | "generic";
const DEFAULTS: Record<ErrorKind, { icon: IconName; title: string; description: string; retry: string; failed: string }> = {
offline: {
icon: "wifi-off",
title: "You're offline",
description: "Check your Wi-Fi or mobile data, then try again.",
retry: "Try again",
failed: "Still no connection.",
},
"not-found": {
icon: "search",
title: "Page not found",
description: "The link may be broken, or the page was moved or deleted.",
retry: "Go home",
failed: "That didn't work.",
},
server: {
icon: "alert-triangle",
title: "Something went wrong on our end",
description: "We're on it. Your data is safe; try again in a moment.",
retry: "Try again",
failed: "Still having trouble.",
},
permission: {
icon: "lock",
title: "Access needed",
description: "Allow access in Settings to use this feature.",
retry: "Open Settings",
failed: "Access is still off.",
},
generic: {
icon: "info",
title: "Couldn't load this",
description: "Something interrupted the request.",
retry: "Try again",
failed: "That didn't work.",
},
};
const rootVariants = createVariant({
base: "w-full",
variants: {
layout: {
// Fills its parent and centres — a whole screen or tab that failed.
screen: "flex-1 items-center justify-center gap-6 px-6 py-10",
// A section inside a page — leading glyph, compact action.
inline: "flex-row items-start gap-3 py-4",
},
},
defaultVariants: { layout: "screen" },
});
export interface ErrorStateProps extends Omit<ViewProps, "className"> {
/** Which failure — picks the glyph and default copy. Default "generic". */
kind?: ErrorKind;
/** "screen" (centred, fills its parent) or "inline" (a failed section). Default "screen". */
layout?: "screen" | "inline";
/** Override the title. */
title?: string;
/** Override the help line. */
description?: string;
/** Small reference under the help — "Error 503 · Ref 7Q2-K9". */
code?: string;
/** Override the glyph. */
icon?: IconName;
/** Replace the glyph disc with your own illustration. */
illustration?: React.ReactNode;
/** Primary action. May return a Promise — rejection counts as a failed retry. */
onRetry?: () => void | Promise<unknown>;
/** Primary action label. Default per kind ("Try again", "Open Settings"…). */
retryLabel?: string;
/** Shown (and announced) after a failed retry. */
retryFailedMessage?: string;
/** Quieter second action. */
secondaryAction?: { label: string; onPress: () => void };
/** Retry automatically after this many seconds (restarts after each failure). */
autoRetry?: number;
/** The exact layout of the content that failed, drawn dim under the error. */
structure?: React.ReactNode;
/** After a successful retry: the error dims, a sheen crosses the structure, then this fires — show the real content here. */
onRecover?: () => void;
className?: string;
}
/** The flicker: a 4px kick released into the shake spring. */
const KICK = 4;
const KICK_IN = { duration: timing.instant.duration / 2 };
/** The glyph dips to this on a refused retry. */
const GLYPH_DIP = 0.6;
/** Structure brightness under the error — the shape reads, the error leads. */
const STRUCTURE_DIM = 0.16;
/** …and once the error has cleared, before the real blocks switch on. */
const STRUCTURE_LIT = 0.35;
/** Countdown seconds rise this far as they swap (direction: time moving). */
const SWAP_RISE = 4;
/** onRecover fires once the error has dimmed and the sheen is mostly across. */
const RECOVER_AT = timing.recede.duration + tempo.drift * 0.7;
const IGNITE = { ...timing.fast, easing: Easing.bezier(...ease.out) } as const;
const DIM = { ...timing.recede, easing: Easing.bezier(...ease.in) } as const;
/** A status line that swaps in place when its text changes (dim / ignite, ≤6px). */
function SwapLine({ text, className }: { text: string; className?: string }) {
const reduce = useReducedMotion();
const lit = useSharedValue(1);
const rise = useSharedValue(0);
const prev = React.useRef(text);
React.useEffect(() => {
if (prev.current === text) return;
prev.current = text;
lit.value = 0.4;
lit.value = withTiming(1, IGNITE);
if (reduce) return;
rise.value = SWAP_RISE;
rise.value = withSpring(0, spring.swap);
}, [text, reduce, lit, rise]);
const style = useAnimatedStyle(() => ({ opacity: lit.value, transform: [{ translateY: rise.value }] }));
return (
<Animated.View style={style}>
<Text accessibilityLiveRegion="polite" className={className}>
{text}
</Text>
</Animated.View>
);
}
export const ErrorState = forwardRef<React.ElementRef<typeof View>, ErrorStateProps>(
function ErrorState(
{
kind = "generic",
layout = "screen",
title,
description,
code,
icon,
illustration,
onRetry,
retryLabel,
retryFailedMessage,
secondaryAction,
autoRetry,
structure,
onRecover,
className,
...props
},
ref
) {
const d = DEFAULTS[kind];
const haptics = useHaptic();
const reduce = useReducedMotion();
const { colors } = useTheme();
const inline = layout === "inline";
const [busy, setBusy] = React.useState(false);
const [failed, setFailed] = React.useState(false);
const [recovering, setRecovering] = React.useState(false);
const [sheen, setSheen] = React.useState(0);
const [countdown, setCountdown] = React.useState<number | null>(autoRetry ?? null);
const kickX = useSharedValue(0);
const glyphLit = useSharedValue(1);
const errorShown = useSharedValue(1);
const structureLit = useSharedValue(STRUCTURE_DIM);
const glyphStyle = useAnimatedStyle(() => ({
opacity: glyphLit.value,
transform: [{ translateX: kickX.value }],
}));
const errorStyle = useAnimatedStyle(() => ({ opacity: errorShown.value }));
const structureStyle = useAnimatedStyle(() => ({ opacity: structureLit.value }));
const failMessage = retryFailedMessage ?? d.failed;
const mounted = React.useRef(true);
const recoverTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
React.useEffect(
() => () => {
mounted.current = false;
if (recoverTimer.current) clearTimeout(recoverTimer.current);
},
[]
);
const runRetry = React.useCallback(async () => {
if (!onRetry || busy || recovering) return;
setBusy(true);
setFailed(false);
setCountdown(null);
try {
await onRetry();
if (!mounted.current) return;
setBusy(false);
if (onRecover) {
// Recovery: the error dims, light crosses the structure, the content takes over.
setRecovering(true);
errorShown.value = withTiming(0, DIM);
structureLit.value = withTiming(STRUCTURE_LIT, { ...timing.slow, easing: Easing.bezier(...ease.out) });
setSheen((n) => n + 1);
haptics.light();
recoverTimer.current = setTimeout(() => onRecover(), structure ? RECOVER_AT : DIM.duration);
}
} catch {
if (!mounted.current) return;
setBusy(false);
setFailed(true);
haptics.warning();
announce(failMessage);
// Flicker for "can't": the glyph dips while it kicks 4px and rings home.
glyphLit.value = withSequence(withTiming(GLYPH_DIP, timing.instant), withTiming(1, timing.normal));
if (!reduce) kickX.value = withSequence(withTiming(-KICK, KICK_IN), withSpring(0, spring.shake));
if (autoRetry) setCountdown(autoRetry);
}
}, [onRetry, busy, recovering, onRecover, structure, haptics, failMessage, reduce, kickX, glyphLit, errorShown, structureLit, autoRetry]);
// Auto-retry countdown — one state update per second, never per frame.
React.useEffect(() => {
if (countdown == null || busy) return;
if (countdown <= 0) {
void runRetry();
return;
}
const t = setTimeout(() => setCountdown((c) => (c == null ? c : c - 1)), 1000);
return () => clearTimeout(t);
}, [countdown, busy, runRetry]);
const glyph = illustration ?? (
<View
className={cn(
"items-center justify-center rounded-full bg-secondary",
inline ? "h-10 w-10" : "h-16 w-16"
)}
>
<Icon name={icon ?? d.icon} size={inline ? "sm" : "lg"} color="foreground" />
</View>
);
const status = busy
? "Retrying…"
: countdown != null && countdown > 0
? `Retrying in ${countdown}s`
: failed
? failMessage
: null;
const copy = (
<View className={cn("gap-2", inline ? "flex-1" : "items-center")}>
<Text
accessibilityRole="header"
className={cn(
"font-semibold text-foreground",
inline ? "text-base" : "text-center text-2xl tracking-tight"
)}
>
{title ?? d.title}
</Text>
<Text
className={cn("text-muted-foreground", inline ? "text-sm leading-5" : "max-w-[320px] text-center text-base leading-6")}
>
{description ?? d.description}
</Text>
{code ? (
<Text className={cn("text-xs text-muted-foreground tabular-nums", !inline && "text-center")} selectable>
{code}
</Text>
) : null}
{status ? (
<SwapLine
text={status}
className={cn(
"text-sm font-medium tabular-nums",
failed && !busy && countdown == null ? "text-destructive" : "text-muted-foreground",
!inline && "text-center"
)}
/>
) : null}
</View>
);
const retryText = retryLabel ?? d.retry;
const actions =
onRetry || secondaryAction ? (
<View className={cn(inline ? "flex-row items-center gap-2 pt-2" : "w-full max-w-[320px] gap-2")}>
{onRetry ? (
<Button
size={inline ? "sm" : "lg"}
variant={inline ? "secondary" : "default"}
loading={busy}
asyncTracking={false}
onPress={() => void runRetry()}
className={inline ? undefined : "w-full"}
>
{countdown != null && countdown > 0 && inline ? "Retry now" : retryText}
</Button>
) : null}
{secondaryAction ? (
<Button
size={inline ? "sm" : "lg"}
variant="ghost"
onPress={secondaryAction.onPress}
className={inline ? undefined : "w-full"}
>
{secondaryAction.label}
</Button>
) : null}
</View>
) : null;
// Things switch on: glyph, then copy, then actions, one band apart.
const body = (
<Animated.View
ref={structure ? undefined : ref}
className={cn(rootVariants({ layout }), !structure && className)}
style={errorStyle}
accessibilityRole="alert"
pointerEvents={recovering ? "none" : "auto"}
{...(structure ? {} : props)}
>
<SwitchOn>
<Animated.View style={glyphStyle}>{glyph}</Animated.View>
</SwitchOn>
{inline ? (
<View className="flex-1">
<SwitchOn delay={BAND_STAGGER}>{copy}</SwitchOn>
{actions ? <SwitchOn delay={BAND_STAGGER * 2}>{actions}</SwitchOn> : null}
</View>
) : (
<>
<SwitchOn delay={BAND_STAGGER} className="w-full items-center">
{copy}
</SwitchOn>
{actions ? (
<SwitchOn delay={BAND_STAGGER * 2} className="w-full items-center">
{actions}
</SwitchOn>
) : null}
</>
)}
</Animated.View>
);
if (!structure) return body;
// The failed content's exact layout, dim, with the error laid over it.
return (
<View ref={ref} className={cn("w-full", !inline && "flex-1", className)} {...props}>
<View
className="overflow-hidden rounded-2xl"
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
pointerEvents="none"
>
<Animated.View style={structureStyle}>{structure}</Animated.View>
<Sheen run={sheen} color={colors.primary} strength={0.3} />
</View>
<View className={cn("absolute inset-0", !inline && "items-center justify-center")}>{body}</View>
</View>
);
}
);
ErrorState.displayName = "ErrorState";
// Exposed for tests / custom wrappers that want the stock copy.
export const errorStateDefaults = DEFAULTS; import { ErrorState } from "@/components/ui/error-state/error-state";
export function Example() {
return (
<ErrorState />
);
} Installation
Install the dependencies
npx expo install react-native-reanimatedAdd the components it builds on
Copy the source from the Code tab into your project
components/ui/error-state/error-state.tsxcomponents/ui/error-state/index.tsIcons it renders
Update the import paths to match your project setup.
Usage
import { ErrorState, errorStateDefaults } from "@/components/ui/error-state/error-state"; <ErrorState /> Also exported: errorStateDefaults.
Examples
One section of a project dashboard failed with a 503 while the rest loaded.
import * as React from "react";
import { View } from "react-native";
import { SwitchOn, rowDelay } from "@lumioui/core";
import { ErrorState } from "../error-state";
import { Text } from "../../text/text";
const wait = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
const SPRINT = [
{ name: "Checkout redesign", done: 18, total: 22 },
{ name: "Android widget", done: 7, total: 15 },
];
const RELEASES = [
{ name: "iOS 4.18.0 · in review", meta: "Submitted Tuesday 16:40 · build 2291" },
{ name: "Android 4.18.0 · rolling out", meta: "20% of users · since Monday 09:15" },
{ name: "Web 2026.41 · live", meta: "Shipped Friday 11:02 · 3 fixes" },
];
/** The releases list — live, or as the dim shape the error sits on. */
function Releases({ live }: { live: boolean }) {
return (
<View>
{RELEASES.map((r, i) => {
const row = (
<View className={i > 0 ? "gap-1 border-t border-border py-4" : "gap-1 py-4"}>
<Text className="text-base font-medium">{r.name}</Text>
<Text className="text-sm text-muted-foreground">{r.meta}</Text>
</View>
);
// Live rows switch on in reading order, 40ms apart.
return live ? (
<SwitchOn key={r.name} delay={rowDelay(i)}>
{row}
</SwitchOn>
) : (
<React.Fragment key={r.name}>{row}</React.Fragment>
);
})}
</View>
);
}
/** ErrorStateInline — one section of a project dashboard failed with a 503
* while the rest loaded. The error sits over the section's dim shape and
* counts down "Retrying in 8s", the seconds swapping in place; the first
* automatic retry fails (a 4px flicker, countdown restarts), "Retry now" or
* the next one succeeds: the error dims, a sheen crosses the dim rows and
* the releases switch on 40ms apart. Neutral ink. */
export function ErrorStateInline() {
const attempts = React.useRef(0);
const [loaded, setLoaded] = React.useState(false);
const retry = async () => {
await wait(900);
attempts.current += 1;
if (attempts.current < 2) throw new Error("503");
};
return (
<View className="w-full">
<View className="w-full gap-6">
<View className="gap-1">
<Text className="text-3xl font-bold tracking-tight">This week</Text>
<Text variant="muted">Mobile team · Sprint 41</Text>
</View>
<View className="gap-2">
<Text className="px-1 text-[13px] font-medium text-muted-foreground">Projects</Text>
<View className="overflow-hidden rounded-2xl bg-card">
{SPRINT.map((p, i) => (
<View key={p.name} className={i > 0 ? "gap-2 border-t border-border p-4" : "gap-2 p-4"}>
<View className="flex-row items-center justify-between">
<Text className="text-base font-medium">{p.name}</Text>
<Text className="text-sm text-muted-foreground tabular-nums">{`${p.done}/${p.total}`}</Text>
</View>
<View className="h-1.5 overflow-hidden rounded-full bg-muted">
<View className="h-full rounded-full bg-primary" style={{ width: `${(p.done / p.total) * 100}%` }} />
</View>
</View>
))}
</View>
</View>
<View className="gap-2">
<Text className="px-1 text-[13px] font-medium text-muted-foreground">Releases</Text>
<View className="overflow-hidden rounded-2xl bg-card px-4">
{loaded ? (
<Releases live />
) : (
<ErrorState
kind="server"
layout="inline"
title="Couldn't load releases"
description="The store sync timed out."
code="Error 503 · Ref 7Q2-K9"
autoRetry={8}
structure={<Releases live={false} />}
onRetry={retry}
onRecover={() => setLoaded(true)}
/>
)}
</View>
</View>
</View>
</View>
);
} A resale marketplace opened from a shared link to a listing that's gone.
import * as React from "react";
import { View } from "react-native";
import { SwitchOn, rowDelay } from "@lumioui/core";
import { ErrorState } from "../error-state";
import { Button } from "../../button/button";
import { Icon } from "../../icon/icon";
import { Text } from "../../text/text";
const wait = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
const SIMILAR = [
{ name: "Waxed cotton field jacket", meta: "Size M · Good condition", price: "$88", tile: "bg-stone-500" },
{ name: "Quilted barn jacket", meta: "Size M · Like new", price: "$64", tile: "bg-tint-green" },
{ name: "Corduroy-collar work coat", meta: "Size M · Worn in", price: "$52", tile: "bg-tint-amber" },
];
/** ErrorStateNotFound — a resale marketplace opened from a shared link to
* a listing that's gone. Compact back-chevron header, the 404 code
* kept small, "Browse similar" spins, the error dims in place and the
* matches switch on 40ms apart. Ink, no accent. */
export function ErrorStateNotFound() {
const [similar, setSimilar] = React.useState(false);
return (
<View className="min-h-[640px] w-full">
<View className="h-11 flex-row items-center">
<Icon name="chevron-left" size="md" color="foreground" />
<Text className="flex-1 text-center text-[17px] font-semibold">{similar ? "Similar listings" : "Listing"}</Text>
<View className="w-6" />
</View>
{similar ? (
<View className="mt-4 gap-4">
<View className="overflow-hidden rounded-2xl bg-card">
{SIMILAR.map((s, i) => (
// Matches switch on 40ms apart where the error dimmed away.
<SwitchOn key={s.name} delay={rowDelay(i)} className={i > 0 ? "flex-row items-center gap-3 border-t border-border p-3" : "flex-row items-center gap-3 p-3"}>
<View className={`h-16 w-16 items-center justify-center rounded-xl ${s.tile}`}>
<Icon name="shirt" size="md" color="#FFFFFF" />
</View>
<View className="flex-1 gap-0.5">
<Text className="text-[15px] font-semibold">{s.name}</Text>
<Text className="text-sm text-muted-foreground">{s.meta}</Text>
</View>
<Text className="text-base font-semibold tabular-nums">{s.price}</Text>
</SwitchOn>
))}
</View>
<Button variant="ghost" onPress={() => setSimilar(false)}>
Back to the sold listing
</Button>
</View>
) : (
<ErrorState
kind="not-found"
title="This listing has sold"
description="The waxed field jacket from @mirela.k sold yesterday. Similar ones in size M are still around."
code="404 · loopmarket.app/l/8812-KQ"
retryLabel="Browse similar"
onRetry={() => wait(900)}
onRecover={() => setSimilar(true)}
secondaryAction={{ label: "Save a search for this jacket", onPress: () => undefined }}
/>
)}
</View>
);
} A transit app's Departures tab with no signal.
import * as React from "react";
import { View } from "react-native";
import { Accent, SwitchOn, rowDelay } from "@lumioui/core";
import { ErrorState } from "../error-state";
import { Text } from "../../text/text";
const wait = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
const DEPARTURES = [
{ line: "U2", to: "North Quay", mins: "2 min", tile: "bg-tint-orange" },
{ line: "13A", to: "Lumio Central", mins: "6 min", tile: "bg-tint-green" },
{ line: "U2", to: "Old Mill Square", mins: "9 min", tile: "bg-tint-orange" },
{ line: "D", to: "Harbour Gate", mins: "11 min", tile: "bg-tint-red" },
{ line: "13A", to: "Glassworks", mins: "14 min", tile: "bg-tint-green" },
{ line: "U2", to: "North Quay", mins: "17 min", tile: "bg-tint-orange" },
{ line: "D", to: "Riverside Park", mins: "21 min", tile: "bg-tint-red" },
{ line: "13A", to: "Lumio Central", mins: "24 min", tile: "bg-tint-green" },
];
/** The board — live, or as the dim shape the error sits on. */
function Board({ live }: { live: boolean }) {
return (
<View className="overflow-hidden rounded-2xl bg-card">
{DEPARTURES.map((d, i) => {
const row = (
<View className={i > 0 ? "flex-row items-center gap-3 border-t border-border p-4" : "flex-row items-center gap-3 p-4"}>
<View className={`h-8 min-w-[44px] items-center justify-center rounded-lg px-2 ${live ? d.tile : "bg-muted"}`}>
<Text className={live ? "text-sm font-bold text-white" : "text-sm font-bold text-muted-foreground"}>{d.line}</Text>
</View>
<Text className="flex-1 text-base font-medium">{d.to}</Text>
<Text className="text-base font-semibold tabular-nums">{live ? d.mins : "– min"}</Text>
</View>
);
// Live rows switch on top to bottom, 40ms apart.
return live ? (
<SwitchOn key={`${i}-${d.line}`} delay={rowDelay(i)}>
{row}
</SwitchOn>
) : (
<React.Fragment key={`${i}-${d.line}`}>{row}</React.Fragment>
);
})}
</View>
);
}
/** ErrorStateOffline — a transit app's Departures tab with no signal. The
* error sits over the board's own dim shape, not a blank page. The first
* "Try again" fails (spinner, a 4px flicker, warning buzz, "Still no
* connection"); the second gets through: the error dims, one sheen crosses
* the dim board and the live rows switch on 40ms apart. Teal accent. */
export function ErrorStateOffline() {
const attempts = React.useRef(0);
const [online, setOnline] = React.useState(false);
const retry = async () => {
await wait(1100);
attempts.current += 1;
if (attempts.current < 2) throw new Error("offline");
};
return (
<Accent color="teal" className="w-full">
<View className="min-h-[640px] w-full gap-2">
<View className="gap-1">
<Text className="text-3xl font-bold tracking-tight">Departures</Text>
<Text variant="muted">Lumio Central · Platform 1</Text>
</View>
<View className="mt-4">
{online ? (
<Board live />
) : (
<ErrorState
kind="offline"
description="Live departures need a connection. Timetables you saved still work offline."
structure={<Board live={false} />}
onRetry={retry}
onRecover={() => setOnline(true)}
secondaryAction={{ label: "Open saved timetables", onPress: () => undefined }}
/>
)}
</View>
</View>
</Accent>
);
} An expense app's receipt scanner after camera access was denied.
import * as React from "react";
import { View } from "react-native";
import Animated, { Easing, useAnimatedStyle, useSharedValue, withTiming } from "react-native-reanimated";
import { Accent, BAND_STAGGER, SwitchOn, ease, timing, useReducedMotion } from "@lumioui/core";
import { ErrorState } from "../error-state";
import { Button } from "../../button/button";
import { Icon } from "../../icon/icon";
import { Text } from "../../text/text";
const wait = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
/** Each corner arm draws out from its corner — the checkbox-mark draw beat. */
const DRAW = { ...timing.normal, easing: Easing.bezier(...ease.out) } as const;
/** Length of each corner arm. */
const ARM = 36;
const CORNERS = [
{ key: "tl", h: "left", v: "top" },
{ key: "tr", h: "right", v: "top" },
{ key: "bl", h: "left", v: "bottom" },
{ key: "br", h: "right", v: "bottom" },
] as const;
/** One corner bracket of the scan frame, drawing its two arms on. */
function Corner({ corner }: { corner: (typeof CORNERS)[number] }) {
const reduce = useReducedMotion();
const drawn = useSharedValue(reduce ? 1 : 0);
React.useEffect(() => {
if (!reduce) drawn.value = withTiming(1, DRAW);
}, [drawn, reduce]);
const { h, v } = corner;
// Arms grow out of the corner point (scale, not size — transforms only).
const across = useAnimatedStyle(() => ({ transformOrigin: `${h} center`, transform: [{ scaleX: drawn.value }] }));
const down = useAnimatedStyle(() => ({ transformOrigin: `center ${v}`, transform: [{ scaleY: drawn.value }] }));
return (
<View
style={{
position: "absolute",
width: ARM,
height: ARM,
[v]: 0,
[h]: 0,
}}
>
<Animated.View className="absolute h-1 rounded-full bg-primary" style={[{ [v]: 0, [h]: 0, width: ARM }, across]} />
<Animated.View className="absolute w-1 rounded-full bg-primary" style={[{ [v]: 0, [h]: 0, height: ARM }, down]} />
</View>
);
}
/** ErrorStatePermission — an expense app's receipt scanner after camera
* access was denied. "Open Settings" hands off (simulated) and comes back
* granted: the error dims and, in its place, the scan frame draws its four
* corners on (240ms), then the hint switches on. Amber accent. */
export function ErrorStatePermission() {
const [granted, setGranted] = React.useState(false);
return (
<Accent color="amber" className="w-full">
<View className="min-h-[640px] w-full gap-2">
<View className="gap-1">
<Text className="text-3xl font-bold tracking-tight">Scan receipt</Text>
<Text variant="muted">Business trip · Harbourside, Oct 14–16</Text>
</View>
{granted ? (
<View className="mt-4 gap-4">
<View className="h-[420px] items-center justify-center rounded-3xl bg-card p-6">
<View className="h-full w-full items-center justify-center">
{CORNERS.map((c) => (
<Corner key={c.key} corner={c} />
))}
<SwitchOn delay={BAND_STAGGER * 2} className="items-center">
<Icon name="camera" size="xl" color="mutedForeground" />
<Text className="mt-3 text-base text-muted-foreground">Hold the receipt inside the frame</Text>
</SwitchOn>
</View>
</View>
<Button variant="ghost" onPress={() => setGranted(false)}>
Reset demo
</Button>
</View>
) : (
<ErrorState
kind="permission"
icon="camera"
title="Allow camera to scan receipts"
description="Lumio Expense reads totals, dates and VAT straight off the paper. Photos stay in this report."
onRetry={() => wait(900)}
onRecover={() => setGranted(true)}
secondaryAction={{ label: "Enter amount manually", onPress: () => undefined }}
/>
)}
</View>
</Accent>
);
} API reference
onPress* () => void } — kind ErrorKind Which failure — picks the glyph and default copy. Default "generic".
"generic" layout "screen" | "inline" "screen" (centred, fills its parent) or "inline" (a failed section). Default "screen".
"screen" title string Override the title.
—description string Override the help line.
—code string Small reference under the help — "Error 503 · Ref 7Q2-K9".
—icon IconName Override the glyph.
—illustration React.ReactNode Replace the glyph disc with your own illustration.
—onRetry () => void | Promise<unknown> Primary action. May return a Promise — rejection counts as a failed retry.
—retryLabel string Primary action label. Default per kind ("Try again", "Open Settings"…).
—retryFailedMessage string Shown (and announced) after a failed retry.
—secondaryAction { label: string Quieter second action.
—autoRetry number Retry automatically after this many seconds (restarts after each failure).
—structure React.ReactNode The exact layout of the content that failed, drawn dim under the error.
—onRecover () => void After a successful retry: the error dims, a sheen crosses the structure, then this fires — show the real content here.
—className string —