Styled avatar with image, fallback, badge and grouping. Note: no automatic image error fallback — drive the swap from your own state.

Preview
Style
Sizes
@shadcn@shadcn@shadcn
CNCNCN
Badge
@jorgezreik@jorgezreik@jorgezreik
JZJZJZ
Badge with Icon
@pranathip@pranathip@pranathip
PPPPPP
Group
@shadcn@maxleiter@evilrabbit
CNLRER
Group with Count
@shadcn@maxleiter@evilrabbit
+3
Group with Icon Count
CNLRER
CNLRER
In Empty
@shadcn@maxleiter@evilrabbit
No Team Members
Invite your team to collaborate on this project.
src/demo/views/avatar.ts666 lines
import { Option } from 'effect'
import { Schema as S } from 'effect'
import { evo } from 'foldkit/struct'
import { defineMessageUnion } from 'foldkit/message'
import type { Html, HtmlBuilder } from 'foldkit/html'
import * as Update from 'foldkit/update'

import * as AvatarModule from '../../generated/registry/ui/avatar'
import { Empty } from '../../generated/registry/ui/empty'
import { button } from '../../generated/registry/ui/button'
import { icon } from '../../generated/registry/lib/icons'
import { Check, Plus } from 'lucide'

import { defineSlice, type UpdateReturn } from '../slice'
import type { Model, Message as AppMessage } from '../assemble'

const Avatar = AvatarModule.Avatar

const Message = defineMessageUnion({
  GotSizeShadcnSmMessage: { message: AvatarModule.Message },
  GotSizeShadcnDefaultMessage: { message: AvatarModule.Message },
  GotSizeShadcnLgMessage: { message: AvatarModule.Message },
  GotBadgeJzSmMessage: { message: AvatarModule.Message },
  GotBadgeJzDefaultMessage: { message: AvatarModule.Message },
  GotBadgeJzLgMessage: { message: AvatarModule.Message },
  GotBadgeIconPpSmMessage: { message: AvatarModule.Message },
  GotBadgeIconPpDefaultMessage: { message: AvatarModule.Message },
  GotBadgeIconPpLgMessage: { message: AvatarModule.Message },
  GotGroupShadcnMessage: { message: AvatarModule.Message },
  GotGroupMaxleiterMessage: { message: AvatarModule.Message },
  GotGroupEvilrabbitMessage: { message: AvatarModule.Message },
  GotGroupCountShadcnMessage: { message: AvatarModule.Message },
  GotGroupCountMaxleiterMessage: { message: AvatarModule.Message },
  GotGroupCountEvilrabbitMessage: { message: AvatarModule.Message },
  GotEmptyShadcnMessage: { message: AvatarModule.Message },
  GotEmptyMaxleiterMessage: { message: AvatarModule.Message },
  GotEmptyEvilrabbitMessage: { message: AvatarModule.Message },
})

const avatarWithImage = (
  id: string,
  src: string,
  alt: string,
  fallback: string,
  size: 'sm' | 'default' | 'lg',
  model: AvatarModule.Model,
  toParent: (m: AvatarModule.Message) => AppMessage,
  h: HtmlBuilder<AppMessage>,
  badge?: Html,
): Html =>
  Avatar<AppMessage>(
    { size },
    [
      Avatar.picture({ id, src, alt, fallback: [fallback] }, model, toParent, h),
      ...(badge ? [badge] : []),
    ],
    h,
  )

const avatarFallbackOnly = (
  fallback: string,
  size: 'sm' | 'default' | 'lg',
  h: HtmlBuilder<AppMessage>,
  badge?: Html,
): Html =>
  Avatar<AppMessage>(
    { size },
    [Avatar.fallback<AppMessage>({}, [fallback], h), ...(badge ? [badge] : [])],
    h,
  )

