Styled chat bubble with group, content and reactions sub-builders, variants and start/end alignment.

Preview
Style
Variants
Default bubbles use the primary color for the active user side of a chat.
Secondary bubbles are the standard neutral surface for assistant content.
Muted bubbles lower the emphasis for quiet system notes.
Tinted bubbles use a softer primary tint when primary fill is too strong.
Outline bubbles can be used to frame message content and give it a border.
Destructive bubbles flag errors or failed actions in a conversation.
Ghost bubbles work for assistant text that should not be framed. They are full width and can take the whole row of the conversation.
Sizes
This is a one line bubble.
This bubble has multiple lines. It should wrap to the next line and you should see a different radius on the corners.

This bubble has multiple lines.

It should wrap to the next line and you should see a different radius on the corners.

Here is some more text to see how it wraps.

Alignment
This bubble is aligned to the start.
This bubble is aligned to the end.
This multiline bubble is aligned to the start. The corners should adjust when the text wraps to show the grouped side of the conversation.
This multiline bubble is aligned to the end. It should sit on the opposite side with the matching corner radius for wrapped text.
Grouped
I finished the audit pass.
The registry output looks clean, but I found one stale route.
Want me to remove it now?
Yes, clean that up.
Then rerun the registry build.
Buttons & Links
Chat Suggestions
How can I help you today?
Reaction Placement
side=bottom align=end
This is a one line message.
👍
A longer message that wraps across lines so the reaction offset is easier to inspect.
👍😮
side=top align=start
A longer message that wraps across lines so the reaction offset is easier to inspect.
👍😮🔥👀
side=top align=end
This is a one line message.
👍
Reaction Buttons
This is a one line message.
This is a one line message.
We are going to the movies first then dinner. Are you in?
src/demo/views/bubble.ts507 lines
import { Schema as S } from 'effect'
import { defineMessageUnion } from 'foldkit/message'
import type { Html, HtmlBuilder } from 'foldkit/html'
import { evo } from 'foldkit/struct'

import { Bubble } from '../../generated/registry/ui/bubble'
import { button } from '../../generated/registry/ui/button'
import { Marker } from '../../generated/registry/ui/marker'
import { icon } from '../../generated/registry/lib/icons'
import { ThumbsUp, ThumbsDown, PartyPopper } from 'lucide'

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

const Message = defineMessageUnion({
  PickedReply: { text: S.String },
  ToggledReaction: {},
})

const quickReplies = [
  'I need help with my account.',
  'I forgot my password.',
  'I have another question. I would like to talk to a human. Can you help me?',
]

