Illustrated placeholder with title, copy and action slot.
components/ui/empty-state/empty-state.tsx@lumioui/core@latestreact-native-reanimated@*Live preview — the real component, driven by the props below.
import { EmptyState } from "@/components/ui/empty-state/empty-state";
export function Example() {
return (
<EmptyState />
);
} import * as React from "react";
import { forwardRef } from "react";
import { View, Text, type ViewProps } from "react-native";
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
withTiming,
withDelay,
} from "react-native-reanimated";
import { cn, createVariant, spring, timing, useReducedMotion, type ColorTokens } from "@lumioui/core";
import { Icon, type IconName } from "../icon";
const iconWrapVariants = createVariant({
base: "h-16 w-16 items-center justify-center rounded-full",
variants: {
variant: {
default: "bg-muted",
search: "bg-accent",
error: "bg-destructive/10",
},
},
defaultVariants: { variant: "default" },
});
const variantIcons: Record<NonNullable<EmptyStateProps["variant"]>, IconName> = {
default: "info",
search: "search",
error: "alert-triangle",
};
const variantIconColors: Record<NonNullable<EmptyStateProps["variant"]>, keyof ColorTokens> = {
default: "mutedForeground",
search: "foreground",
error: "destructive",
};
export interface EmptyStateProps extends ViewProps {
/** Visual tone. Default "default". */
variant?: "default" | "search" | "error";
/** Icon slot rendered in a circular well. Defaults to a lucide icon per variant. */
icon?: React.ReactNode;
/** Headline. */
title: string;
/** Supporting copy. */
description?: string;
/** Optional action slot (e.g. a <Button>). */
action?: React.ReactNode;
className?: string;
}
/**
* EmptyState — friendly placeholder for empty, no-result, or error screens.
*/
export const EmptyState = forwardRef<React.ElementRef<typeof View>, EmptyStateProps>(
function EmptyState({ variant = "default", icon, title, description, action, className, ...props }, ref) {
const reduceMotion = useReducedMotion();
// Staggered mount: icon pops with a spring, then title, then body/action.
const iconPop = useSharedValue(reduceMotion ? 1 : 0);
const titleIn = useSharedValue(reduceMotion ? 1 : 0);
const bodyIn = useSharedValue(reduceMotion ? 1 : 0);
React.useEffect(() => {
if (reduceMotion) return;
// spring.bouncy gives the illustration a soft overshoot-and-settle —
// it should feel placed, not slid in.
iconPop.value = withSpring(1, spring.bouncy);
titleIn.value = withDelay(90, withTiming(1, timing.normal));
bodyIn.value = withDelay(180, withTiming(1, timing.normal));
}, [iconPop, titleIn, bodyIn, reduceMotion]);
const iconStyle = useAnimatedStyle(() => ({
opacity: iconPop.value,
transform: [{ scale: 0.6 + iconPop.value * 0.4 }],
}));
const titleStyle = useAnimatedStyle(() => ({
opacity: titleIn.value,
transform: [{ translateY: (1 - titleIn.value) * 8 }],
}));
const bodyStyle = useAnimatedStyle(() => ({
opacity: bodyIn.value,
transform: [{ translateY: (1 - bodyIn.value) * 8 }],
}));
return (
<View
ref={ref}
className={cn("items-center justify-center gap-3 px-8 py-12", className)}
accessibilityRole="text"
accessibilityLabel={description ? `${title}. ${description}` : title}
{...props}
>
<Animated.View style={iconStyle} className={iconWrapVariants({ variant })}>
{icon ?? (
<Icon
name={variantIcons[variant]}
size="lg"
color={variantIconColors[variant]}
/>
)}
</Animated.View>
<Animated.View style={titleStyle}>
<Text
className="text-center text-lg font-semibold leading-tight text-foreground"
accessibilityRole="header"
>
{title}
</Text>
</Animated.View>
{description ? (
<Animated.View style={bodyStyle}>
<Text className="text-center text-sm text-muted-foreground">{description}</Text>
</Animated.View>
) : null}
{action ? (
<Animated.View style={bodyStyle} className="mt-2">
{action}
</Animated.View>
) : null}
</View>
);
}
);
EmptyState.displayName = "EmptyState";
| title | string | Headline. | |
| variant? | "default" | "search" | "error" | Visual tone. Default "default". | |
| icon? | React.ReactNode | Icon slot rendered in a circular well. Defaults to a lucide icon per variant. | |
| description? | string | Supporting copy. | |
| action? | React.ReactNode | Optional action slot (e.g. a <Button>). | |
| className? | string |
Illustrated placeholder with title, copy and action slot.
import { EmptyStateVariants } from "@/components/ui/empty-state/variants/variants";
export function Example() {
return (
<EmptyStateVariants />
);
} import * as React from "react";
import { View } from "react-native";
import { EmptyState } from "../empty-state";
import { Button } from "../../button/button";
import { Icon } from "../../icon/icon";
import { Glass } from "../../glass/glass";
/** EmptyStateVariants — default, search, and error treatments. */
export function EmptyStateVariants() {
return (
<View className="w-full gap-3">
<Glass interactive curvature="edge" className="rounded-2xl">
<EmptyState
title="No orders yet"
description="Your first Sakura Ramen order shows up here."
icon={<Icon name="package" size="lg" color="mutedForeground" />}
action={<Button size="sm">Browse menu</Button>}
className="gap-2 px-4 py-4"
/>
</Glass>
<Glass interactive curvature="edge" className="rounded-2xl">
<EmptyState
variant="search"
title="No results for 'gf ramen'"
description="Try another term, or clear filters."
action={<Button size="sm" variant="outline">Clear filters</Button>}
className="gap-2 px-4 py-4"
/>
</Glass>
<Glass interactive curvature="edge" className="rounded-2xl">
<EmptyState
variant="error"
title="Couldn't load notifications"
description="You're offline. Reconnect to sync."
action={<Button size="sm" variant="outline">Retry</Button>}
className="gap-2 px-4 py-4"
/>
</Glass>
</View>
);
}