export const avatarView = (model: Model, h: HtmlBuilder<AppMessage>): Html =>
  h.div(
    [h.Class('flex w-full flex-col gap-8')],
    [
      h.div(
        [h.Class('flex w-full flex-col gap-2')],
        [
          h.div([h.Class('px-1 text-xs font-medium text-muted-foreground')], ['Sizes']),
          h.div(
            [h.Class('flex flex-wrap items-center gap-2')],
            [
              avatarWithImage(
                'avatar-size-shadcn-sm',
                'https://github.com/shadcn.png',
                '@shadcn',
                'CN',
                'sm',
                model.avatarSizeShadcnSm,
                (m) => Message.GotSizeShadcnSmMessage({ message: m }),
                h,
              ),
              avatarWithImage(
                'avatar-size-shadcn-default',
                'https://github.com/shadcn.png',
                '@shadcn',
                'CN',
                'default',
                model.avatarSizeShadcnDefault,
                (m) => Message.GotSizeShadcnDefaultMessage({ message: m }),
                h,
              ),
              avatarWithImage(
                'avatar-size-shadcn-lg',
                'https://github.com/shadcn.png',
                '@shadcn',
                'CN',
                'lg',
                model.avatarSizeShadcnLg,
                (m) => Message.GotSizeShadcnLgMessage({ message: m }),
                h,
              ),
            ],
          ),
          h.div(
            [h.Class('flex flex-wrap items-center gap-2')],
            [
              avatarFallbackOnly('CN', 'sm', h),
              avatarFallbackOnly('CN', 'default', h),
              avatarFallbackOnly('CN', 'lg', h),
            ],
          ),
        ],
      ),
      h.div(
        [h.Class('flex w-full flex-col gap-2')],
        [
          h.div([h.Class('px-1 text-xs font-medium text-muted-foreground')], ['Badge']),
          h.div(
            [h.Class('flex flex-wrap items-center gap-2')],
            [
              avatarWithImage(
                'avatar-badge-jz-sm',
                'https://github.com/jorgezreik.png',
                '@jorgezreik',
                'JZ',
                'sm',
                model.avatarBadgeJzSm,
                (m) => Message.GotBadgeJzSmMessage({ message: m }),
                h,
                Avatar.badge<AppMessage>({}, [], h),
              ),
              avatarWithImage(
                'avatar-badge-jz-default',
                'https://github.com/jorgezreik.png',
                '@jorgezreik',
                'JZ',
                'default',
                model.avatarBadgeJzDefault,
                (m) => Message.GotBadgeJzDefaultMessage({ message: m }),
                h,
                Avatar.badge<AppMessage>({}, [], h),
              ),
              avatarWithImage(
                'avatar-badge-jz-lg',
                'https://github.com/jorgezreik.png',
                '@jorgezreik',
                'JZ',
                'lg',
                model.avatarBadgeJzLg,
                (m) => Message.GotBadgeJzLgMessage({ message: m }),
                h,
                Avatar.badge<AppMessage>({}, [], h),
              ),
            ],
          ),
          h.div(
            [h.Class('flex flex-wrap items-center gap-2')],
            [
              avatarFallbackOnly('JZ', 'sm', h, Avatar.badge<AppMessage>({}, [], h)),
              avatarFallbackOnly('JZ', 'default', h, Avatar.badge<AppMessage>({}, [], h)),
              avatarFallbackOnly('JZ', 'lg', h, Avatar.badge<AppMessage>({}, [], h)),
            ],
          ),
        ],
      ),
      h.div(
        [h.Class('flex w-full flex-col gap-2')],
        [
          h.div([h.Class('px-1 text-xs font-medium text-muted-foreground')], ['Badge with Icon']),
          h.div(
            [h.Class('flex flex-wrap items-center gap-2')],
            [
              avatarWithImage(
                'avatar-badgeicon-pp-sm',
                'https://github.com/pranathip.png',
                '@pranathip',
                'PP',
                'sm',
                model.avatarBadgeIconPpSm,
                (m) => Message.GotBadgeIconPpSmMessage({ message: m }),
                h,
                Avatar.badge<AppMessage>({}, [icon(h, Plus, 'size-3')], h),
              ),
              avatarWithImage(
                'avatar-badgeicon-pp-default',
                'https://github.com/pranathip.png',
                '@pranathip',
                'PP',
                'default',
                model.avatarBadgeIconPpDefault,
                (m) => Message.GotBadgeIconPpDefaultMessage({ message: m }),
                h,
                Avatar.badge<AppMessage>({}, [icon(h, Plus, 'size-3')], h),
              ),
              avatarWithImage(
                'avatar-badgeicon-pp-lg',
                'https://github.com/pranathip.png',
                '@pranathip',
                'PP',
                'lg',
                model.avatarBadgeIconPpLg,
                (m) => Message.GotBadgeIconPpLgMessage({ message: m }),
                h,
                Avatar.badge<AppMessage>({}, [icon(h, Plus, 'size-3')], h),
              ),
            ],
          ),
          h.div(
            [h.Class('flex flex-wrap items-center gap-2')],
            [
              avatarFallbackOnly(
                'PP',
                'sm',
                h,
                Avatar.badge<AppMessage>({}, [icon(h, Check, 'size-3')], h),
              ),
              avatarFallbackOnly(
                'PP',
                'default',
                h,
                Avatar.badge<AppMessage>({}, [icon(h, Check, 'size-3')], h),
              ),
              avatarFallbackOnly(
                'PP',
                'lg',
                h,
                Avatar.badge<AppMessage>({}, [icon(h, Check, 'size-3')], h),
              ),
            ],
          ),
        ],
      ),
      h.div(
        [h.Class('flex w-full flex-col gap-2')],
        [
          h.div([h.Class('px-1 text-xs font-medium text-muted-foreground')], ['Group']),
          Avatar.group<AppMessage>(
            {},
            [
              avatarWithImage(
                'avatar-group-shadcn',
                'https://github.com/shadcn.png',
                '@shadcn',
                'CN',
                'default',
                model.avatarGroupShadcn,
                (m) => Message.GotGroupShadcnMessage({ message: m }),
                h,
              ),
              avatarWithImage(
                'avatar-group-maxleiter',
                'https://github.com/maxleiter.png',
                '@maxleiter',
                'LR',
                'default',
                model.avatarGroupMaxleiter,
                (m) => Message.GotGroupMaxleiterMessage({ message: m }),
                h,
              ),
              avatarWithImage(
                'avatar-group-evilrabbit',
                'https://github.com/evilrabbit.png',
                '@evilrabbit',
                'ER',
                'default',
                model.avatarGroupEvilrabbit,
                (m) => Message.GotGroupEvilrabbitMessage({ message: m }),
                h,
              ),
            ],
            h,
          ),
          Avatar.group<AppMessage>(
            { className: 'data-[slot=avatar-group]:gap-0' },
            [
              Avatar<AppMessage>({ size: 'sm' }, [Avatar.fallback<AppMessage>({}, ['CN'], h)], h),
              Avatar<AppMessage>({ size: 'sm' }, [Avatar.fallback<AppMessage>({}, ['LR'], h)], h),
              Avatar<AppMessage>({ size: 'sm' }, [Avatar.fallback<AppMessage>({}, ['ER'], h)], h),
            ],
            h,
          ),
        ],
      ),
      h.div(
        [h.Class('flex w-full flex-col gap-2')],
        [
          h.div([h.Class('px-1 text-xs font-medium text-muted-foreground')], ['Group with Count']),
          Avatar.group<AppMessage>(
            {},
            [
              avatarWithImage(
                'avatar-groupcount-shadcn',
                'https://github.com/shadcn.png',
                '@shadcn',
                'CN',
                'default',
                model.avatarGroupCountShadcn,
                (m) => Message.GotGroupCountShadcnMessage({ message: m }),
                h,
              ),
              avatarWithImage(
                'avatar-groupcount-maxleiter',
                'https://github.com/maxleiter.png',
                '@maxleiter',
                'LR',
                'default',
                model.avatarGroupCountMaxleiter,
                (m) => Message.GotGroupCountMaxleiterMessage({ message: m }),
                h,
              ),
              avatarWithImage(
                'avatar-groupcount-evilrabbit',
                'https://github.com/evilrabbit.png',
                '@evilrabbit',
                'ER',
                'default',
                model.avatarGroupCountEvilrabbit,
                (m) => Message.GotGroupCountEvilrabbitMessage({ message: m }),
                h,
              ),
              Avatar.groupCount<AppMessage>({}, ['+3'], h),
            ],
            h,
          ),
        ],
      ),
      h.div(
        [h.Class('flex w-full flex-col gap-2')],
        [
          h.div(
            [h.Class('px-1 text-xs font-medium text-muted-foreground')],
            ['Group with Icon Count'],
          ),
          Avatar.group<AppMessage>(
            {},
            [
              Avatar<AppMessage>({ size: 'sm' }, [Avatar.fallback<AppMessage>({}, ['CN'], h)], h),
              Avatar<AppMessage>({ size: 'sm' }, [Avatar.fallback<AppMessage>({}, ['LR'], h)], h),
              Avatar<AppMessage>({ size: 'sm' }, [Avatar.fallback<AppMessage>({}, ['ER'], h)], h),
              Avatar.groupCount<AppMessage>({}, [icon(h, Plus, 'size-4')], h),
            ],
            h,
          ),
          Avatar.group<AppMessage>(
            {},
            [
              Avatar<AppMessage>({}, [Avatar.fallback<AppMessage>({}, ['CN'], h)], h),
              Avatar<AppMessage>({}, [Avatar.fallback<AppMessage>({}, ['LR'], h)], h),
              Avatar<AppMessage>({}, [Avatar.fallback<AppMessage>({}, ['ER'], h)], h),
              Avatar.groupCount<AppMessage>({}, [icon(h, Plus, 'size-4')], h),
            ],
            h,
          ),
        ],
      ),
      h.div(
        [h.Class('flex w-full flex-col gap-2')],
        [
          h.div([h.Class('px-1 text-xs font-medium text-muted-foreground')], ['In Empty']),
          Empty<AppMessage>(
            { className: 'w-full flex-none border' },
            [
              Empty.header<AppMessage>(
                {},
                [
                  Empty.media<AppMessage>(
                    {},
                    [
                      Avatar.group<AppMessage>(
                        {},
                        [
                          avatarWithImage(
                            'avatar-empty-shadcn',
                            'https://github.com/shadcn.png',
                            '@shadcn',
                            'CN',
                            'lg',
                            model.avatarEmptyShadcn,
                            (m) => Message.GotEmptyShadcnMessage({ message: m }),
                            h,
                          ),
                          avatarWithImage(
                            'avatar-empty-maxleiter',
                            'https://github.com/maxleiter.png',
                            '@maxleiter',
                            'LR',
                            'lg',
                            model.avatarEmptyMaxleiter,
                            (m) => Message.GotEmptyMaxleiterMessage({ message: m }),
                            h,
                          ),
                          avatarWithImage(
                            'avatar-empty-evilrabbit',
                            'https://github.com/evilrabbit.png',
                            '@evilrabbit',
                            'ER',
                            'lg',
                            model.avatarEmptyEvilrabbit,
                            (m) => Message.GotEmptyEvilrabbitMessage({ message: m }),
                            h,
                          ),
                          Avatar.groupCount<AppMessage>({}, [icon(h, Plus, 'size-4')], h),
                        ],
                        h,
                      ),
                    ],
                    h,
                  ),
                  Empty.title<AppMessage>({}, ['No Team Members'], h),
                  Empty.description<AppMessage>(
                    {},
                    ['Invite your team to collaborate on this project.'],
                    h,
                  ),
                ],
                h,
              ),
              Empty.content<AppMessage>(
                {},
                [
                  button<AppMessage>(
                    {},
                    h.span(
                      [h.Class('inline-flex items-center gap-1.5')],
                      [icon(h, Plus, 'size-4'), 'Invite Members'],
                    ),
                    h,
                  ),
                ],
                h,
              ),
            ],
            h,
          ),
        ],
      ),
    ],
  )