export const bubbleView = (model: Model, h: HtmlBuilder<AppMessage>): Html => {
  const separatorMarker = (label: string): Html =>
    Marker<AppMessage>({ variant: 'separator' }, [Marker.content<AppMessage>({}, [label], h)], h)

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

  return h.div(
    [h.Class('flex w-full max-w-md flex-col gap-8 py-12')],
    [
      section('Variants', [
        Bubble<AppMessage>(
          {},
          [
            Bubble.content<AppMessage>(
              {},
              ['Default bubbles use the primary color for the active user side of a chat.'],
              h,
            ),
          ],
          h,
        ),
        Bubble<AppMessage>(
          { variant: 'secondary' },
          [
            Bubble.content<AppMessage>(
              {},
              ['Secondary bubbles are the standard neutral surface for assistant content.'],
              h,
            ),
          ],
          h,
        ),
        Bubble<AppMessage>(
          { variant: 'muted' },
          [
            Bubble.content<AppMessage>(
              {},
              ['Muted bubbles lower the emphasis for quiet system notes.'],
              h,
            ),
          ],
          h,
        ),
        Bubble<AppMessage>(
          { variant: 'tinted', align: 'end' },
          [
            Bubble.content<AppMessage>(
              {},
              ['Tinted bubbles use a softer primary tint when primary fill is too strong.'],
              h,
            ),
          ],
          h,
        ),
        Bubble<AppMessage>(
          { variant: 'outline' },
          [
            Bubble.content<AppMessage>(
              {},
              ['Outline bubbles can be used to frame message content and give it a border.'],
              h,
            ),
          ],
          h,
        ),
        Bubble<AppMessage>(
          { variant: 'destructive' },
          [
            Bubble.content<AppMessage>(
              {},
              ['Destructive bubbles flag errors or failed actions in a conversation.'],
              h,
            ),
          ],
          h,
        ),
        Bubble<AppMessage>(
          { variant: 'ghost' },
          [
            Bubble.content<AppMessage>(
              {},
              [
                h.span(
                  [h.Class('whitespace-pre-wrap')],
                  [
                    'Ghost bubbles work for assistant text that should not be framed.\n\nThey are full width and can take the whole row of the conversation.',
                  ],
                ),
              ],
              h,
            ),
          ],
          h,
        ),
      ]),
      section('Sizes', [
        Bubble<AppMessage>(
          {},
          [Bubble.content<AppMessage>({}, ['This is a one line bubble.'], h)],
          h,
        ),
        Bubble<AppMessage>(
          {},
          [
            Bubble.content<AppMessage>(
              {},
              [
                'This bubble has multiple lines. It should wrap to the next line and you should see a different radius on the corners.',
              ],
              h,
            ),
          ],
          h,
        ),
        Bubble<AppMessage>(
          {},
          [
            Bubble.content<AppMessage>(
              {},
              [
                h.p([], ['This bubble has multiple lines.']),
                h.p(
                  [],
                  [
                    'It should wrap to the next line and you should see a different radius on the corners.',
                  ],
                ),
                h.p([], ['Here is some more text to see how it wraps.']),
              ],
              h,
            ),
          ],
          h,
        ),
      ]),
      section('Alignment', [
        Bubble<AppMessage>(
          { variant: 'muted' },
          [Bubble.content<AppMessage>({}, ['This bubble is aligned to the start.'], h)],
          h,
        ),
        Bubble<AppMessage>(
          { align: 'end' },
          [Bubble.content<AppMessage>({}, ['This bubble is aligned to the end.'], h)],
          h,
        ),
        Bubble<AppMessage>(
          { variant: 'muted' },
          [
            Bubble.content<AppMessage>(
              {},
              [
                'This multiline bubble is aligned to the start. The corners should adjust when the text wraps to show the grouped side of the conversation.',
              ],
              h,
            ),
          ],
          h,
        ),
        Bubble<AppMessage>(
          { align: 'end' },
          [
            Bubble.content<AppMessage>(
              {},
              [
                'This multiline bubble is aligned to the end. It should sit on the opposite side with the matching corner radius for wrapped text.',
              ],
              h,
            ),
          ],
          h,
        ),
      ]),
      section('Grouped', [
        Bubble.group<AppMessage>(
          {},
          [
            Bubble<AppMessage>(
              { variant: 'secondary' },
              [Bubble.content<AppMessage>({}, ['I finished the audit pass.'], h)],
              h,
            ),
            Bubble<AppMessage>(
              { variant: 'secondary' },
              [
                Bubble.content<AppMessage>(
                  {},
                  ['The registry output looks clean, but I found one stale route.'],
                  h,
                ),
              ],
              h,
            ),
            Bubble<AppMessage>(
              { variant: 'secondary' },
              [Bubble.content<AppMessage>({}, ['Want me to remove it now?'], h)],
              h,
            ),
          ],
          h,
        ),
        Bubble.group<AppMessage>(
          {},
          [
            Bubble<AppMessage>(
              { variant: 'tinted', align: 'end' },
              [Bubble.content<AppMessage>({}, ['Yes, clean that up.'], h)],
              h,
            ),
            Bubble<AppMessage>(
              { variant: 'tinted', align: 'end' },
              [Bubble.content<AppMessage>({}, ['Then rerun the registry build.'], h)],
              h,
            ),
          ],
          h,
        ),
      ]),
      section('Buttons & Links', [
        Bubble<AppMessage>(
          {},
          [
            Bubble.content<AppMessage>(
              { as: 'a', attributes: [h.Href('#')] },
              ['This bubble is a link.'],
              h,
            ),
          ],
          h,
        ),
        Bubble<AppMessage>(
          { variant: 'secondary' },
          [
            Bubble.content<AppMessage>(
              { as: 'button' },
              ['This one is a button you can click.'],
              h,
            ),
          ],
          h,
        ),
        Bubble<AppMessage>(
          { variant: 'muted' },
          [
            Bubble.content<AppMessage>(
              { as: 'button' },
              ['You can also do tinted buttons. Even ones that are multilines.'],
              h,
            ),
          ],
          h,
        ),
        separatorMarker('Chat Suggestions'),
        Bubble<AppMessage>(
          {},
          [Bubble.content<AppMessage>({}, ['How can I help you today?'], h)],
          h,
        ),
        Bubble.group<AppMessage>(
          {},
          [
            ...quickReplies.map((reply) =>
              Bubble<AppMessage>(
                { variant: 'outline', align: 'end' },
                [
                  Bubble.content<AppMessage>(
                    {
                      as: 'button',
                      className: 'border-dashed border-primary',
                      onClick: Message.PickedReply({ text: reply }),
                    },
                    [reply],
                    h,
                  ),
                ],
                h,
              ),
            ),
          ],
          h,
        ),
        ...model.bubbleReplies.map((reply) =>
          Bubble<AppMessage>(
            { variant: 'secondary', align: 'end' },
            [Bubble.content<AppMessage>({}, [reply], h)],
            h,
          ),
        ),
      ]),
      section('Reaction Placement', [
        separatorMarker('side=bottom align=end'),
        Bubble<AppMessage>(
          {},
          [
            Bubble.content<AppMessage>({}, ['This is a one line message.'], h),
            Bubble.reactions<AppMessage>(
              {
                side: 'bottom',
                align: 'end',
                attributes: [h.Attribute('role', 'img'), h.AriaLabel('Reaction: thumbs up')],
              },
              [h.span([], ['👍'])],
              h,
            ),
          ],
          h,
        ),
        Bubble<AppMessage>(
          { variant: 'secondary', align: 'end' },
          [
            Bubble.content<AppMessage>(
              {},
              [
                'A longer message that wraps across lines so the reaction offset is easier to inspect.',
              ],
              h,
            ),
            Bubble.reactions<AppMessage>(
              {
                side: 'bottom',
                align: 'start',
                attributes: [
                  h.Attribute('role', 'img'),
                  h.AriaLabel('Reactions: thumbs up, surprised'),
                ],
              },
              [h.span([], ['👍']), h.span([], ['😮'])],
              h,
            ),
          ],
          h,
        ),
        separatorMarker('side=top align=start'),
        Bubble<AppMessage>(
          { variant: 'secondary' },
          [
            Bubble.content<AppMessage>(
              {},
              [
                'A longer message that wraps across lines so the reaction offset is easier to inspect.',
              ],
              h,
            ),
            Bubble.reactions<AppMessage>(
              {
                side: 'top',
                align: 'start',
                attributes: [
                  h.Attribute('role', 'img'),
                  h.AriaLabel('Reactions: thumbs up, surprised, fire, eyes'),
                ],
              },
              [h.span([], ['👍']), h.span([], ['😮']), h.span([], ['🔥']), h.span([], ['👀'])],
              h,
            ),
          ],
          h,
        ),
        separatorMarker('side=top align=end'),
        Bubble<AppMessage>(
          { variant: 'muted' },
          [
            Bubble.content<AppMessage>({}, ['This is a one line message.'], h),
            Bubble.reactions<AppMessage>(
              {
                side: 'top',
                align: 'end',
                attributes: [h.Attribute('role', 'img'), h.AriaLabel('Reaction: thumbs up')],
              },
              [h.span([], ['👍'])],
              h,
            ),
          ],
          h,
        ),
      ]),
      section('Reaction Buttons', [
        Bubble<AppMessage>(
          {},
          [
            Bubble.content<AppMessage>({}, ['This is a one line message.'], h),
            Bubble.reactions<AppMessage>(
              {},
              [button<AppMessage>({ variant: 'outline', size: 'xs' }, 'Button', h)],
              h,
            ),
          ],
          h,
        ),
        Bubble<AppMessage>(
          { align: 'end' },
          [
            Bubble.content<AppMessage>({}, ['This is a one line message.'], h),
            Bubble.reactions<AppMessage>(
              { align: 'start' },
              [
                button<AppMessage>(
                  {
                    variant: 'ghost',
                    size: 'icon-xs',
                    attributes: [h.AriaLabel('Confetti')],
                  },
                  icon(h, PartyPopper, 'size-3'),
                  h,
                ),
              ],
              h,
            ),
          ],
          h,
        ),
        Bubble<AppMessage>(
          { variant: 'tinted' },
          [
            Bubble.content<AppMessage>(
              {},
              ['We are going to the movies first then dinner. Are you in?'],
              h,
            ),
            Bubble.reactions<AppMessage>(
              { className: 'gap-1 bg-background' },
              [
                button<AppMessage>(
                  {
                    variant: model.bubbleReacted ? 'secondary' : 'outline',
                    size: 'icon-xs',
                    attributes: [
                      h.AriaLabel('Thumbs up'),
                      h.Attribute('aria-pressed', String(model.bubbleReacted)),
                    ],
                    onClick: Message.ToggledReaction(),
                  },
                  icon(h, ThumbsUp, 'size-3'),
                  h,
                ),
                button<AppMessage>(
                  {
                    variant: 'secondary',
                    size: 'icon-xs',
                    attributes: [h.AriaLabel('Thumbs down')],
                  },
                  icon(h, ThumbsDown, 'size-3'),
                  h,
                ),
              ],
              h,
            ),
          ],
          h,
        ),
      ]),
    ],
  )
}

