Installation
pnpm dlx shadcn@latest add https://rdf.dev.360online.com/r/bubble.jsonInstalls to @/components/ui/bubble.
Manual installation
Install the dependencies:
pnpm add @base-ui/react@^1.8.0 class-variance-authority@^0.7.1Copy the source into your project:
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
renderprop
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.
As Link Or Button
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 (
renderwith 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