const fields = {
  avatarSizeShadcnSm: AvatarModule.Model,
  avatarSizeShadcnDefault: AvatarModule.Model,
  avatarSizeShadcnLg: AvatarModule.Model,
  avatarBadgeJzSm: AvatarModule.Model,
  avatarBadgeJzDefault: AvatarModule.Model,
  avatarBadgeJzLg: AvatarModule.Model,
  avatarBadgeIconPpSm: AvatarModule.Model,
  avatarBadgeIconPpDefault: AvatarModule.Model,
  avatarBadgeIconPpLg: AvatarModule.Model,
  avatarGroupShadcn: AvatarModule.Model,
  avatarGroupMaxleiter: AvatarModule.Model,
  avatarGroupEvilrabbit: AvatarModule.Model,
  avatarGroupCountShadcn: AvatarModule.Model,
  avatarGroupCountMaxleiter: AvatarModule.Model,
  avatarGroupCountEvilrabbit: AvatarModule.Model,
  avatarEmptyShadcn: AvatarModule.Model,
  avatarEmptyMaxleiter: AvatarModule.Model,
  avatarEmptyEvilrabbit: AvatarModule.Model,
}

const stateSchema = S.Struct(fields)
type State = typeof stateSchema.Type

const makeFold = (
  read: (m: State) => Option.Option<AvatarModule.Model>,
  write: (m: State, n: AvatarModule.Model) => State,
  toParent: (m: AvatarModule.Message) => AppMessage,
) =>
  Update.foldChild({
    update: AvatarModule.update,
    read: (model: State): Option.Option<AvatarModule.Model> => read(model),
    write,
    toParentMessage: toParent,
  })

