@cf/bubble
Bubble
The coloured surface that holds one message's text in a chat, in seven variants, with an optional reactions badge.
The second bubble renders as a link through render, which is what the hover styles in each variant are for.
A Bubble is a wrapper that caps width at 80% and aligns itself; BubbleContent is the surface, rendered with Base UI's useRender so it can be a div, button or a. tinted derives its colour from --primary in OKLCH, and BubbleReactions pins a small badge to a corner.
In the chat, the person's turns are outline bubbles and replies are ghost, which reads as plain text with no box. Replies keep a tenth of the panel clear and stop at a comfortable line length through data-[variant=ghost]:max-w-[min(90%,34rem)].
import { Bubble, BubbleContent } from "@/components/ui/bubble";
<Bubble align="end" variant="outline">
<BubbleContent className="whitespace-pre-wrap">{text}</BubbleContent>
</Bubble>Props
| Prop | Type | Default |
|---|---|---|
| Bubble variant | default | secondary | muted | outline | tinted | destructive | ghost | default |
| Bubble align | start | end | start |
| BubbleReactions side | top | bottom | bottom |
| BubbleReactions align | start | end | end |
| BubbleContent render | ReactElement (useRender) | โ |
Rules
The same rules ship in DESIGN.md and the MCP server, for the agents building with this.
- must
Put the text in
BubbleContent;Bubbleonly sets the variant and alignment, and every variant paintsdata-slot="bubble-content".content-slot-required
- should
Make a tappable bubble with
BubbleContent'srenderprop (render={<button type="button" />}or an<a>), not by nesting a button inside it, so it gets the hover and focus styles.render-for-interactive
- should
Override the ghost bubble's width with a
data-[variant=ghost]:max-w-*class, not a baremax-w-*.The ghost variant sets
max-w-fullthrough that selector, and an unprefixed class loses to it.override-ghost-width-by-variant
- may
Use
variant="destructive"for a message that failed to send or an error from the assistant.destructive-for-failures
Install
bunx shadcn@latest add @cf/bubbleNeeds the @cf registry in your components.json once. The theme and any sibling components come along automatically.
Source
bubble.tsxShowHide
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 { cn } from "cn";
import type * as React from "react";
function BubbleGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn("flex min-w-0 flex-col gap-2", className)}
data-slot="bubble-group"
{...props}
/>
);
}
const bubbleVariants = cva(
"group/bubble relative flex w-fit min-w-0 max-w-[80%] flex-col gap-1 data-[variant=ghost]:max-w-full data-[align=end]:self-end group-data-[align=end]/message:self-end",
{
defaultVariants: {
variant: "default",
},
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",
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",
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",
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%)]",
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",
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%)]",
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)]",
},
},
}
);
function Bubble({
variant = "default",
align = "start",
className,
...props
}: React.ComponentProps<"div"> &
VariantProps<typeof bubbleVariants> & {
align?: "start" | "end";
}) {
return (
<div
className={cn(bubbleVariants({ variant }), className)}
data-align={align}
data-slot="bubble"
data-variant={variant}
{...props}
/>
);
}
function BubbleContent({
className,
render,
...props
}: useRender.ComponentProps<"div">) {
return useRender({
defaultTagName: "div",
props: mergeProps<"div">(
{
className: cn(
"wrap-break-word w-fit min-w-0 max-w-full overflow-hidden rounded-xl border border-transparent px-3 py-2 text-sm leading-relaxed group-data-[align=end]/bubble:self-end [button,a]:outline-none [button,a]:transition-colors [button,a]:focus-visible:border-ring [button,a]:focus-visible:ring-3 [button,a]:focus-visible:ring-ring/50 [button]:text-left",
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",
{
defaultVariants: {
align: "end",
side: "bottom",
},
variants: {
align: {
end: "right-3",
start: "left-3",
},
side: {
bottom: "bottom-0 translate-y-3/4",
top: "top-0 -translate-y-3/4",
},
},
}
);
function BubbleReactions({
side = "bottom",
align = "end",
className,
...props
}: React.ComponentProps<"div"> & {
align?: "start" | "end";
side?: "top" | "bottom";
}) {
return (
<div
className={cn(bubbleReactionsVariants({ align, side }), className)}
data-align={align}
data-side={side}
data-slot="bubble-reactions"
{...props}
/>
);
}
export { Bubble, BubbleContent, BubbleGroup, BubbleReactions };