const fields = {
  bubbleReplies: S.Array(S.String),
  bubbleReacted: S.Boolean,
}

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

export const slice = defineSlice({
  fields,
  init: { bubbleReplies: [], bubbleReacted: false },
  messages: [Message.PickedReply, Message.ToggledReaction],
  handlers: (model: State) => ({
    PickedReply: (payload: typeof Message.PickedReply.Type): UpdateReturn => ({
      model: evo(model, { bubbleReplies: () => [...model.bubbleReplies, payload.text] }),
    }),
    ToggledReaction: (): UpdateReturn => ({
      model: evo(model, { bubbleReacted: () => !model.bubbleReacted }),
    }),
  }),
  samples: [Message.ToggledReaction()],
})

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

Source

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

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

type Child = Html | string

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

/** Bubble variant keys. Sync with `bubbleVariants` is compiler-enforced:
 *  `bubbleVariants` is `Record<BubbleVariant, string>` (missing key = error)
 *  and annotated object literals reject unknown keys. */
export const bubbleVariantKeys = [
  'default',
  'secondary',
  'muted',
  'tinted',
  'outline',
  'ghost',
  'destructive',
] as const

export type BubbleVariant = (typeof bubbleVariantKeys)[number]

export const bubbleVariants: Record<BubbleVariant, string> = {
  default: '*:data-[slot=bubble-content]:bg-primary *:data-[slot=bubble-content]:text-primary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-primary/80',
  secondary: '*:data-[slot=bubble-content]:bg-secondary *:data-[slot=bubble-content]:text-secondary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)]',
  muted: '*:data-[slot=bubble-content]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--muted),var(--foreground)_5%)]',
  tinted: '*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.93_calc(c*0.4)_h)] dark:*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.3_calc(c*0.4)_h)] *:data-[slot=bubble-content]:text-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.88_calc(c*0.5)_h)] dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.35_calc(c*0.5)_h)]',
  outline: '*:data-[slot=bubble-content]:bg-background *:data-[slot=bubble-content]:border-border [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-input/30',
  ghost: '*:data-[slot=bubble-content]:rounded-none *:data-[slot=bubble-content]:bg-transparent *:data-[slot=bubble-content]:p-0 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted/50 border-none',
  destructive: '*:data-[slot=bubble-content]:bg-destructive/10 dark:*:data-[slot=bubble-content]:bg-destructive/20 *:data-[slot=bubble-content]:text-destructive [&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/20 dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/30',
}