const folds = {
  sizeSm: makeFold(
    (m) => Option.some(m.avatarSizeShadcnSm),
    (m, n) => evo(m, { avatarSizeShadcnSm: () => n }),
    (msg) => Message.GotSizeShadcnSmMessage({ message: msg }),
  ),
  sizeDefault: makeFold(
    (m) => Option.some(m.avatarSizeShadcnDefault),
    (m, n) => evo(m, { avatarSizeShadcnDefault: () => n }),
    (msg) => Message.GotSizeShadcnDefaultMessage({ message: msg }),
  ),
  sizeLg: makeFold(
    (m) => Option.some(m.avatarSizeShadcnLg),
    (m, n) => evo(m, { avatarSizeShadcnLg: () => n }),
    (msg) => Message.GotSizeShadcnLgMessage({ message: msg }),
  ),
  badgeSm: makeFold(
    (m) => Option.some(m.avatarBadgeJzSm),
    (m, n) => evo(m, { avatarBadgeJzSm: () => n }),
    (msg) => Message.GotBadgeJzSmMessage({ message: msg }),
  ),
  badgeDefault: makeFold(
    (m) => Option.some(m.avatarBadgeJzDefault),
    (m, n) => evo(m, { avatarBadgeJzDefault: () => n }),
    (msg) => Message.GotBadgeJzDefaultMessage({ message: msg }),
  ),
  badgeLg: makeFold(
    (m) => Option.some(m.avatarBadgeJzLg),
    (m, n) => evo(m, { avatarBadgeJzLg: () => n }),
    (msg) => Message.GotBadgeJzLgMessage({ message: msg }),
  ),
  badgeIconSm: makeFold(
    (m) => Option.some(m.avatarBadgeIconPpSm),
    (m, n) => evo(m, { avatarBadgeIconPpSm: () => n }),
    (msg) => Message.GotBadgeIconPpSmMessage({ message: msg }),
  ),
  badgeIconDefault: makeFold(
    (m) => Option.some(m.avatarBadgeIconPpDefault),
    (m, n) => evo(m, { avatarBadgeIconPpDefault: () => n }),
    (msg) => Message.GotBadgeIconPpDefaultMessage({ message: msg }),
  ),
  badgeIconLg: makeFold(
    (m) => Option.some(m.avatarBadgeIconPpLg),
    (m, n) => evo(m, { avatarBadgeIconPpLg: () => n }),
    (msg) => Message.GotBadgeIconPpLgMessage({ message: msg }),
  ),
  groupShadcn: makeFold(
    (m) => Option.some(m.avatarGroupShadcn),
    (m, n) => evo(m, { avatarGroupShadcn: () => n }),
    (msg) => Message.GotGroupShadcnMessage({ message: msg }),
  ),
  groupMaxleiter: makeFold(
    (m) => Option.some(m.avatarGroupMaxleiter),
    (m, n) => evo(m, { avatarGroupMaxleiter: () => n }),
    (msg) => Message.GotGroupMaxleiterMessage({ message: msg }),
  ),
  groupEvilrabbit: makeFold(
    (m) => Option.some(m.avatarGroupEvilrabbit),
    (m, n) => evo(m, { avatarGroupEvilrabbit: () => n }),
    (msg) => Message.GotGroupEvilrabbitMessage({ message: msg }),
  ),
  groupCountShadcn: makeFold(
    (m) => Option.some(m.avatarGroupCountShadcn),
    (m, n) => evo(m, { avatarGroupCountShadcn: () => n }),
    (msg) => Message.GotGroupCountShadcnMessage({ message: msg }),
  ),
  groupCountMaxleiter: makeFold(
    (m) => Option.some(m.avatarGroupCountMaxleiter),
    (m, n) => evo(m, { avatarGroupCountMaxleiter: () => n }),
    (msg) => Message.GotGroupCountMaxleiterMessage({ message: msg }),
  ),
  groupCountEvilrabbit: makeFold(
    (m) => Option.some(m.avatarGroupCountEvilrabbit),
    (m, n) => evo(m, { avatarGroupCountEvilrabbit: () => n }),
    (msg) => Message.GotGroupCountEvilrabbitMessage({ message: msg }),
  ),
  emptyShadcn: makeFold(
    (m) => Option.some(m.avatarEmptyShadcn),
    (m, n) => evo(m, { avatarEmptyShadcn: () => n }),
    (msg) => Message.GotEmptyShadcnMessage({ message: msg }),
  ),
  emptyMaxleiter: makeFold(
    (m) => Option.some(m.avatarEmptyMaxleiter),
    (m, n) => evo(m, { avatarEmptyMaxleiter: () => n }),
    (msg) => Message.GotEmptyMaxleiterMessage({ message: msg }),
  ),
  emptyEvilrabbit: makeFold(
    (m) => Option.some(m.avatarEmptyEvilrabbit),
    (m, n) => evo(m, { avatarEmptyEvilrabbit: () => n }),
    (msg) => Message.GotEmptyEvilrabbitMessage({ message: msg }),
  ),
}

