New: Activity Terrain +12 more

Avatar

User photos with an initials fallback, status badges and stacked groups that collapse into a count.

9 examplesDepends on@base-ui/react@tabler/icons-reactclass-variance-authoritycnFrom HextaUI (MIT)

Installation

Run this in your project. It adds the files, installs the npm packages and anything this builds on, and sets up the theme:

npx shadcn@latest add https://kryptonite-ui.vercel.app/r/avatar.json
Install manually
  1. Install the dependencies:

    npm install @base-ui/react @tabler/icons-react class-variance-authority cn
  2. Copy this into components/ui/avatar.tsx:

    avatar.tsx
    "use client"
    
    import * as React from "react"
    import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"
    import { mergeProps } from "@base-ui/react/merge-props"
    import { useRender } from "@base-ui/react/use-render"
    import { IconUser } from "@tabler/icons-react"
    import { cva } from "class-variance-authority"
    import { cn } from "cn"
    
    type AvatarSize = "xs" | "sm" | "default" | "lg" | "xl"
    
    type AvatarShape = "circle" | "square"
    
    type AvatarStatus = "online" | "away" | "busy" | "offline"
    
    type ClassName<State> =
      string | ((state: State) => string | undefined) | undefined
    
    const AvatarGroupContext = React.createContext<{
      size?: AvatarSize
      shape?: AvatarShape
    } | null>(null)
    
    function mergeClassName<State>(base: string, className: ClassName<State>) {
      return typeof className === "function"
        ? (state: State) => cn(base, className(state))
        : cn(base, className)
    }
    
    const avatarVariants = cva(
      "group/avatar [container-type:size] relative isolate inline-flex shrink-0 rounded-(--avatar-radius) bg-muted align-middle text-muted-foreground outline-none select-none after:pointer-events-none after:absolute after:inset-0 after:z-2 after:rounded-[inherit] after:inset-ring-(length:--hairline) after:inset-ring-foreground/10 focus-visible:z-10 focus-visible:ring-3 focus-visible:ring-focus-ring focus-visible:outline-hidden",
      {
        variants: {
          size: {
            xs: "size-5",
            sm: "size-6",
            default: "size-8",
            lg: "size-10",
            xl: "size-14",
          },
          shape: {
            circle: "[--avatar-radius:9999px]",
            square: "",
          },
        },
        compoundVariants: [
          {
            shape: "square",
            size: "xs",
            className: "[--avatar-radius:var(--radius-sm)]",
          },
          {
            shape: "square",
            size: "sm",
            className: "[--avatar-radius:var(--radius-md)]",
          },
          {
            shape: "square",
            size: ["default", "lg"],
            className: "[--avatar-radius:var(--radius-lg)]",
          },
          {
            shape: "square",
            size: "xl",
            className: "[--avatar-radius:var(--radius-xl)]",
          },
        ],
        defaultVariants: {
          size: "default",
          shape: "circle",
        },
      }
    )
    
    type AvatarProps = AvatarPrimitive.Root.Props & {
      size?: AvatarSize
      shape?: AvatarShape
    }
    
    function Avatar({ className, size, shape, ...props }: AvatarProps) {
      const group = React.useContext(AvatarGroupContext)
      const resolvedSize = size ?? group?.size ?? "default"
      const resolvedShape = shape ?? group?.shape ?? "circle"
    
      return (
        <AvatarPrimitive.Root
          data-slot="avatar"
          data-size={resolvedSize}
          data-shape={resolvedShape}
          className={mergeClassName(
            cn(
              avatarVariants({ size: resolvedSize, shape: resolvedShape }),
              group && "ring-2 ring-background"
            ),
            className
          )}
          {...props}
        />
      )
    }
    
    function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
      return (
        <AvatarPrimitive.Image
          data-slot="avatar-image"
          className={mergeClassName(
            "absolute inset-0 z-1 size-full rounded-(--avatar-radius) object-cover transition-opacity duration-200 ease-out-quint data-ending-style:opacity-0 data-error:invisible data-loading:invisible data-starting-style:opacity-0 motion-reduce:transition-none",
            className
          )}
          {...props}
        />
      )
    }
    
    function isEmpty(children: React.ReactNode) {
      return (
        children === undefined ||
        children === null ||
        typeof children === "boolean" ||
        (typeof children === "string" && children.trim() === "")
      )
    }
    
    type AvatarFallbackProps = useRender.ComponentProps<"span"> & {
      delay?: number
    }
    
    function AvatarFallback({
      className,
      children,
      delay = 0,
      render,
      ...props
    }: AvatarFallbackProps) {
      const [delayPassed, setDelayPassed] = React.useState(delay <= 0)
    
      React.useEffect(() => {
        if (delay <= 0) {
          return
        }
    
        const timeout = window.setTimeout(() => setDelayPassed(true), delay)
    
        return () => window.clearTimeout(timeout)
      }, [delay])
    
      const ready = delayPassed || delay <= 0
    
      return useRender({
        defaultTagName: "span",
        render,
        props: mergeProps<"span">(
          {
            className: cn(
              "absolute inset-0 flex items-center justify-center overflow-hidden rounded-(--avatar-radius) bg-muted text-[max(0.5625rem,38cqmin)] leading-none font-medium whitespace-nowrap text-muted-foreground transition-[opacity,visibility] duration-200 ease-out-quint data-[ready=false]:invisible data-[ready=false]:opacity-0 motion-reduce:transition-none [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-[56cqmin] [[data-slot=avatar]:has(>[data-slot=avatar-image]:not([data-loading]):not([data-error]):not([data-ending-style]))>&]:invisible [[data-slot=avatar]:has(>[data-slot=avatar-image]:not([data-loading]):not([data-error]):not([data-ending-style]))>&]:opacity-0",
              className
            ),
            children: isEmpty(children) ? <IconUser aria-hidden /> : children,
          },
          props,
          { "data-slot": "avatar-fallback", "data-ready": String(ready) } as Record<
            string,
            string
          >
        ),
      })
    }
    
    const statusLabels: Record<AvatarStatus, string> = {
      online: "Online",
      away: "Away",
      busy: "Busy",
      offline: "Offline",
    }
    
    const avatarBadgeVariants = cva(
      "absolute end-(--avatar-badge-inset) bottom-(--avatar-badge-inset) z-3 inline-flex size-(--avatar-badge-size) items-center justify-center rounded-full ring-2 ring-background select-none [--avatar-badge-inset:calc(min(var(--avatar-radius),50cqmin)*0.2929-var(--avatar-badge-size)/2)] [--avatar-badge-size:max(0.375rem,min(0.875rem,30cqmin))] [&>svg]:pointer-events-none [&>svg]:size-[calc(var(--avatar-badge-size)*0.75)] group-data-[size=sm]/avatar:[&>svg]:hidden group-data-[size=xs]/avatar:[&>svg]:hidden",
      {
        variants: {
          status: {
            none: "bg-primary text-primary-foreground",
            online: "bg-success",
            away: "bg-warning",
            busy: "bg-destructive",
            offline:
              "border-[max(1.5px,calc(var(--avatar-badge-size)*0.22))] border-muted-foreground bg-background",
          },
        },
        defaultVariants: {
          status: "none",
        },
      }
    )
    
    type AvatarBadgeProps = React.ComponentProps<"span"> & {
      status?: AvatarStatus
    }
    
    function AvatarBadge({
      className,
      status,
      children,
      ...props
    }: AvatarBadgeProps) {
      const [pulse, setPulse] = React.useState({ status, count: 0 })
    
      if (status !== pulse.status) {
        setPulse({ status, count: pulse.count + 1 })
      }
    
      const showPulse = pulse.count > 0 && status && status !== "offline"
    
      return (
        <span
          data-slot="avatar-badge"
          data-status={status}
          role={status ? "img" : undefined}
          aria-label={status ? statusLabels[status] : undefined}
          className={cn(
            avatarBadgeVariants({ status: status ?? "none" }),
            className
          )}
          {...props}
        >
          {showPulse ? (
            <span
              key={pulse.count}
              data-slot="avatar-badge-pulse"
              aria-hidden
              className="pointer-events-none absolute inset-0 rounded-[inherit] bg-inherit motion-safe:animate-avatar-badge-pulse motion-reduce:hidden"
            />
          ) : null}
          {children}
        </span>
      )
    }
    
    const avatarGroupVariants = cva(
      "group/avatar-group isolate flex w-fit max-w-full items-center",
      {
        variants: {
          size: {
            xs: "-space-x-1.5",
            sm: "-space-x-2",
            default: "-space-x-2",
            lg: "-space-x-2.5",
            xl: "-space-x-3.5",
          },
        },
        defaultVariants: {
          size: "default",
        },
      }
    )
    
    type AvatarGroupProps = React.ComponentProps<"div"> & {
      size?: AvatarSize
      shape?: AvatarShape
      max?: number
    }
    
    function resolveMax(max: number | undefined) {
      return max !== undefined && Number.isFinite(max) && max >= 1
        ? Math.max(2, Math.floor(max))
        : Infinity
    }
    
    function AvatarGroup({
      className,
      size = "default",
      shape = "circle",
      max,
      children,
      ...props
    }: AvatarGroupProps) {
      const context = React.useMemo(() => ({ size, shape }), [size, shape])
      const items = React.Children.toArray(children)
      const limit = resolveMax(max)
      const overflow = items.length > limit ? items.length - limit + 1 : 0
    
      return (
        <AvatarGroupContext.Provider value={context}>
          <div
            data-slot="avatar-group"
            data-size={size}
            role="group"
            className={cn(avatarGroupVariants({ size }), className)}
            {...props}
          >
            {overflow ? items.slice(0, limit - 1) : items}
            {overflow ? <AvatarGroupCount count={overflow} /> : null}
          </div>
        </AvatarGroupContext.Provider>
      )
    }
    
    function formatCount(count: number) {
      return count > 99 ? "99+" : `+${count}`
    }
    
    type AvatarGroupCountProps = React.ComponentProps<"span"> & {
      count?: number
      size?: AvatarSize
      shape?: AvatarShape
    }
    
    function AvatarGroupCount({
      className,
      count,
      size,
      shape,
      children,
      ...props
    }: AvatarGroupCountProps) {
      const group = React.useContext(AvatarGroupContext)
      const resolvedSize = size ?? group?.size ?? "default"
      const resolvedShape = shape ?? group?.shape ?? "circle"
      const value =
        count !== undefined && Number.isFinite(count)
          ? Math.max(0, Math.floor(count))
          : undefined
    
      return (
        <span
          data-slot="avatar-group-count"
          data-size={resolvedSize}
          data-shape={resolvedShape}
          className={cn(
            avatarVariants({ size: resolvedSize, shape: resolvedShape }),
            group && "ring-2 ring-background",
            className
          )}
          {...props}
        >
          <span className="flex size-full items-center justify-center overflow-hidden rounded-[inherit] text-[max(0.5625rem,36cqmin)] leading-none font-medium whitespace-nowrap tabular-nums [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-[50cqmin]">
            {value !== undefined && children === undefined ? (
              <>
                <span aria-hidden dir="ltr">
                  {formatCount(value)}
                </span>
                <span className="sr-only">{`${value} more`}</span>
              </>
            ) : (
              children
            )}
          </span>
        </span>
      )
    }
    
    const segmenter =
      typeof Intl !== "undefined" && "Segmenter" in Intl
        ? new Intl.Segmenter(undefined, { granularity: "grapheme" })
        : null
    
    const visible = /[\p{L}\p{N}\p{Extended_Pictographic}]/u
    
    const markless = /^[\p{Script=Latin}\p{Script=Greek}\p{Script=Cyrillic}]/u
    
    function graphemes(word: string) {
      return segmenter
        ? Array.from(segmenter.segment(word), (part) => part.segment)
        : Array.from(word)
    }
    
    function firstGrapheme(word: string) {
      const grapheme = graphemes(word.slice(0, 64)).find((part) =>
        visible.test(part)
      )
    
      if (!grapheme) {
        return ""
      }
    
      return markless.test(grapheme) ? grapheme.replace(/\p{M}+/gu, "") : grapheme
    }
    
    function getInitials(name: string | null | undefined, max = 2) {
      if (typeof name !== "string") {
        return ""
      }
    
      const limit = Number.isFinite(max) ? Math.max(1, Math.floor(max)) : 2
      const source = name.trim()
      const at = source.indexOf("@")
      const words =
        at > 0
          ? source
              .slice(0, at)
              .split("+")[0]
              .split(/[._-]+/u)
          : source.split(/\s+/u)
      const letters: string[] = []
    
      for (const word of words) {
        const letter = firstGrapheme(word)
    
        if (letter) {
          letters.push(letter)
        }
      }
    
      const picked =
        letters.length <= limit
          ? letters
          : limit === 1
            ? letters.slice(0, 1)
            : [...letters.slice(0, limit - 1), letters[letters.length - 1]]
    
      return picked.join("").toLocaleUpperCase()
    }
    
    export {
      Avatar,
      AvatarImage,
      AvatarFallback,
      AvatarBadge,
      AvatarGroup,
      AvatarGroupCount,
      avatarVariants,
      avatarBadgeVariants,
      getInitials,
      type AvatarProps,
      type AvatarFallbackProps,
      type AvatarBadgeProps,
      type AvatarGroupProps,
      type AvatarGroupCountProps,
      type AvatarSize,
      type AvatarShape,
      type AvatarStatus,
    }

Examples

Group

Initials

Layout

Linked group

Loading

Right to left

Sizes and shapes

Status

Feedback

Questions, bugs or ideas for Avatar? Post them here, or reply to someone else. Sign in with GitHub to comment.