Circular user image with fallback initials and status dot.
components/ui/avatar/avatar.tsx@lumioui/core@latestreact-native-reanimated@*Live preview — the real component, driven by the props below.
import { Avatar } from "@/components/ui/avatar/avatar";
export function Example() {
return (
<Avatar />
);
} import * as React from "react";
import { forwardRef, useState } from "react";
import { View, Text, type ViewProps, type ImageSourcePropType, type GestureResponderEvent, } from "react-native";
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
withTiming,
Easing,
} from "react-native-reanimated";
import { cn, createVariant, useHaptic, usePressAnimation, useReducedMotion, AnimatedPressable, spring, ease, timing } from "@lumioui/core";
import { Icon } from "../icon";
const avatarVariants = createVariant({
base: "items-center justify-center overflow-hidden rounded-full bg-muted",
variants: {
size: {
sm: "h-8 w-8",
md: "h-10 w-10",
lg: "h-14 w-14",
xl: "h-20 w-20",
},
},
defaultVariants: { size: "md" },
});
const statusColors = {
online: "bg-success",
offline: "bg-muted-foreground",
busy: "bg-destructive",
away: "bg-warning",
} as const;
const dotSize = { sm: "h-2 w-2", md: "h-2.5 w-2.5", lg: "h-3.5 w-3.5", xl: "h-4 w-4" } as const;
// Fallback initials track the avatar size — xl with md text looked lost.
const fallbackTextSize = {
sm: "text-xs",
md: "text-sm",
lg: "text-lg",
xl: "text-2xl",
} as const;
const fallbackIconSize = { sm: "sm", md: "md", lg: "lg", xl: "xl" } as const;
/** Image settle — the photo fades in while shrinking from a hair oversized,
* so it reads as landing in the well rather than snapping on (beui content
* reveal: short lift, decelerating ease). */
const IMAGE_SETTLE = { duration: 350, easing: Easing.bezier(...ease.out) };
export interface AvatarProps extends ViewProps {
size?: "sm" | "md" | "lg" | "xl";
/** Image source for the avatar — fades in when it loads. */
source?: ImageSourcePropType;
/** Fallback initials shown when no image (or it fails). */
fallback?: string;
/** Presence dot in the corner — chat/status lists. */
status?: keyof typeof statusColors;
/** Makes the avatar tappable (profile → detail). */
onPress?: (e: GestureResponderEvent) => void;
className?: string;
/** Accessibility label — defaults to "Avatar". */
alt?: string;
}
/**
* Avatar — image fades in on load, initials fallback with person glyph,
* optional presence dot, optional press with spring + haptic.
*/
export const Avatar = forwardRef<React.ElementRef<typeof View>, AvatarProps>(
function Avatar(
{ size = "md", source, fallback, status, onPress, className, alt = "Avatar", ...props },
ref
) {
const [failed, setFailed] = useState(false);
const haptics = useHaptic();
const reduce = useReducedMotion();
const press = usePressAnimation({ pressedScale: 0.94 });
const showImage = source && !failed;
const fade = useSharedValue(0);
// settleT: 0 = just loaded (slightly oversized), 1 = at rest.
const settleT = useSharedValue(0);
const imageStyle = useAnimatedStyle(() => ({
opacity: fade.value,
transform: [{ scale: 1.08 - settleT.value * 0.08 }],
}));
// Presence dot pops in with the bouncy token — a small badge deserves
// the same arrival treatment as a count pill.
const dotPop = useSharedValue(reduce ? 1 : 0);
React.useEffect(() => {
if (status && !reduce) dotPop.value = withSpring(1, spring.bouncy);
else dotPop.value = status ? 1 : 0;
}, [status, reduce, dotPop]);
const dotStyle = useAnimatedStyle(() => ({
opacity: dotPop.value,
transform: [{ scale: 0.4 + dotPop.value * 0.6 }],
}));
const dot = status ? (
<Animated.View
pointerEvents="none"
style={dotStyle}
className={cn(
"absolute bottom-0 right-0 rounded-full border-2 border-background",
dotSize[size],
statusColors[status]
)}
accessibilityElementsHidden
aria-hidden
/>
) : null;
const content = (
<>
{showImage ? (
<>
{/* Initials stay under the image until it loads — no blank flash */}
<Text
className={cn("absolute font-semibold leading-tight text-muted-foreground", fallbackTextSize[size])}
allowFontScaling={false}
>
{(fallback ?? "?").slice(0, 2).toUpperCase()}
</Text>
<Animated.Image
source={source}
className="h-full w-full"
style={imageStyle}
onLoad={() => {
if (reduce) {
fade.value = 1;
settleT.value = 1;
} else {
fade.value = withTiming(1, timing.normal);
settleT.value = withTiming(1, IMAGE_SETTLE);
}
}}
onError={() => setFailed(true)}
accessibilityIgnoresInvertColors
/>
</>
) : fallback ? (
<Text className={cn("font-semibold leading-tight text-muted-foreground", fallbackTextSize[size])} allowFontScaling={false}>
{fallback.slice(0, 2).toUpperCase()}
</Text>
) : (
<Icon name="user" size={fallbackIconSize[size]} color="mutedForeground" />
)}
{dot}
</>
);
const container = cn(avatarVariants({ size }), className);
const a11y = {
accessibilityLabel: alt,
accessibilityRole: (onPress ? "button" : "image") as "button" | "image",
};
if (!onPress) {
return (
<View ref={ref} className={container} {...a11y} {...props}>
{content}
</View>
);
}
return (
<AnimatedPressable
ref={ref as never}
onPress={(e) => {
haptics.light();
onPress(e);
}}
// sm/md avatars are below the 44pt target — expand the hit area.
hitSlop={size === "sm" || size === "md" ? 6 : 0}
onPressIn={press.onPressIn}
onPressOut={press.onPressOut}
style={press.animatedStyle}
className={container}
{...a11y}
{...props}
>
{content}
</AnimatedPressable>
);
}
);
Avatar.displayName = "Avatar";
| size? | "sm" | "md" | "lg" | "xl" | ||
| source? | ImageSourcePropType | Image source for the avatar — fades in when it loads. | |
| fallback? | string | Fallback initials shown when no image (or it fails). | |
| status? | keyof typeof statusColors | Presence dot in the corner — chat/status lists. | |
| onPress? | (e: GestureResponderEvent) => void | Makes the avatar tappable (profile → detail). | |
| className? | string | ||
| alt? | string | Accessibility label — defaults to "Avatar". |
Circular user image with fallback initials and status dot.
import { AvatarFallback } from "@/components/ui/avatar/variants/fallback";
export function Example() {
return (
<AvatarFallback />
);
} import * as React from "react";
import { View } from "react-native";
import { Avatar } from "../avatar";
import { Text } from "../../text/text";
import { Glass } from "../../glass/glass";
/** AvatarFallback — initials and generic-guest fallbacks when no image loads. */
export function AvatarFallback() {
return (
<View className="w-full gap-3">
<Glass interactive curvature="edge" className="w-full gap-3 rounded-2xl p-4">
<Text variant="small">Initials fallback (no image)</Text>
<View className="flex-row flex-wrap items-center gap-3">
<Avatar fallback="LW" alt="Lucia Wang" />
<Avatar fallback="KO" alt="Kenji Okafor" />
<Avatar alt="Guest user" />
</View>
</Glass>
</View>
);
}
Circular user image with fallback initials and status dot.
import { AvatarPresence } from "@/components/ui/avatar/variants/presence";
export function Example() {
return (
<AvatarPresence />
);
} import * as React from "react";
import { View } from "react-native";
import { Avatar } from "../avatar";
import { Text } from "../../text/text";
import { Glass } from "../../glass/glass";
/** AvatarPresence — status dots for online, busy, away and offline. */
export function AvatarPresence() {
return (
<View className="w-full gap-3">
<Glass interactive curvature="edge" className="w-full gap-3 rounded-2xl p-4">
<Text variant="small">Presence dots</Text>
<View className="flex-row flex-wrap items-center gap-3">
<Avatar source={{ uri: "https://i.pravatar.cc/150?img=47" }} fallback="AC" status="online" alt="Amara Chen, online" />
<Avatar source={{ uri: "https://i.pravatar.cc/150?img=12" }} fallback="DR" status="busy" alt="Diego Ruiz, busy" />
<Avatar source={{ uri: "https://i.pravatar.cc/150?img=32" }} fallback="PN" status="away" alt="Priya Nair, away" />
<Avatar source={{ uri: "https://i.pravatar.cc/150?img=68" }} fallback="MO" status="offline" alt="Mara Osei, offline" />
</View>
</Glass>
</View>
);
}
Circular user image with fallback initials and status dot.
import { AvatarSizes } from "@/components/ui/avatar/variants/sizes";
export function Example() {
return (
<AvatarSizes />
);
} import * as React from "react";
import { View } from "react-native";
import { Avatar } from "../avatar";
import { Text } from "../../text/text";
import { Glass } from "../../glass/glass";
/** AvatarSizes — the four-step size scale with tap selection. */
export function AvatarSizes() {
const [selected, setSelected] = React.useState<string | null>(null);
return (
<View className="w-full gap-3">
<Glass interactive curvature="edge" className="w-full gap-3 rounded-2xl p-4">
<View className="flex-row items-baseline justify-between">
<Text variant="small" className="font-semibold">Reviewers</Text>
<Text variant="muted">Tap to assign</Text>
</View>
<View className="flex-row flex-wrap items-center gap-3">
<Avatar size="sm" fallback="LB" alt="Lena Beck" onPress={() => setSelected("Lena Beck")} />
<Avatar size="md" source={{ uri: "https://i.pravatar.cc/150?img=15" }} fallback="JT" alt="Jon Tanaka" onPress={() => setSelected("Jon Tanaka")} />
<Avatar size="lg" source={{ uri: "https://i.pravatar.cc/150?img=59" }} fallback="SK" alt="Sofia Kovacs" onPress={() => setSelected("Sofia Kovacs")} />
<Avatar size="xl" source={{ uri: "https://i.pravatar.cc/150?img=25" }} fallback="AH" alt="Amir Haddad" onPress={() => setSelected("Amir Haddad")} />
</View>
<Text variant="muted">{selected ? `Assigned to ${selected}` : "Unassigned — pick a reviewer"}</Text>
</Glass>
</View>
);
}