export const slice = defineSlice({
  fields,
  init: {
    avatarSizeShadcnSm: AvatarModule.init,
    avatarSizeShadcnDefault: AvatarModule.init,
    avatarSizeShadcnLg: AvatarModule.init,
    avatarBadgeJzSm: AvatarModule.init,
    avatarBadgeJzDefault: AvatarModule.init,
    avatarBadgeJzLg: AvatarModule.init,
    avatarBadgeIconPpSm: AvatarModule.init,
    avatarBadgeIconPpDefault: AvatarModule.init,
    avatarBadgeIconPpLg: AvatarModule.init,
    avatarGroupShadcn: AvatarModule.init,
    avatarGroupMaxleiter: AvatarModule.init,
    avatarGroupEvilrabbit: AvatarModule.init,
    avatarGroupCountShadcn: AvatarModule.init,
    avatarGroupCountMaxleiter: AvatarModule.init,
    avatarGroupCountEvilrabbit: AvatarModule.init,
    avatarEmptyShadcn: AvatarModule.init,
    avatarEmptyMaxleiter: AvatarModule.init,
    avatarEmptyEvilrabbit: AvatarModule.init,
  },
  messages: [
    Message.GotSizeShadcnSmMessage,
    Message.GotSizeShadcnDefaultMessage,
    Message.GotSizeShadcnLgMessage,
    Message.GotBadgeJzSmMessage,
    Message.GotBadgeJzDefaultMessage,
    Message.GotBadgeJzLgMessage,
    Message.GotBadgeIconPpSmMessage,
    Message.GotBadgeIconPpDefaultMessage,
    Message.GotBadgeIconPpLgMessage,
    Message.GotGroupShadcnMessage,
    Message.GotGroupMaxleiterMessage,
    Message.GotGroupEvilrabbitMessage,
    Message.GotGroupCountShadcnMessage,
    Message.GotGroupCountMaxleiterMessage,
    Message.GotGroupCountEvilrabbitMessage,
    Message.GotEmptyShadcnMessage,
    Message.GotEmptyMaxleiterMessage,
    Message.GotEmptyEvilrabbitMessage,
  ],
  handlers: (model: State) => ({
    GotSizeShadcnSmMessage: (p: typeof Message.GotSizeShadcnSmMessage.Type): UpdateReturn =>
      folds.sizeSm(model, p.message),
    GotSizeShadcnDefaultMessage: (
      p: typeof Message.GotSizeShadcnDefaultMessage.Type,
    ): UpdateReturn => folds.sizeDefault(model, p.message),
    GotSizeShadcnLgMessage: (p: typeof Message.GotSizeShadcnLgMessage.Type): UpdateReturn =>
      folds.sizeLg(model, p.message),
    GotBadgeJzSmMessage: (p: typeof Message.GotBadgeJzSmMessage.Type): UpdateReturn =>
      folds.badgeSm(model, p.message),
    GotBadgeJzDefaultMessage: (p: typeof Message.GotBadgeJzDefaultMessage.Type): UpdateReturn =>
      folds.badgeDefault(model, p.message),
    GotBadgeJzLgMessage: (p: typeof Message.GotBadgeJzLgMessage.Type): UpdateReturn =>
      folds.badgeLg(model, p.message),
    GotBadgeIconPpSmMessage: (p: typeof Message.GotBadgeIconPpSmMessage.Type): UpdateReturn =>
      folds.badgeIconSm(model, p.message),
    GotBadgeIconPpDefaultMessage: (
      p: typeof Message.GotBadgeIconPpDefaultMessage.Type,
    ): UpdateReturn => folds.badgeIconDefault(model, p.message),
    GotBadgeIconPpLgMessage: (p: typeof Message.GotBadgeIconPpLgMessage.Type): UpdateReturn =>
      folds.badgeIconLg(model, p.message),
    GotGroupShadcnMessage: (p: typeof Message.GotGroupShadcnMessage.Type): UpdateReturn =>
      folds.groupShadcn(model, p.message),
    GotGroupMaxleiterMessage: (p: typeof Message.GotGroupMaxleiterMessage.Type): UpdateReturn =>
      folds.groupMaxleiter(model, p.message),
    GotGroupEvilrabbitMessage: (p: typeof Message.GotGroupEvilrabbitMessage.Type): UpdateReturn =>
      folds.groupEvilrabbit(model, p.message),
    GotGroupCountShadcnMessage: (p: typeof Message.GotGroupCountShadcnMessage.Type): UpdateReturn =>
      folds.groupCountShadcn(model, p.message),
    GotGroupCountMaxleiterMessage: (
      p: typeof Message.GotGroupCountMaxleiterMessage.Type,
    ): UpdateReturn => folds.groupCountMaxleiter(model, p.message),
    GotGroupCountEvilrabbitMessage: (
      p: typeof Message.GotGroupCountEvilrabbitMessage.Type,
    ): UpdateReturn => folds.groupCountEvilrabbit(model, p.message),
    GotEmptyShadcnMessage: (p: typeof Message.GotEmptyShadcnMessage.Type): UpdateReturn =>
      folds.emptyShadcn(model, p.message),
    GotEmptyMaxleiterMessage: (p: typeof Message.GotEmptyMaxleiterMessage.Type): UpdateReturn =>
      folds.emptyMaxleiter(model, p.message),
    GotEmptyEvilrabbitMessage: (p: typeof Message.GotEmptyEvilrabbitMessage.Type): UpdateReturn =>
      folds.emptyEvilrabbit(model, p.message),
  }),
  samples: [Message.GotSizeShadcnSmMessage({ message: AvatarModule.Message.ImageErrored() })],
})

