V1.0

Base Components

Avatar

Avatar is an image that represents a user or organization.

Installation

Install the following dependencies:

terminal
npm install @radix-ui/react-slot

Create a avatar.tsx file and paste the following code into it.

/components/ui/avatar.tsx
// AlignUI Avatar v0.0.0
 
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
 
import {
  IconEmptyCompany,
  IconEmptyUser,
} from '@/components/ui/avatar-empty-icons';
import { cn, cnExt } from '@/utils/cn';
import { recursiveCloneChildren } from '@/utils/recursive-clone-children';
import { tv, type VariantProps } from '@/utils/tv';
 
export const AVATAR_ROOT_NAME = 'AvatarRoot';
const AVATAR_IMAGE_NAME = 'AvatarImage';
const AVATAR_INDICATOR_NAME = 'AvatarIndicator';
const AVATAR_STATUS_NAME = 'AvatarStatus';
const AVATAR_BRAND_LOGO_NAME = 'AvatarBrandLogo';
const AVATAR_NOTIFICATION_NAME = 'AvatarNotification';
 
export const avatarVariants = tv({
  slots: {
    root: [
      'relative flex shrink-0 items-center justify-center rounded-full',
      'select-none text-center uppercase',
    ],
    image: 'size-full rounded-full object-cover',
    indicator:
      'absolute flex size-8 items-center justify-center drop-shadow-[0_2px_4px_#1b1c1d0a]',
  },
  variants: {
    size: {
      '80': {
        root: 'size-20 text-title-h5',
      },
      '72': {
        root: 'size-[72px] text-title-h5',
      },
      '64': {
        root: 'size-16 text-title-h5',
      },
      '56': {
        root: 'size-14 text-label-lg',
      },
      '48': {
        root: 'size-12 text-label-lg',
      },
      '40': {
        root: 'size-10 text-label-md',
      },
      '32': {
        root: 'size-8 text-label-sm',
      },
      '24': {
        root: 'size-6 text-label-xs',
      },
      '20': {
        root: 'size-5 text-label-xs',
      },
    },
    color: {
      gray: {
        root: 'bg-bg-soft-200 text-static-black',
      },
      yellow: {
        root: 'bg-yellow-200 text-yellow-950',
      },
      blue: {
        root: 'bg-blue-200 text-blue-950',
      },
      sky: {
        root: 'bg-sky-200 text-sky-950',
      },
      purple: {
        root: 'bg-purple-200 text-purple-950',
      },
      red: {
        root: 'bg-red-200 text-red-950',
      },
    },
  },
  compoundVariants: [
    {
      size: ['80', '72'],
      class: {
        indicator: '-right-2',
      },
    },
    {
      size: '64',
      class: {
        indicator: '-right-2 scale-[.875]',
      },
    },
    {
      size: '56',
      class: {
        indicator: '-right-1.5 scale-75',
      },
    },
    {
      size: '48',
      class: {
        indicator: '-right-1.5 scale-[.625]',
      },
    },
    {
      size: '40',
      class: {
        indicator: '-right-1.5 scale-[.5625]',
      },
    },
    {
      size: '32',
      class: {
        indicator: '-right-1.5 scale-50',
      },
    },
    {
      size: '24',
      class: {
        indicator: '-right-1 scale-[.375]',
      },
    },
    {
      size: '20',
      class: {
        indicator: '-right-1 scale-[.3125]',
      },
    },
  ],
  defaultVariants: {
    size: '80',
    color: 'gray',
  },
});
 
type AvatarSharedProps = VariantProps<typeof avatarVariants>;
 
export type AvatarRootProps = VariantProps<typeof avatarVariants> &
  React.HTMLAttributes<HTMLDivElement> & {
    asChild?: boolean;
    placeholderType?: 'user' | 'company';
  };
 