export type BubbleAlign = 'start' | 'end'

export type BubbleReactionsSide = 'top' | 'bottom'

export const bubbleClass = 'gap-1 data-[align=end]:self-end max-w-[80%] data-[variant=ghost]:max-w-full group-data-[align=end]/message:self-end group/bubble relative flex w-fit min-w-0 flex-col'

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

export const bubbleContentClass =
  'rounded-xl border border-transparent px-3 py-2 text-sm leading-relaxed [button,a]:outline-none [button,a]:focus-visible:border-ring [button,a]:focus-visible:ring-3 [button,a]:focus-visible:ring-ring/50 group-data-[align=end]/bubble:self-end w-fit max-w-full min-w-0 overflow-hidden wrap-break-word [button]:text-left [button,a]:transition-colors'

export const bubbleReactionsClass =
  'rounded-full ring-3 ring-card bg-muted shrink-0 gap-1 px-1.5 py-0.5 has-[button]:p-0 text-sm absolute z-10 flex w-fit items-center justify-center'

export const bubbleReactionsSideClasses: Record<BubbleReactionsSide, string> = {
  top: 'top-0 -translate-y-3/4',
  bottom: 'bottom-0 translate-y-3/4',
}

export const bubbleReactionsAlignClasses: Record<BubbleAlign, string> = {
  start: 'left-3',
  end: 'right-3',
}