This code is shown verbatim — it’s the exact file used for this preview. Much of the surrounding wiring (slice, fields, messages, handlers) belongs to this site’s demo harness; you only need the view and the state that matters for your own app. Adapt what you need.

Installation

Add this component to your project:

pnpm dlx shadcn@latest add @foldcn/avatar

Source

The component ships as plain source — no build step, no wrapper. Copy it and make it yours.

registry/default/ui/avatar.ts181 lines
import { Schema as S } from 'effect'
import type { Html, HtmlBuilder } from 'foldkit/html'
import { defineMessageUnion } from 'foldkit/message'
import { defineView } from 'foldkit/submodel'
import * as Update from 'foldkit/update'

type Child = Html | string

import { cn } from '@/lib/utils'

/**
 * Known foldkit gap: upstream swaps image→fallback automatically via the
 * Base UI Avatar primitive; foldkit has no such primitive, so `Avatar.image`
 * always renders `<img>`. `Avatar.picture` (below) closes that gap itself —
 * a tiny submodel that swaps to the fallback on load error — so most
 * consumers never need to touch `Avatar.image`/`Avatar.fallback` directly.
 */

export const avatarSizeKeys = ['default', 'sm', 'lg'] as const
export type AvatarSize = (typeof avatarSizeKeys)[number]

