Large-avatar header with handle and meta row, edit action, pressable stats card, grouped menu list, and destructive logout
import * as React from "react";
import { View, ScrollView } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { AnimatedNumber, Avatar, Button, Glass, Icon, Image, List, Text } from "@lumioui/ui";
import { AnimatedPressable, useHaptic, usePressAnimation } from "@lumioui/core";
import { BLOCK_STAGGER, catalogImg, HeroGradient, Mount, RowIcon, SEPARATOR_INSET, SERIF, StoryScrim } from "../shared";
export interface ProfileUser {
name: string;
handle: string;
bio?: string;
avatarUrl?: string;
location?: string;
link?: string;
/** Footer line — e.g. "Joined March 2022". */
joined?: string;
}
export interface ProfileStats {
posts: number;
followers: number;
following: number;
}
export interface ProfileScreenProps {
user?: ProfileUser;
stats?: ProfileStats;
onEdit?: () => void;
onNavigate?: (route: string) => void;
onStatPress?: (key: keyof ProfileStats) => void;
onLogout?: () => void;
}
const face = (n: number) => `https://i.pravatar.cc/150?img=${n}`;
const DEFAULT_STATS: ProfileStats = { posts: 128, followers: 4200, following: 312 };
function Stat({
value,
label,
onPress,
}: {
value: number;
label: string;
onPress?: () => void;
}) {
const haptics = useHaptic();
const press = usePressAnimation({ pressedScale: 0.94, fast: true });
return (
<AnimatedPressable
onPress={() => {
haptics.light();
onPress?.();
}}
onPressIn={press.onPressIn}
onPressOut={press.onPressOut}
style={press.animatedStyle}
accessibilityRole="button"
accessibilityLabel={`${value.toLocaleString("en-US")} ${label.toLowerCase()}`}
className="min-h-11 flex-1 items-center justify-center gap-0.5 py-1"
>
<AnimatedNumber
value={value}
format={(n) =>
n >= 1000 ? `${(n / 1000).toFixed(1).replace(/\.0$/, "")}k` : `${Math.round(n)}`
}
className="text-lg font-semibold leading-tight"
/>
<Text variant="muted" className="text-xs">
{label}
</Text>
</AnimatedPressable>
);
}
Stat.displayName = "Stat";
/** Profile — avatar + name + handle header, stats card, grouped menu list. */
export function ProfileScreen({
user = {
name: "Amara Chen",
handle: "@amarachen",
avatarUrl: face(9),
bio: "Product designer · coffee first · writing about design systems",
location: "Lisbon",
link: "amara.design",
joined: "Joined March 2022",
},
stats = DEFAULT_STATS,
onEdit,
onNavigate,
onStatPress,
onLogout,
}: ProfileScreenProps) {
const haptics = useHaptic();
const insets = useSafeAreaInsets();
// Rows only get onPress when the host wired navigation — a row with a
// press handler always announces as a button, even if it's a no-op.
const nav = (route: string) =>
onNavigate
? () => {
haptics.light();
onNavigate(route);
}
: undefined;
const stat = (key: keyof ProfileStats) => () => onStatPress?.(key);
return (
<View className="flex-1 bg-[#F4F5F9] dark:bg-[#111114]">
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={{ paddingBottom: insets.bottom + 32 }}
>
{/* Cover hero — photo + indigo wash, avatar rides the seam. The
negative margin overlaps the avatar into the cover so the
header reads as one composed surface, not stacked blocks. */}
<View className="h-44 overflow-hidden">
<Image
source={{ uri: catalogImg("flatlay") }}
className="absolute inset-0 h-full w-full"
accessibilityElementsHidden
/>
<HeroGradient from="#1B256599" to="#0C1030" />
<View className="absolute inset-x-0 bottom-0 h-20">
<StoryScrim edge="bottom" />
</View>
</View>
{/* Header — ringed avatar overlaps the cover, name, handle. */}
<Mount>
<View className="-mt-10 flex-row items-end gap-4 px-6">
{/* Stories-style ring — background-colored rim lifts the avatar
off the photo. */}
<View className="rounded-full border-4 border-background">
<Avatar
size="xl"
source={user.avatarUrl ? { uri: user.avatarUrl } : undefined}
fallback={user.name}
alt={user.name}
status="online"
/>
</View>
<View className="flex-1 gap-0.5 pb-1">
<Text variant="h4" accessibilityRole="header" numberOfLines={1} style={{ fontFamily: SERIF }}>
{user.name}
</Text>
<Text variant="muted" numberOfLines={1}>
{user.handle}
</Text>
</View>
{/* Share-profile affordance — the one action headers earn. */}
<AnimatedPressable
onPress={() => haptics.light()}
accessibilityRole="button"
accessibilityLabel="Share profile"
className="mb-1 h-9 w-9 items-center justify-center overflow-hidden rounded-full"
>
<Glass interactive effect="clear" sheen={0.5} className="absolute inset-0 rounded-full" pointerEvents="none" />
<Icon name="share" size="sm" color="foreground" />
</AnimatedPressable>
</View>
</Mount>
{user.bio ? (
<Mount delay={BLOCK_STAGGER}>
<Text className="px-6 pt-3">{user.bio}</Text>
</Mount>
) : null}
{/* Meta — location, link, join date. */}
<Mount delay={BLOCK_STAGGER * 2}>
<View className="flex-row flex-wrap items-center gap-x-4 gap-y-1 px-6 pt-2">
{user.location ? (
<View className="flex-row items-center gap-1">
<Icon name="map-pin" size="sm" color="mutedForeground" />
<Text variant="muted">{user.location}</Text>
</View>
) : null}
{user.link ? (
<View className="flex-row items-center gap-1">
<Icon name="link" size="sm" color="mutedForeground" />
<Text variant="muted" className="text-primary">
{user.link}
</Text>
</View>
) : null}
{user.joined ? (
<View className="flex-row items-center gap-1">
<Icon name="calendar" size="sm" color="mutedForeground" />
<Text variant="muted">{user.joined}</Text>
</View>
) : null}
</View>
</Mount>
{/* Edit */}
<Mount delay={BLOCK_STAGGER * 3}>
<View className="px-6 pt-4">
<Button
variant="secondary"
onPress={() => {
haptics.light();
onEdit?.();
}}
>
Edit profile
</Button>
</View>
</Mount>
{/* Stats card — pressable, routes to posts/followers/following. */}
<Mount delay={BLOCK_STAGGER * 4}>
<View className="mx-6 mt-5 flex-row items-center overflow-hidden rounded-3xl bg-white px-2 py-3 dark:bg-[#1A1A20]">
<Stat value={stats.posts} label="Posts" onPress={stat("posts")} />
<View className="h-8 w-px bg-border" accessibilityElementsHidden importantForAccessibility="no" />
<Stat value={stats.followers} label="Followers" onPress={stat("followers")} />
<View className="h-8 w-px bg-border" accessibilityElementsHidden importantForAccessibility="no" />
<Stat value={stats.following} label="Following" onPress={stat("following")} />
</View>
</Mount>
{/* Menu — sections stagger in after the stats card. */}
<List inset className="pt-6">
<Mount delay={BLOCK_STAGGER * 5}>
<List.Section title="Your content">
<List.Item
title="Posts"
subtitle={`${stats.posts.toLocaleString("en-US")} published`}
leading={<RowIcon name="grid" tone="blue" />}
showChevron
haptic="light"
onPress={nav("profile/posts")}
/>
<List.Separator className={SEPARATOR_INSET} />
<List.Item
title="Saved"
leading={<RowIcon name="bookmark" tone="orange" />}
showChevron
haptic="light"
onPress={nav("profile/saved")}
/>
<List.Separator className={SEPARATOR_INSET} />
<List.Item
title="Liked"
leading={<RowIcon name="heart" tone="red" />}
showChevron
haptic="light"
onPress={nav("profile/liked")}
/>
</List.Section>
</Mount>
<Mount delay={BLOCK_STAGGER * 6}>
<List.Section title="Account">
<List.Item
title="Personal information"
leading={<RowIcon name="user" tone="blue" />}
showChevron
haptic="light"
onPress={nav("account/personal")}
/>
<List.Separator className={SEPARATOR_INSET} />
<List.Item
title="Subscription"
subtitle="Lumio Pro"
leading={<RowIcon name="crown" tone="purple" />}
showChevron
haptic="light"
onPress={nav("account/subscription")}
/>
<List.Separator className={SEPARATOR_INSET} />
<List.Item
title="Settings"
leading={<RowIcon name="settings" tone="gray" />}
showChevron
haptic="light"
onPress={nav("settings")}
/>
</List.Section>
</Mount>
<Mount delay={BLOCK_STAGGER * 7}>
<List.Section title="Support">
<List.Item
title="Help center"
leading={<RowIcon name="question-circle" tone="teal" />}
showChevron
haptic="light"
onPress={nav("support/help")}
/>
<List.Separator className={SEPARATOR_INSET} />
<List.Item
title="Invite friends"
leading={<RowIcon name="user-plus" tone="green" />}
showChevron
haptic="light"
onPress={nav("support/invite")}
/>
</List.Section>
</Mount>
{/* Destructive row — centered label is the iOS logout idiom. */}
<Mount delay={BLOCK_STAGGER * 8}>
<List.Section>
<List.Item
title="Log Out"
titleClassName="text-center font-medium text-destructive"
haptic={false}
onPress={() => {
haptics.warning();
onLogout?.();
}}
accessibilityLabel="Log out"
accessibilityHint="Ends your session on this device"
/>
</List.Section>
</Mount>
</List>
</ScrollView>
</View>
);
}
Recreate the "Profile Screen" screen in React Native using LumioUI.
Goal: Large-avatar header with handle and meta row, edit action, pressable stats card, grouped menu list, and destructive logout Match the reference implementation's layout, behavior, and motion exactly — this is a production screen, not a mockup.
## Setup
Fastest path — install the screen plus its component deps:
```bash
npx lumioui add core-profile
```
Or pull just the components: `npx lumioui add animated-number avatar button icon image list text`
Native packages:
- @lumioui/ui@latest
- @lumioui/core@latest
- react-native-safe-area-context@*
- react-native-reanimated@*
- react-native-svg@*
- nativewind@*
Stack assumptions: Expo SDK 54 / RN 0.81, Reanimated 4, NativeWind v4, TypeScript strict, Expo Go compatible.
## Components — how, where, when
### `animated-number` — Number that tweens between values with reduced-motion support.
- `<AnimatedNumber>` ×1
### `avatar` — Circular user image with fallback initials and status dot.
- `<Avatar>` ×1
### `button` — Tactile pill button with press spring, ink wash and async loading.
- `<Button>` ×1
### `icon` — Lucide icon set mapped to theme colors and sizes.
- `<Icon>` ×4
### `image` — Cached image with placeholder, fallback and aspect handling.
- `<Image>` ×1
### `list` — Settings-style rows with separators and press feedback.
- `<List>` ×1 — Menu — sections stagger in after the stats card.
### `text` — Themed type scale from caption to display.
- `<Text>` ×7
## Conventions (non-negotiable)
- Styling via NativeWind `className` with semantic tokens only: `bg-background`, `text-foreground`, `bg-card`, `text-card-foreground`, `bg-secondary`, `bg-muted`, `text-muted-foreground`, `bg-accent`, `bg-destructive`, `border-border`. Hard-coded colors only inside media overlays (`text-white`, `bg-black/N`).
- Motion via the token presets from `@lumioui/core` (`spring.press`, `spring.surface`, `spring.glide`, `timing.fast`, `timing.slow`, …) — no bespoke spring configs or durations.
- Gate transform motion behind `useReducedMotion()`; keep opacity/color feedback, drop travel and overshoot.
- Haptics via `useHaptic()`: `light` on press, `selection` on toggle/select, `warning` on invalid input. Never import expo-haptics directly.
- Press feedback via `usePressAnimation()` (~0.97 scale), not `activeOpacity` tricks.
- a11y: `accessibilityRole` + `accessibilityLabel` on every control, touch targets ≥44pt.
- Entrance choreography: staggered `Mount` wrappers (delay 0/60/120…) so regions land in sequence instead of popping in at once.
- Wrap the screen in `SafeAreaView`; keep dark mode working via the semantic tokens.
## Deliverable
One file: `screens/core/profile.tsx` exporting ProfileScreen. Preserve the props interface — the host app wires the callbacks (navigation, submit handlers) through them.
## Reference implementation
The LumioUI source for this screen — reproduce it faithfully:
```tsx
import * as React from "react";
import { View, ScrollView } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { AnimatedNumber, Avatar, Button, Glass, Icon, Image, List, Text } from "@lumioui/ui";
import { AnimatedPressable, useHaptic, usePressAnimation } from "@lumioui/core";
import { BLOCK_STAGGER, catalogImg, HeroGradient, Mount, RowIcon, SEPARATOR_INSET, SERIF, StoryScrim } from "../shared";
export interface ProfileUser {
name: string;
handle: string;
bio?: string;
avatarUrl?: string;
location?: string;
link?: string;
/** Footer line — e.g. "Joined March 2022". */
joined?: string;
}
export interface ProfileStats {
posts: number;
followers: number;
following: number;
}
export interface ProfileScreenProps {
user?: ProfileUser;
stats?: ProfileStats;
onEdit?: () => void;
onNavigate?: (route: string) => void;
onStatPress?: (key: keyof ProfileStats) => void;
onLogout?: () => void;
}
const face = (n: number) => `https://i.pravatar.cc/150?img=${n}`;
const DEFAULT_STATS: ProfileStats = { posts: 128, followers: 4200, following: 312 };
function Stat({
value,
label,
onPress,
}: {
value: number;
label: string;
onPress?: () => void;
}) {
const haptics = useHaptic();
const press = usePressAnimation({ pressedScale: 0.94, fast: true });
return (
<AnimatedPressable
onPress={() => {
haptics.light();
onPress?.();
}}
onPressIn={press.onPressIn}
onPressOut={press.onPressOut}
style={press.animatedStyle}
accessibilityRole="button"
accessibilityLabel={`${value.toLocaleString("en-US")} ${label.toLowerCase()}`}
className="min-h-11 flex-1 items-center justify-center gap-0.5 py-1"
>
<AnimatedNumber
value={value}
format={(n) =>
n >= 1000 ? `${(n / 1000).toFixed(1).replace(/\.0$/, "")}k` : `${Math.round(n)}`
}
className="text-lg font-semibold leading-tight"
/>
<Text variant="muted" className="text-xs">
{label}
</Text>
</AnimatedPressable>
);
}
Stat.displayName = "Stat";
/** Profile — avatar + name + handle header, stats card, grouped menu list. */
export function ProfileScreen({
user = {
name: "Amara Chen",
handle: "@amarachen",
avatarUrl: face(9),
bio: "Product designer · coffee first · writing about design systems",
location: "Lisbon",
link: "amara.design",
joined: "Joined March 2022",
},
stats = DEFAULT_STATS,
onEdit,
onNavigate,
onStatPress,
onLogout,
}: ProfileScreenProps) {
const haptics = useHaptic();
const insets = useSafeAreaInsets();
// Rows only get onPress when the host wired navigation — a row with a
// press handler always announces as a button, even if it's a no-op.
const nav = (route: string) =>
onNavigate
? () => {
haptics.light();
onNavigate(route);
}
: undefined;
const stat = (key: keyof ProfileStats) => () => onStatPress?.(key);
return (
<View className="flex-1 bg-[#F4F5F9] dark:bg-[#111114]">
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={{ paddingBottom: insets.bottom + 32 }}
>
{/* Cover hero — photo + indigo wash, avatar rides the seam. The
negative margin overlaps the avatar into the cover so the
header reads as one composed surface, not stacked blocks. */}
<View className="h-44 overflow-hidden">
<Image
source={{ uri: catalogImg("flatlay") }}
className="absolute inset-0 h-full w-full"
accessibilityElementsHidden
/>
<HeroGradient from="#1B256599" to="#0C1030" />
<View className="absolute inset-x-0 bottom-0 h-20">
<StoryScrim edge="bottom" />
</View>
</View>
{/* Header — ringed avatar overlaps the cover, name, handle. */}
<Mount>
<View className="-mt-10 flex-row items-end gap-4 px-6">
{/* Stories-style ring — background-colored rim lifts the avatar
off the photo. */}
<View className="rounded-full border-4 border-background">
<Avatar
size="xl"
source={user.avatarUrl ? { uri: user.avatarUrl } : undefined}
fallback={user.name}
alt={user.name}
status="online"
/>
</View>
<View className="flex-1 gap-0.5 pb-1">
<Text variant="h4" accessibilityRole="header" numberOfLines={1} style={{ fontFamily: SERIF }}>
{user.name}
</Text>
<Text variant="muted" numberOfLines={1}>
{user.handle}
</Text>
</View>
{/* Share-profile affordance — the one action headers earn. */}
<AnimatedPressable
onPress={() => haptics.light()}
accessibilityRole="button"
accessibilityLabel="Share profile"
className="mb-1 h-9 w-9 items-center justify-center overflow-hidden rounded-full"
>
<Glass interactive effect="clear" sheen={0.5} className="absolute inset-0 rounded-full" pointerEvents="none" />
<Icon name="share" size="sm" color="foreground" />
</AnimatedPressable>
</View>
</Mount>
{user.bio ? (
<Mount delay={BLOCK_STAGGER}>
<Text className="px-6 pt-3">{user.bio}</Text>
</Mount>
) : null}
{/* Meta — location, link, join date. */}
<Mount delay={BLOCK_STAGGER * 2}>
<View className="flex-row flex-wrap items-center gap-x-4 gap-y-1 px-6 pt-2">
{user.location ? (
<View className="flex-row items-center gap-1">
<Icon name="map-pin" size="sm" color="mutedForeground" />
<Text variant="muted">{user.location}</Text>
</View>
) : null}
{user.link ? (
<View className="flex-row items-center gap-1">
<Icon name="link" size="sm" color="mutedForeground" />
<Text variant="muted" className="text-primary">
{user.link}
</Text>
</View>
) : null}
{user.joined ? (
<View className="flex-row items-center gap-1">
<Icon name="calendar" size="sm" color="mutedForeground" />
<Text variant="muted">{user.joined}</Text>
</View>
) : null}
</View>
</Mount>
{/* Edit */}
<Mount delay={BLOCK_STAGGER * 3}>
<View className="px-6 pt-4">
<Button
variant="secondary"
onPress={() => {
haptics.light();
onEdit?.();
}}
>
Edit profile
</Button>
</View>
</Mount>
{/* Stats card — pressable, routes to posts/followers/following. */}
<Mount delay={BLOCK_STAGGER * 4}>
<View className="mx-6 mt-5 flex-row items-center overflow-hidden rounded-3xl bg-white px-2 py-3 dark:bg-[#1A1A20]">
<Stat value={stats.posts} label="Posts" onPress={stat("posts")} />
<View className="h-8 w-px bg-border" accessibilityElementsHidden importantForAccessibility="no" />
<Stat value={stats.followers} label="Followers" onPress={stat("followers")} />
<View className="h-8 w-px bg-border" accessibilityElementsHidden importantForAccessibility="no" />
<Stat value={stats.following} label="Following" onPress={stat("following")} />
</View>
</Mount>
{/* Menu — sections stagger in after the stats card. */}
<List inset className="pt-6">
<Mount delay={BLOCK_STAGGER * 5}>
<List.Section title="Your content">
<List.Item
title="Posts"
subtitle={`${stats.posts.toLocaleString("en-US")} published`}
leading={<RowIcon name="grid" tone="blue" />}
showChevron
haptic="light"
onPress={nav("profile/posts")}
/>
<List.Separator className={SEPARATOR_INSET} />
<List.Item
title="Saved"
leading={<RowIcon name="bookmark" tone="orange" />}
showChevron
haptic="light"
onPress={nav("profile/saved")}
/>
<List.Separator className={SEPARATOR_INSET} />
<List.Item
title="Liked"
leading={<RowIcon name="heart" tone="red" />}
showChevron
haptic="light"
onPress={nav("profile/liked")}
/>
</List.Section>
</Mount>
<Mount delay={BLOCK_STAGGER * 6}>
<List.Section title="Account">
<List.Item
title="Personal information"
leading={<RowIcon name="user" tone="blue" />}
showChevron
haptic="light"
onPress={nav("account/personal")}
/>
<List.Separator className={SEPARATOR_INSET} />
<List.Item
title="Subscription"
subtitle="Lumio Pro"
leading={<RowIcon name="crown" tone="purple" />}
showChevron
haptic="light"
onPress={nav("account/subscription")}
/>
<List.Separator className={SEPARATOR_INSET} />
<List.Item
title="Settings"
leading={<RowIcon name="settings" tone="gray" />}
showChevron
haptic="light"
onPress={nav("settings")}
/>
</List.Section>
</Mount>
<Mount delay={BLOCK_STAGGER * 7}>
<List.Section title="Support">
<List.Item
title="Help center"
leading={<RowIcon name="question-circle" tone="teal" />}
showChevron
haptic="light"
onPress={nav("support/help")}
/>
<List.Separator className={SEPARATOR_INSET} />
<List.Item
title="Invite friends"
leading={<RowIcon name="user-plus" tone="green" />}
showChevron
haptic="light"
onPress={nav("support/invite")}
/>
</List.Section>
</Mount>
{/* Destructive row — centered label is the iOS logout idiom. */}
<Mount delay={BLOCK_STAGGER * 8}>
<List.Section>
<List.Item
title="Log Out"
titleClassName="text-center font-medium text-destructive"
haptic={false}
onPress={() => {
haptics.warning();
onLogout?.();
}}
accessibilityLabel="Log out"
accessibilityHint="Ends your session on this device"
/>
</List.Section>
</Mount>
</List>
</ScrollView>
</View>
);
}
```
Screen: https://lumioui.dev/screens/core-profile