Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 56 additions & 22 deletions components/ui/EmptyState.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React from "react";
import { View, Text } from "react-native";
import { View } from "react-native";
import Animated, { FadeInDown, ZoomIn } from "react-native-reanimated";
import type { LucideIcon } from "lucide-react-native";
import { Button } from "~/components/ui/Button";

Expand All @@ -19,7 +20,10 @@ export interface EmptyStateProps {

/**
* A centred icon-badge + title + body used for empty, error, and idle states
* across the app. Keeps every "nothing here" screen visually consistent.
* across the app. The badge sits inside a soft concentric halo for depth, and
* the whole group eases in — the badge springs, the copy rises with a short
* stagger — so "nothing here" feels considered rather than blank. Keeps every
* empty/error screen visually consistent.
*/
export function EmptyState({
icon: Icon,
Expand All @@ -33,31 +37,61 @@ export function EmptyState({
}: EmptyStateProps) {
const isError = variant === "error";
return (
<View className={"flex-1 items-center justify-center px-10 " + (className ?? "")}>
<View
className={
"h-20 w-20 items-center justify-center rounded-2xl " +
(isError ? "bg-destructive/10" : "bg-muted")
}
// Plain View owns the flex-1 centering; only the inner pieces animate, so
// the entrance can never interfere with layout.
<View
className={"flex-1 items-center justify-center px-10 " + (className ?? "")}
>
{/* Layered badge: a faint outer halo behind a solid inner tile gives the
icon depth instead of floating on a flat square. */}
<Animated.View
entering={ZoomIn.springify().damping(14).stiffness(160)}
className="h-24 w-24 items-center justify-center"
>
<Icon size={40} color={isError ? "#dc2626" : "#94a3b8"} />
</View>
<Text className="mt-5 text-lg font-semibold text-foreground">{title}</Text>
<View
className={
"absolute h-24 w-24 rounded-3xl " +
(isError ? "bg-destructive/5" : "bg-primary/5")
}
/>
<View
className={
"h-[68px] w-[68px] items-center justify-center rounded-2xl " +
(isError ? "bg-destructive/10" : "bg-primary/10")
}
>
<Icon size={36} color={isError ? "#dc2626" : "#64748b"} />
</View>
</Animated.View>

<Animated.Text
entering={FadeInDown.delay(90).duration(340)}
className="mt-5 text-lg font-semibold text-foreground"
>
{title}
</Animated.Text>

{description ? (
<Text className="mt-2 text-center text-sm text-muted-foreground">
<Animated.Text
entering={FadeInDown.delay(150).duration(340)}
className="mt-2 text-center text-sm text-muted-foreground"
>
{description}
</Text>
</Animated.Text>
) : null}

{actionLabel && onAction ? (
<Button
variant={isError ? "default" : "outline"}
size="sm"
className="mt-6"
onPress={onAction}
loading={actionLoading}
>
{actionLabel}
</Button>
<Animated.View entering={FadeInDown.delay(210).duration(340)}>
<Button
variant={isError ? "default" : "outline"}
size="sm"
className="mt-6"
onPress={onAction}
loading={actionLoading}
>
{actionLabel}
</Button>
</Animated.View>
) : null}
</View>
);
Expand Down
Loading