const AvatarRoot = React.forwardRef<HTMLDivElement, AvatarRootProps>(
  (
    {
      asChild,
      children,
      size,
      color,
      className,
      placeholderType = 'user',
      ...rest
    },
    forwardedRef,
  ) => {
    const uniqueId = React.useId();
    const Component = asChild ? Slot : 'div';
    const { root } = avatarVariants({ size, color });
 
    const sharedProps: AvatarSharedProps = {
      size,
      color,
    };
 
    // use placeholder icon if no children provided
    if (!children) {
      return (
        <div className={root({ class: className })} {...rest}>
          <AvatarImage asChild>
            {placeholderType === 'company' ? (
              <IconEmptyCompany />
            ) : (
              <IconEmptyUser />
            )}
          </AvatarImage>
        </div>
      );
    }
 
    const extendedChildren = recursiveCloneChildren(
      children as React.ReactElement[],
      sharedProps,
      [AVATAR_IMAGE_NAME, AVATAR_INDICATOR_NAME],
      uniqueId,
      asChild,
    );
 
    return (
      <Component
        ref={forwardedRef}
        className={root({ class: className })}
        {...rest}
      >
        {extendedChildren}
      </Component>
    );
  },
);
AvatarRoot.displayName = AVATAR_ROOT_NAME;
 
type AvatarImageProps = AvatarSharedProps &
  Omit<React.ImgHTMLAttributes<HTMLImageElement>, 'color'> & {
    asChild?: boolean;
  };
 
const AvatarImage = React.forwardRef<HTMLImageElement, AvatarImageProps>(
  ({ asChild, className, size, color, ...rest }, forwardedRef) => {
    const Component = asChild ? Slot : 'img';
    const { image } = avatarVariants({ size, color });
 
    return (
      <Component
        ref={forwardedRef}
        className={image({ class: className })}
        {...rest}
      />
    );
  },
);
AvatarImage.displayName = AVATAR_IMAGE_NAME;
 
function AvatarIndicator({
  size,
  color,
  className,
  position = 'bottom',
  ...rest
}: AvatarSharedProps &
  React.HTMLAttributes<HTMLDivElement> & {
    position?: 'top' | 'bottom';
  }) {
  const { indicator } = avatarVariants({ size, color });
 
  return (
    <div
      className={cn(indicator({ class: className }), {
        'top-0 origin-top-right': position === 'top',
        'bottom-0 origin-bottom-right': position === 'bottom',
      })}
      {...rest}
    />
  );
}
AvatarIndicator.displayName = AVATAR_INDICATOR_NAME;
 
export const avatarStatusVariants = tv({
  base: 'box-content size-3 rounded-full border-4 border-bg-white-0',
  variants: {
    status: {
      online: 'bg-success-base',
      offline: 'bg-faded-base',
      busy: 'bg-error-base',
      away: 'bg-away-base',
    },
  },
  defaultVariants: {
    status: 'online',
  },
});
 
function AvatarStatus({
  status,
  className,
  ...rest
}: VariantProps<typeof avatarStatusVariants> &
  React.HTMLAttributes<HTMLDivElement>) {
  return (
    <div
      className={avatarStatusVariants({ status, class: className })}
      {...rest}
    />
  );
}
AvatarStatus.displayName = AVATAR_STATUS_NAME;
 
type AvatarBrandLogoProps = React.ImgHTMLAttributes<HTMLImageElement> & {
  asChild?: boolean;
};
 
const AvatarBrandLogo = React.forwardRef<
  HTMLImageElement,
  AvatarBrandLogoProps
>(({ asChild, className, ...rest }, forwardedRef) => {
  const Component = asChild ? Slot : 'img';
 
  return (
    <Component
      ref={forwardedRef}
      className={cnExt(
        'box-content size-6 rounded-full border-2 border-bg-white-0',
        className,
      )}
      {...rest}
    />
  );
});
AvatarBrandLogo.displayName = AVATAR_BRAND_LOGO_NAME;
 
function AvatarNotification({
  className,
  ...rest
}: React.HTMLAttributes<HTMLDivElement>) {
  return (
    <div
      className={cnExt(
        'box-content size-3 rounded-full border-2 border-bg-white-0 bg-error-base',
        className,
      )}
      {...rest}
    />
  );
}
AvatarNotification.displayName = AVATAR_NOTIFICATION_NAME;
 
