Chat message layout with group, avatar, content, header and footer builders and start/end alignment.

Preview
Style
Basic
Deploying to prod real quick.
It's 4:55 PM. On a Friday.
It's a one-line change.
Delivered
Avatars
OL
The build failed during installation.
ME
Can you share the exact error?
Read
Header and footer
Olivia
I already checked the logs.
You
Send the report to the team. Ping @shadcn if you need help.
Read yesterday
Grouped messages
I checked the registry addresses.
OL
The component and example JSON now live under the UI registry.
Found them.
The install command works now.
Delivered
src/demo/views/message.ts138 lines
import type { Html, HtmlBuilder } from 'foldkit/html'

import { Avatar } from '../../generated/registry/ui/avatar'
import { Bubble } from '../../generated/registry/ui/bubble'
import { Message, type MessageAlign } from '../../generated/registry/ui/message'

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

const section = (label: string, children: ReadonlyArray<Html>, h: HtmlBuilder<AppMessage>): Html =>
  h.div(
    [h.Class('flex w-full flex-col gap-6')],
    [h.div([h.Class('px-1 text-xs font-medium text-muted-foreground')], [label]), ...children],
  )

const bubble = (text: string, align: MessageAlign, h: HtmlBuilder<AppMessage>): Html =>
  Bubble({ variant: align === 'end' ? 'default' : 'muted' }, [Bubble.content({}, [text], h)], h)

const row = (
  config: Readonly<{
    text: string
    align: MessageAlign
    initials?: string
    header?: string
    footer?: string
  }>,
  h: HtmlBuilder<AppMessage>,
): Html =>
  Message(
    { align: config.align },
    [
      ...(config.initials === undefined
        ? []
        : [Message.avatar({}, [Avatar({}, [Avatar.fallback({}, [config.initials], h)], h)], h)]),
      Message.content(
        {},
        [
          ...(config.header === undefined ? [] : [Message.header({}, [config.header], h)]),
          bubble(config.text, config.align, h),
          ...(config.footer === undefined ? [] : [Message.footer({}, [config.footer], h)]),
        ],
        h,
      ),
    ],
    h,
  )

export const messageView = (_model: Model, h: HtmlBuilder<AppMessage>): Html =>
  h.div(
    [h.Class('flex w-full max-w-sm flex-col gap-10 py-6')],
    [
      section(
        'Basic',
        [
          row({ align: 'end', text: 'Deploying to prod real quick.' }, h),
          row({ align: 'start', text: "It's 4:55 PM. On a Friday." }, h),
          row({ align: 'end', text: "It's a one-line change.", footer: 'Delivered' }, h),
        ],
        h,
      ),
      section(
        'Avatars',
        [
          row({ align: 'start', initials: 'OL', text: 'The build failed during installation.' }, h),
          row(
            {
              align: 'end',
              initials: 'ME',
              text: 'Can you share the exact error?',
              footer: 'Read',
            },
            h,
          ),
        ],
        h,
      ),
      section(
        'Header and footer',
        [
          row({ align: 'start', header: 'Olivia', text: 'I already checked the logs.' }, h),
          row(
            {
              align: 'end',
              header: 'You',
              text: 'Send the report to the team. Ping @shadcn if you need help.',
              footer: 'Read yesterday',
            },
            h,
          ),
        ],
        h,
      ),
      section(
        'Grouped messages',
        [
          Message.group(
            {},
            [
              Message(
                {},
                [
                  Message.avatar({}, [], h),
                  Message.content({}, [bubble('I checked the registry addresses.', 'start', h)], h),
                ],
                h,
              ),
              row(
                {
                  align: 'start',
                  initials: 'OL',
                  text: 'The component and example JSON now live under the UI registry.',
                },
                h,
              ),
            ],
            h,
          ),
          Message.group(
            {},
            [
              row({ align: 'end', text: 'Found them.' }, h),
              row({ align: 'end', text: 'The install command works now.', footer: 'Delivered' }, h),
            ],
            h,
          ),
        ],
        h,
      ),
    ],
  )

