shadimage
kanpeki
uiavatarsource: ok · hit

Avatar

npx shadcn add "https://kanpeki.vercel.app/r/avatar.json"
free

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

Dependencies

Bundle weight · light

Registry dependencies

@kanpeki/cva@kanpeki/custom-plugin

Files4 · source cached 10/8/2026, 5:55:32 PM

src/registry/ui/avatar/index.ts
export * from "./avatar";
export * as Avatar from "./namespace";
export * from "./styles";
src/registry/ui/avatar/avatar.tsx
"use client";

import { createContext, use, useLayoutEffect, useState } from "react";
import { AvatarStyles } from "./styles";

type Status = "idle" | "loading" | "success" | "error";
const AvatarContext = createContext<{
  status: Status;
  onStatusChange: (status: Status) => void;
} | null>(null);

function useAvatarContext() {
  const context = use(AvatarContext);
  if (!context) {
    throw new Error("Avatar components must be rendered within the AvatarRoot");
  }
  return context;
}

export interface AvatarRootProps extends React.ComponentProps<"span"> {}

export function AvatarRoot({ className, ...props }: AvatarRootProps) {
  const [status, setStatus] = useState<Status>("idle");

  return (
    <AvatarContext value={{ onStatusChange: setStatus, status }}>
      <span
        className={AvatarStyles.Root({ className })}
        data-slot="avatar-root"
        data-status={status}
        {...props}
      />
    </AvatarContext>
  );
}

function useImageLoadingStatus(src?: string) {
  const [status, setStatus] = useState<Status>("idle");

  useLayoutEffect(() => {
    if (!src) {
      setStatus("error");
      return;
    }
    let isMounted = true;
    const image = new window.Image();
    const updateStatus = (status: Status) => () => {
      if (!isMounted) {
        return;
      }
      setStatus(status);
    };
    setStatus("loading");
    image.onload = updateStatus("success");
    image.onerror = updateStatus("error");
    image.src = src;
    return () => {
      isMounted = false;
    };
  }, [src]);

  return status;
}

export interface AvatarImageProps extends React.ComponentProps<"img"> {
  src?: string;
}

export function AvatarImage({
  className,
  src,
  width,
  height,
  ...props
}: AvatarImageProps) {
  const context = useAvatarContext();
  const status = useImageLoadingStatus(src);

  useLayoutEffect(() => {
    if (status !== "idle") {
      context.onStatusChange(status);
    }
  }, [status, context.onStatusChange]);

  if (status !== "success") {
    return null;
  }

  return (
    <img
      {...props}
      alt={props.alt}
      className={AvatarStyles.Image({ className })}
      data-slot="avatar-image"
      height={height}
      src={src}
      width={width}
    />
  );
}

export interface AvatarFallbackProps extends React.ComponentProps<"span"> {}

export function AvatarFallback({ className, ...props }: AvatarFallbackProps) {
  const { status } = useAvatarContext();

  return (
    <span
      {...props}
      aria-hidden={status !== "error"}
      className={AvatarStyles.Fallback({ className })}
      data-slot="avatar-fallback"
    />
  );
}

export interface AvatarPlaceholderProps extends React.ComponentProps<"span"> {}

export function AvatarPlaceholder({
  className,
  ...props
}: AvatarPlaceholderProps) {
  const { status } = useAvatarContext();

  if (status !== "idle" && status !== "loading") {
    return null;
  }

  return (
    <span
      {...props}
      className={AvatarStyles.Placeholder({ className })}
      data-slot="avatar-placeholder"
    />
  );
}
src/registry/ui/avatar/styles.ts
import { cva } from "~/registry/lib/cva";

export const AvatarStyles = {
  Fallback: cva({
    base: [
      "col-start-1 row-start-1 size-full select-none place-content-center rounded-full bg-muted",
      "grid group-data-[status=success]:hidden",
    ],
  }),
  Image: cva({
    base: ["aspect-square size-full"],
  }),
  Placeholder: cva({
    base: [
      "col-start-1 row-start-1 grid size-full animate-pulse place-content-center bg-muted",
    ],
  }),
  Root: cva({
    base: ["group relative grid size-8 shrink-0 overflow-hidden rounded-full"],
  }),
};
src/registry/ui/avatar/namespace.ts
export {
  AvatarFallback as Fallback,
  AvatarImage as Image,
  AvatarPlaceholder as Placeholder,
  AvatarRoot as Root,
} from "./avatar";

More from kanpeki

Browse all components in this library →