shadimage
tetra-ui
uiavatarsource: ok · hit

Avatar

An image element with a fallback for representing the user.

npx shadcn add "https://tetra-ui.com/r/avatar.json"
free

No selection tags yet — empty means no evidence, not lack of support.

Files1 · source cached 10/9/2026, 6:42:54 AM

ui/avatar.tsx
import { cva, type VariantProps } from "class-variance-authority";
import {
  Children,
  cloneElement,
  createContext,
  isValidElement,
  useCallback,
  useContext,
  useMemo,
  useState,
} from "react";
import { Image, type ImageProps, Text, View } from "react-native";
import { withUniwind } from "uniwind";
import { cn } from "@/lib/utils";

// Types
type AvatarSize = NonNullable<VariantProps<typeof avatarVariants>["size"]>;
type AvatarImageStatus = "idle" | "loading" | "loaded" | "error";

type AvatarImageKeyStatus = {
  status: AvatarImageStatus;
  srcKey: string;
};

type AvatarContextValue = {
  size: AvatarSize;
  imageKeyStatus: AvatarImageKeyStatus;
  setImageKeyStatus: (status: AvatarImageKeyStatus) => void;
};

type AvatarProps = React.ComponentProps<typeof View> &
  VariantProps<typeof avatarVariants> & {
    children?: React.ReactNode;
  };

type AvatarImageProps = ImageProps;

type AvatarFallbackProps = React.ComponentProps<typeof View> & {
  children?: React.ReactNode;
};

type AvatarBadgeProps = React.ComponentProps<typeof View> & {
  children?: React.ReactNode;
};

type ClassNameElement = React.ReactElement<{ className?: string }>;

// Context
const AvatarContext = createContext<AvatarContextValue | null>(null);

const useAvatarContext = () => {
  const context = useContext(AvatarContext);
  if (!context) {
    throw new Error("Avatar components must be used within an Avatar");
  }
  return context;
};

const StyledImage = withUniwind(Image);

// Components
export const Avatar = ({
  children,
  className,
  size = "default",
  ...props
}: AvatarProps) => {
  const [imageKeyStatus, _setImageKeyStatus] = useState<AvatarImageKeyStatus>({
    srcKey: "",
    status: "idle",
  });

  const setImageKeyStatus = useCallback((payload: AvatarImageKeyStatus) => {
    _setImageKeyStatus((prev) => {
      const isSameKey = payload.srcKey === prev.srcKey;

      if (prev.status === "loaded" && isSameKey) {
        return prev;
      }

      return payload;
    });
  }, []);

  const ctx = useMemo(
    () => ({
      imageKeyStatus,
      setImageKeyStatus,
      size: size ?? "default",
    }),
    [imageKeyStatus, size, setImageKeyStatus]
  );

  return (
    <AvatarContext.Provider value={ctx}>
      <View
        className={cn(avatarVariants({ className, size }))}
        data-slot="avatar"
        {...props}
      >
        {children}
      </View>
    </AvatarContext.Provider>
  );
};

export const AvatarImage = ({
  alt,
  className,
  onError,
  onLoad,
  onLoadStart,
  source,
  src,
  ...props
}: AvatarImageProps) => {
  const { setImageKeyStatus } = useAvatarContext();

  const resolvedSource = source ?? (src ? { uri: src } : undefined);
  const resolvedSourceKey = JSON.stringify(resolvedSource);

  if (!resolvedSource) {
    return null;
  }

  return (
    <StyledImage
      accessibilityLabel={alt}
      className={cn("aspect-square size-full rounded-full", className)}
      data-slot="avatar-image"
      onError={(event) => {
        setImageKeyStatus({ srcKey: resolvedSourceKey, status: "error" });
        onError?.(event);
      }}
      onLoad={(event) => {
        setImageKeyStatus({ srcKey: resolvedSourceKey, status: "loaded" });
        onLoad?.(event);
      }}
      onLoadStart={() => {
        setImageKeyStatus({ srcKey: resolvedSourceKey, status: "loading" });
        onLoadStart?.();
      }}
      source={resolvedSource}
      {...props}
    />
  );
};

export const AvatarFallback = ({
  children,
  className,
  ...props
}: AvatarFallbackProps) => {
  const { imageKeyStatus, size } = useAvatarContext();

  if (imageKeyStatus.status === "loaded") {
    return null;
  }

  return (
    <View
      className={cn(
        "absolute inset-0 flex size-full items-center justify-center rounded-full bg-muted",
        className
      )}
      data-slot="avatar-fallback"
      {...props}
    >
      {Children.map(children, (child) => {
        if (typeof child === "string") {
          return (
            <Text
              className={cn(
                "font-medium text-muted-foreground",
                size === "sm" ? "text-xs" : "text-sm"
              )}
            >
              {child}
            </Text>
          );
        }

        return child;
      })}
    </View>
  );
};

export const AvatarBadge = ({
  children,
  className,
  ...props
}: AvatarBadgeProps) => {
  const { size } = useAvatarContext();

  return (
    <View
      className={cn(avatarBadgeVariants({ className, size }))}
      data-slot="avatar-badge"
      {...props}
    >
      {Children.map(children, (child) => {
        if (!isValidElement(child)) {
          return child;
        }

        const element = child as ClassNameElement;

        return cloneElement(element, {
          className: cn(
            size === "sm" ? "size-1.5" : "size-2",
            "text-primary-foreground",
            element.props.className
          ),
        });
      })}
    </View>
  );
};

export const AvatarGroup = ({
  children,
  className,
  ...props
}: React.ComponentProps<typeof View>) => {
  return (
    <View
      className={cn("flex flex-row items-center", className)}
      data-slot="avatar-group"
      {...props}
    >
      {Children.map(children, (child, index) => {
        if (!isValidElement(child)) {
          return child;
        }

        const element = child as ClassNameElement;
        const overlapClassName = index > 0 ? "-ml-2" : undefined;

        return cloneElement(element, {
          className: cn(
            overlapClassName,
            "border-2 border-background",
            element.props.className
          ),
        });
      })}
    </View>
  );
};

export const AvatarGroupCount = ({
  children,
  className,
  ...props
}: React.ComponentProps<typeof View> & {
  children?: React.ReactNode;
}) => {
  return (
    <View
      className={cn(
        "relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted",
        className
      )}
      data-slot="avatar-group-count"
      {...props}
    >
      {Children.map(children, (child) => {
        if (typeof child === "string") {
          return (
            <Text className="font-medium text-muted-foreground text-sm">
              {child}
            </Text>
          );
        }

        return child;
      })}
    </View>
  );
};

// Styles
const avatarVariants = cva(
  "relative flex shrink-0 items-center justify-center rounded-full border border-border",
  {
    defaultVariants: {
      size: "default",
    },
    variants: {
      size: {
        default: "size-8",
        lg: "size-10",
        sm: "size-6",
      },
    },
  }
);

const avatarBadgeVariants = cva(
  "absolute z-10 flex items-center justify-center rounded-full border-2 border-background bg-primary",
  {
    defaultVariants: {
      size: "default",
    },
    variants: {
      size: {
        default: "right-0 bottom-0 size-2.5",
        lg: "right-0 bottom-0 size-3",
        sm: "right-0 bottom-0 size-2",
      },
    },
  }
);

More from tetra-ui

Browse all components in this library →