export const avatarClass =
  'size-8 rounded-full after:rounded-full data-[size=lg]:size-10 data-[size=sm]:size-6 group/avatar relative flex shrink-0 select-none after:absolute after:inset-0 after:border after:border-border after:mix-blend-darken dark:after:mix-blend-lighten'

export const avatarImageClass = 'rounded-full aspect-square size-full object-cover'

export const avatarFallbackClass =
  'bg-muted text-muted-foreground rounded-full flex size-full items-center justify-center text-sm group-data-[size=sm]/avatar:text-xs'

export const avatarBadgeClass =
  'bg-primary text-primary-foreground ring-background absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-blend-color ring-2 select-none group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2 group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2'

export const avatarGroupClass =
  'group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background'

export const avatarGroupCountClass =
  'bg-muted text-muted-foreground size-8 rounded-full text-sm group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3 relative flex shrink-0 items-center justify-center ring-2 ring-background'

type StyleConfig = Readonly<{ className?: string }>

type AvatarConfig = Readonly<{ size?: AvatarSize; className?: string }>

type AvatarImageConfig = Readonly<{ src: string; alt?: string; className?: string }>

const avatarContainer = <M>(
  config: AvatarConfig,
  children: ReadonlyArray<Child>,
  h: HtmlBuilder<M>,
): Html =>
  h.span(
    [
      h.Class(cn(avatarClass, config.className)),
      h.DataAttribute('slot', 'avatar'),
      h.DataAttribute('size', config.size ?? 'default'),
    ],
    children,
  )