export {
  AvatarRoot as Root,
  AvatarImage as Image,
  AvatarIndicator as Indicator,
  AvatarStatus as Status,
  AvatarBrandLogo as BrandLogo,
  AvatarNotification as Notification,
};

Create a avatar-empty-icons.tsx file and paste the following code into it.

/components/ui/avatar-empty-icons.tsx
// AlignUI Avatar Empty Icons v0.0.0
 
'use client';
 
import * as React from 'react';
 
export function IconEmptyUser(props: React.SVGProps<SVGSVGElement>) {
  const clipPathId = React.useId();
 
  return (
    <svg
      xmlns='http://www.w3.org/2000/svg'
      fill='none'
      viewBox='0 0 80 80'
      {...props}
    >
      <g fill='#fff' clipPath={`url(#${clipPathId})`}>
        <ellipse cx={40} cy={78} fillOpacity={0.72} rx={32} ry={24} />
        <circle cx={40} cy={32} r={16} opacity={0.9} />
      </g>
      <defs>
        <clipPath id={clipPathId}>
          <rect width={80} height={80} fill='#fff' rx={40} />
        </clipPath>
      </defs>
    </svg>
  );
}
 
export function IconEmptyCompany(props: React.SVGProps<SVGSVGElement>) {
  const clipPathId = React.useId();
  const filterId1 = React.useId();
  const filterId2 = React.useId();
 
  return (
    <svg
      xmlns='http://www.w3.org/2000/svg'
      width={56}
      height={56}
      fill='none'
      viewBox='0 0 56 56'
      {...props}
    >
      <g clipPath={`url(#${clipPathId})`}>
        <rect width={56} height={56} className='fill-bg-soft-200' rx={28} />
        <path className='fill-bg-soft-200' d='M0 0h56v56H0z' />
        <g filter={`url(#${filterId1})`} opacity={0.48}>
          <path
            fill='#fff'
            d='M7 24.9a2.8 2.8 0 012.8-2.8h21a2.8 2.8 0 012.8 2.8v49a2.8 2.8 0 01-2.8 2.8h-21A2.8 2.8 0 017 73.9v-49z'
          />
        </g>
        <path
          className='fill-bg-soft-200'
          d='M12.6 28.7a.7.7 0 01.7-.7h4.2a.7.7 0 01.7.7v4.2a.7.7 0 01-.7.7h-4.2a.7.7 0 01-.7-.7v-4.2zm0 9.8a.7.7 0 01.7-.7h4.2a.7.7 0 01.7.7v4.2a.7.7 0 01-.7.7h-4.2a.7.7 0 01-.7-.7v-4.2zm0 9.8a.7.7 0 01.7-.7h4.2a.7.7 0 01.7.7v4.2a.7.7 0 01-.7.7h-4.2a.7.7 0 01-.7-.7v-4.2z'
        />
        <g filter={`url(#${filterId2})`}>
          <path
            fill='#fff'
            fillOpacity={0.8}
            d='M21 14a2.8 2.8 0 012.8-2.8h21a2.8 2.8 0 012.8 2.8v49a2.8 2.8 0 01-2.8 2.8h-21A2.8 2.8 0 0121 63V14z'
          />
        </g>
        <path
          className='fill-bg-soft-200'
          d='M26.6 17.8a.7.7 0 01.7-.7h4.2a.7.7 0 01.7.7V22a.7.7 0 01-.7.7h-4.2a.7.7 0 01-.7-.7v-4.2zm0 9.8a.7.7 0 01.7-.7h4.2a.7.7 0 01.7.7v4.2a.7.7 0 01-.7.7h-4.2a.7.7 0 01-.7-.7v-4.2zm0 9.8a.7.7 0 01.7-.7h4.2a.7.7 0 01.7.7v4.2a.7.7 0 01-.7.7h-4.2a.7.7 0 01-.7-.7v-4.2zm0 9.8a.7.7 0 01.7-.7h4.2a.7.7 0 01.7.7v4.2a.7.7 0 01-.7.7h-4.2a.7.7 0 01-.7-.7v-4.2zm9.8-29.4a.7.7 0 01.7-.7h4.2a.7.7 0 01.7.7V22a.7.7 0 01-.7.7h-4.2a.7.7 0 01-.7-.7v-4.2zm0 9.8a.7.7 0 01.7-.7h4.2a.7.7 0 01.7.7v4.2a.7.7 0 01-.7.7h-4.2a.7.7 0 01-.7-.7v-4.2zm0 9.8a.7.7 0 01.7-.7h4.2a.7.7 0 01.7.7v4.2a.7.7 0 01-.7.7h-4.2a.7.7 0 01-.7-.7v-4.2zm0 9.8a.7.7 0 01.7-.7h4.2a.7.7 0 01.7.7v4.2a.7.7 0 01-.7.7h-4.2a.7.7 0 01-.7-.7v-4.2z'
        />
      </g>
      <defs>
        <filter
          id={filterId1}
          width={34.6}
          height={62.6}
          x={3}
          y={18.1}
          colorInterpolationFilters='sRGB'
          filterUnits='userSpaceOnUse'
        >
          <feFlood floodOpacity={0} result='BackgroundImageFix' />
          <feGaussianBlur in='BackgroundImageFix' stdDeviation={2} />
          <feComposite
            in2='SourceAlpha'
            operator='in'
            result='effect1_backgroundBlur_36237_4888'
          />
          <feBlend
            in='SourceGraphic'
            in2='effect1_backgroundBlur_36237_4888'
            result='shape'
          />
        </filter>
        <filter
          id={filterId2}
          width={42.6}
          height={70.6}
          x={13}
          y={3.2}
          colorInterpolationFilters='sRGB'
          filterUnits='userSpaceOnUse'
        >
          <feFlood floodOpacity={0} result='BackgroundImageFix' />
          <feGaussianBlur in='BackgroundImageFix' stdDeviation={4} />
          <feComposite
            in2='SourceAlpha'
            operator='in'
            result='effect1_backgroundBlur_36237_4888'
          />
          <feBlend
            in='SourceGraphic'
            in2='effect1_backgroundBlur_36237_4888'
            result='shape'
          />
          <feColorMatrix
            in='SourceAlpha'
            result='hardAlpha'
            values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0'
          />
          <feOffset dy={4} />
          <feGaussianBlur stdDeviation={2} />
          <feComposite in2='hardAlpha' k2={-1} k3={1} operator='arithmetic' />
          <feColorMatrix values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.25 0' />
          <feBlend in2='shape' result='effect2_innerShadow_36237_4888' />
        </filter>
        <clipPath id={clipPathId}>
          <rect width={56} height={56} fill='#fff' rx={28} />
        </clipPath>
      </defs>
    </svg>
  );
}