export const slice = defineSlice({
  fields: {},
  init: {},
  messages: [],
  handlers: () => ({}),
})

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/message

Source

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

registry/default/ui/message.ts124 lines
import type { Attribute, Html, HtmlBuilder } from 'foldkit/html'

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

type Child = Html | string

export type MessageAlign = 'start' | 'end'

export type MessagePartConfig<M> = Readonly<{
  className?: string
  attributes?: ReadonlyArray<Attribute<M>>
}>

export type MessageConfig<M> = MessagePartConfig<M> & Readonly<{ align?: MessageAlign }>

export const messageGroupClass = 'gap-2 flex min-w-0 flex-col'

export const messageClass =
  'text-sm gap-2 group/message relative flex w-full min-w-0 data-[align=end]:flex-row-reverse'

export const messageAvatarClass =
  'min-w-8 group-has-data-[slot=message-footer]/message:-translate-y-8 flex w-fit shrink-0 items-center justify-center self-end overflow-hidden rounded-full bg-muted'

export const messageContentClass = 'gap-2.5 group-data-[align=end]/message:*:data-slot:self-end flex w-full min-w-0 flex-col wrap-break-word'

export const messageHeaderClass = 'text-xs font-medium text-muted-foreground px-3 group-has-data-[variant=ghost]/message:px-0 flex max-w-full min-w-0 items-center'

export const messageFooterClass =
  'text-xs font-medium text-muted-foreground px-3 group-has-data-[variant=ghost]/message:px-0 flex max-w-full min-w-0 items-center group-data-[align=end]/message:justify-end'

const messageGroup = <M>(
  config: MessagePartConfig<M>,
  children: ReadonlyArray<Child>,
  h: HtmlBuilder<M>,
): Html =>
  h.div(
    [
      h.Class(cn(messageGroupClass, config.className)),
      h.DataAttribute('slot', 'message-group'),
      ...(config.attributes ?? []),
    ],
    children,
  )

const messageContainer = <M>(
  config: MessageConfig<M>,
  children: ReadonlyArray<Child>,
  h: HtmlBuilder<M>,
): Html =>
  h.div(
    [
      h.Class(cn(messageClass, config.className)),
      h.DataAttribute('slot', 'message'),
      h.DataAttribute('align', config.align ?? 'start'),
      ...(config.attributes ?? []),
    ],
    children,
  )

const messageAvatar = <M>(
  config: MessagePartConfig<M>,
  children: ReadonlyArray<Child>,
  h: HtmlBuilder<M>,
): Html =>
  h.div(
    [
      h.Class(cn(messageAvatarClass, config.className)),
      h.DataAttribute('slot', 'message-avatar'),
      ...(config.attributes ?? []),
    ],
    children,
  )

const messageContent = <M>(
  config: MessagePartConfig<M>,
  children: ReadonlyArray<Child>,
  h: HtmlBuilder<M>,
): Html =>
  h.div(
    [
      h.Class(cn(messageContentClass, config.className)),
      h.DataAttribute('slot', 'message-content'),
      ...(config.attributes ?? []),
    ],
    children,
  )

const messageHeader = <M>(
  config: MessagePartConfig<M>,
  children: ReadonlyArray<Child>,
  h: HtmlBuilder<M>,
): Html =>
  h.div(
    [
      h.Class(cn(messageHeaderClass, config.className)),
      h.DataAttribute('slot', 'message-header'),
      ...(config.attributes ?? []),
    ],
    children,
  )

const messageFooter = <M>(
  config: MessagePartConfig<M>,
  children: ReadonlyArray<Child>,
  h: HtmlBuilder<M>,
): Html =>
  h.div(
    [
      h.Class(cn(messageFooterClass, config.className)),
      h.DataAttribute('slot', 'message-footer'),
      ...(config.attributes ?? []),
    ],
    children,
  )

/** Chat message layout, composed with Bubble and Avatar for its content. */
export const Message = Object.assign(messageContainer, {
  group: messageGroup,
  avatar: messageAvatar,
  content: messageContent,
  header: messageHeader,
  footer: messageFooter,
})