rapidUI

Bubble

Chat message bubble with incoming and outgoing styles.

Installation

pnpm dlx shadcn@latest add https://rdf.dev.360online.com/r/bubble.json

Installs to @/components/ui/bubble.

Manual installation

Install the dependencies:

pnpm add @base-ui/react@^1.8.0 class-variance-authority@^0.7.1

Copy the source into your project:

components/ui/bubble.tsx
import { mergeProps } from '@base-ui/react/merge-props'
import { useRender } from '@base-ui/react/use-render'
import { cva, type VariantProps } from 'class-variance-authority'
import type * as React from 'react'
import { cn } from '@/lib/utils'

function BubbleGroup({ className, ...props }: React.ComponentProps<'div'>) {
	return <div data-slot="bubble-group" className={cn('flex min-w-0 flex-col gap-2', className)} {...props} />
}

const bubbleVariants = cva(
	'group/bubble relative flex w-fit max-w-[80%] min-w-0 flex-col gap-1 group-data-[align=end]/message:self-end data-[align=end]:self-end data-[variant=ghost]:max-w-full',
	{
		variants: {
			variant: {
				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)] *:data-[slot=bubble-content]:text-foreground dark:*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.3_calc(c*0.4)_h)] [&>[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]:border-border *:data-[slot=bubble-content]:bg-background [&>[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:
					'border-none *: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',
				destructive:
					'*:data-[slot=bubble-content]:bg-destructive/10 *:data-[slot=bubble-content]:text-destructive dark:*:data-[slot=bubble-content]:bg-destructive/20 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/20 dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/30',
			},
		},
		defaultVariants: {
			variant: 'default',
		},
	},
)

function Bubble({
	variant = 'default',
	align = 'start',
	className,
	...props
}: React.ComponentProps<'div'> &
	VariantProps<typeof bubbleVariants> & {
		align?: 'start' | 'end'
	}) {
	return (
		<div
			data-slot="bubble"
			data-variant={variant}
			data-align={align}
			className={cn(bubbleVariants({ variant }), className)}
			{...props}
		/>
	)
}

function BubbleContent({ className, render, ...props }: useRender.ComponentProps<'div'>) {
	return useRender({
		defaultTagName: 'div',
		props: mergeProps<'div'>(
			{
				className: cn(
					'w-fit max-w-full min-w-0 overflow-hidden rounded-xl border border-transparent px-3 py-2 text-sm leading-relaxed wrap-break-word group-data-[align=end]/bubble:self-end [button]:text-left [button,a]:transition-colors [button,a]:outline-none [button,a]:focus-visible:border-ring [button,a]:focus-visible:ring-3 [button,a]:focus-visible:ring-ring',
					className,
				),
			},
			props,
		),
		render,
		state: {
			slot: 'bubble-content',
		},
	})
}

const bubbleReactionsVariants = cva(
	'absolute z-10 flex w-fit shrink-0 items-center justify-center gap-1 rounded-full bg-muted px-1.5 py-0.5 text-sm ring-3 ring-card has-[button]:p-0',
	{
		variants: {
			side: {
				top: 'top-0 -translate-y-3/4',
				bottom: 'bottom-0 translate-y-3/4',
			},
			align: {
				start: 'left-3',
				end: 'right-3',
			},
		},
		defaultVariants: {
			side: 'bottom',
			align: 'end',
		},
	},
)

function BubbleReactions({
	side = 'bottom',
	align = 'end',
	className,
	...props
}: React.ComponentProps<'div'> & {
	align?: 'start' | 'end'
	side?: 'top' | 'bottom'
}) {
	return (
		<div
			data-slot="bubble-reactions"
			data-align={align}
			data-side={side}
			className={cn(bubbleReactionsVariants({ side, align }), className)}
			{...props}
		/>
	)
}

export { BubbleGroup, Bubble, BubbleContent, BubbleReactions }

A chat bubble for displaying message text. Bubbles come in several variants to distinguish senders and states, align to either side of a conversation, support reactions and can be grouped for consecutive messages. Compose them inside Message for full conversation layouts.

Features

  • Variants: default, secondary, muted, tinted, outline, ghost and destructive
  • Start or end alignment
  • BubbleGroup for consecutive bubbles from the same sender
  • BubbleReactions positioned on the top or bottom edge
  • BubbleContent can render as a link or button via the render prop

Usage

import { Bubble, BubbleContent, BubbleGroup } from '@/components/ui/bubble'

export function Conversation() {
  return (
    <BubbleGroup>
      <Bubble variant="muted">
        <BubbleContent>Welcome to the resident service chat. How can I help you?</BubbleContent>
      </Bubble>
      <Bubble align="end">
        <BubbleContent>When is the next bulky waste collection on Kungsgatan?</BubbleContent>
      </Bubble>
    </BubbleGroup>
  )
}

Examples

Default

A resident’s message in the default variant.

Variants

All available visual variants. Use default for the resident’s own messages, muted or secondary for staff, and destructive for messages that failed.

Alignment

Bubbles from both sides of a resident service chat. align="end" moves the resident’s messages to the right.

Group

Consecutive bubbles grouped per sender with BubbleGroup, with an avatar and a sender line above each group.

With Reactions

Reactions attached to the bottom or top edge of a bubble. Give icon-only reactions a screen reader text.

Interactive bubbles rendered as a link to a decision document or as a button that starts a booking.

Destructive

A message that failed to send, with a text status and a retry action next to it.

Accessibility

  • Bubbles are plain containers; wrap a transcript in a region or log with an accessible name
  • Interactive bubbles (render with a link or button) get visible focus styles
  • Give icon-only reactions a text alternative when they carry meaning
  • Do not rely on colour alone to distinguish senders: combine with alignment, headers or avatars