Update the import paths to match your project setup.

Examples

Color

Size

Text Content

EW
EW
EW

Placeholder

Displays a placeholder icon if no children provided for <Avatar.Root>.

Status

Notification

Indicator With Custom SVG

As "next/image"

Composition

You can simplify component usage by creating a custom API that abstracts the core functionalities.

API Reference

Avatar.Root

The main wrapper for the avatar component. This component is based on the <div> element and supports all of its props and adds:

PropTypeDefault
size
"80"|"72"|"64"|"56"|"48"|"40"|"32"|"24"|"20"
"80"
color
"gray"|"yellow"|"blue"|"sky"|"purple"|"red"
placeholderType
"user"|"company"
"user"
asChild
boolean

Avatar.Image

This component is based on the <img> element and supports all of its props and adds:

PropTypeDefault
asChild
boolean

This component is based on the <img> element and supports all of its props and adds:

PropTypeDefault
asChild
boolean

Avatar.Indicator

A wrapper for absolutely positioned indicators, often used to show status or notifications. This component is based on the <div> element and supports all of its props and adds:

PropTypeDefault
position
"top"|"bottom"

Avatar.Status

Displays a status indicator, like online or offline status. This component is based on the <div> element and supports all of its props and adds:

PropTypeDefault
status
"online"|"offline"|"busy"|"away"

Avatar.Notification

Displays a notification badge on the avatar. This component is based on the <div> element and supports all of its props.

© 2024 AlignUI Design System. All rights reserved.