type BubbleConfig = Readonly<{
  className?: string
  variant?: BubbleVariant
  align?: BubbleAlign
}>

const bubbleGroup = <M>(
  config: Readonly<{ className?: string }>,
  children: ReadonlyArray<Child>,
  h: HtmlBuilder<M>,
): Html =>
  h.div(
    [h.Class(cn(bubbleGroupClass, config.className)), h.DataAttribute('slot', 'bubble-group')],
    children,
  )

const bubbleContainer = <M>(
  config: BubbleConfig,
  children: ReadonlyArray<Child>,
  h: HtmlBuilder<M>,
): Html =>
  h.div(
    [
      h.Class(cn(bubbleClass, bubbleVariants[config.variant ?? 'default'], config.className)),
      h.DataAttribute('slot', 'bubble'),
      h.DataAttribute('variant', config.variant ?? 'default'),
      h.DataAttribute('align', config.align ?? 'start'),
    ],
    children,
  )

export type BubbleContentConfig<M> = Readonly<{
  className?: string
  /** Element to render as — foldcn's stand-in for upstream's `useRender`
   *  `render` prop. Button/anchor bubbles keep the same content classes,
   *  including the variant hover state keyed on the content element itself. */
  as?: 'div' | 'button' | 'a'
  /** Click message when rendered `as: 'button'`. */
  onClick?: M
  /** Extra attributes merged onto the content element (href, labels, …). */
  attributes?: ReadonlyArray<Attribute<M>>
}>

const bubbleContent = <M>(
  config: BubbleContentConfig<M>,
  children: ReadonlyArray<Child>,
  h: HtmlBuilder<M>,
): Html => {
  const attributes: ReadonlyArray<Attribute<M>> = [
    h.Class(cn(bubbleContentClass, config.className)),
    h.DataAttribute('slot', 'bubble-content'),
    ...(config.attributes ?? []),
  ]
  if (config.as === 'button')
    return h.button(
      [...attributes, ...(config.onClick ? [h.OnClick(config.onClick)] : [])],
      children,
    )
  if (config.as === 'a') return h.a(attributes, children)
  return h.div(attributes, children)
}

export type BubbleReactionsConfig<M> = Readonly<{
  side?: BubbleReactionsSide
  align?: BubbleAlign
  className?: string
  /** Extra attributes merged onto the reactions element (role, labels, …). */
  attributes?: ReadonlyArray<Attribute<M>>
}>

const bubbleReactions = <M>(
  config: BubbleReactionsConfig<M>,
  children: ReadonlyArray<Child>,
  h: HtmlBuilder<M>,
): Html => {
  const side = config.side ?? 'bottom'
  const align = config.align ?? 'end'
  return h.div(
    [
      h.Class(
        cn(
          bubbleReactionsClass,
          bubbleReactionsSideClasses[side],
          bubbleReactionsAlignClasses[align],
          config.className,
        ),
      ),
      h.DataAttribute('slot', 'bubble-reactions'),
      h.DataAttribute('align', align),
      h.DataAttribute('side', side),
    ],
    children,
  )
}

/** Styled chat bubble — `Bubble.group`, `Bubble.content` and
 *  `Bubble.reactions` sub-builders. Mirrors the shadcn v4 `bubble.tsx`. */
export const Bubble = Object.assign(bubbleContainer, {
  group: bubbleGroup,
  content: bubbleContent,
  reactions: bubbleReactions,
})