const avatarImage = <M>(config: AvatarImageConfig, h: HtmlBuilder<M>): Html =>
  h.img([
    h.Src(config.src),
    ...(config.alt === undefined ? [] : [h.Alt(config.alt)]),
    h.Class(cn(avatarImageClass, config.className)),
    h.DataAttribute('slot', 'avatar-image'),
  ])

/** `Avatar.picture`'s submodel — tracks whether the image has errored, so
 *  the fallback swap-in is owned by the component instead of the consumer. */
export const Model = S.Struct({ hasError: S.Boolean })
export type Model = typeof Model.Type

export const init: Model = { hasError: false }

export const Message = defineMessageUnion({
  ImageErrored: {},
})
export type Message = typeof Message.Type

export const update = (_model: Model, _message: Message): Update.Return<Model, Message> => ({
  model: { hasError: true },
})

export type PictureConfig = Readonly<{
  id: string
  src: string
  alt?: string
  fallback: ReadonlyArray<Child>
  className?: string
  fallbackClassName?: string
}>

const pictureView = defineView<Model, Message, PictureConfig>((model, config, h) =>
  model.hasError
    ? avatarFallback({ className: config.fallbackClassName }, config.fallback, h)
    : h.img([
        h.Src(config.src),
        ...(config.alt === undefined ? [] : [h.Alt(config.alt)]),
        h.Class(cn(avatarImageClass, config.className)),
        h.DataAttribute('slot', 'avatar-image'),
        h.OnError(Message.ImageErrored()),
      ]),
)

/** Image with an automatic fallback swap on load error — the piece upstream
 *  gets for free from the Base UI Avatar primitive. `model` is this
 *  instance's own `Model` (usually a field on the consumer's own state, one
 *  per avatar); `toParentMessage` maps this module's `Message` into the
 *  consumer's own message type, the same way `h.submodel`'s callers do
 *  elsewhere (see `Dialog`/`HoverCard`). */
const avatarPicture = <M>(
  config: PictureConfig,
  model: Model,
  toParentMessage: (message: Message) => M,
  h: HtmlBuilder<M>,
): Html =>
  h.submodel({
    slotId: config.id,
    model,
    view: pictureView,
    viewInputs: config,
    toParentMessage,
  })

const avatarFallback = <M>(
  config: StyleConfig,
  children: ReadonlyArray<Child>,
  h: HtmlBuilder<M>,
): Html =>
  h.span(
    [
      h.Class(cn(avatarFallbackClass, config.className)),
      h.DataAttribute('slot', 'avatar-fallback'),
    ],
    children,
  )

const avatarBadge = <M>(
  config: StyleConfig,
  children: ReadonlyArray<Child>,
  h: HtmlBuilder<M>,
): Html =>
  h.span(
    [h.Class(cn(avatarBadgeClass, config.className)), h.DataAttribute('slot', 'avatar-badge')],
    children,
  )

const avatarGroup = <M>(
  config: StyleConfig,
  children: ReadonlyArray<Child>,
  h: HtmlBuilder<M>,
): Html =>
  h.div(
    [h.Class(cn(avatarGroupClass, config.className)), h.DataAttribute('slot', 'avatar-group')],
    children,
  )

const avatarGroupCount = <M>(
  config: StyleConfig,
  children: ReadonlyArray<Child>,
  h: HtmlBuilder<M>,
): Html =>
  h.div(
    [
      h.Class(cn(avatarGroupCountClass, config.className)),
      h.DataAttribute('slot', 'avatar-group-count'),
    ],
    children,
  )

/** Styled avatar — image + fallback, with optional status badge and grouping.
 *  Mirrors the shadcn v4 `avatar.tsx` (no Radix primitive; the foldcn registry
 *  renders the same `data-slot` surface). */
export const Avatar = Object.assign(avatarContainer, {
  image: avatarImage,
  fallback: avatarFallback,
  badge: avatarBadge,
  group: avatarGroup,
  groupCount: avatarGroupCount,
  picture: avatarPicture,
})