Bubble
Displays conversational content in a message bubble. Supports variants, alignment, grouping, reactions, and polymorphic content.
Preview
Loading...
import {
Bubble,
BubbleContent,
BubbleGroup,
BubbleReactions,
} from "@/components/ui/Bubble";
export function BubbleDemo() {
return (
<div className="flex w-full max-w-md flex-col gap-2">
<Bubble variant="secondary">
<BubbleContent>Hey there! What's up?</BubbleContent>
</Bubble>
<Bubble variant="default" align="end">
<BubbleContent>Hey! Want to see chat bubbles?</BubbleContent>
</Bubble>
<Bubble variant="secondary">
<BubbleContent>Sure. Hit me with your best demo.</BubbleContent>
<BubbleReactions role="img" aria-label="Reactions: thumbs up, fire, eyes, and 2 more">
<span>👍</span>
<span>🔥</span>
<span>👀</span>
<span>+2</span>
</BubbleReactions>
</Bubble>
</div>
)
}Installation
Make sure that namespace is set in your component.json file. Namespace docs: Learn more about namespaces
pnpm dlx shadcn@latest add @aura/bubbleManual
Install the following dependencies:
pnpm install @radix-ui/react-slot class-variance-authorityCopy and paste the class names utility into your utils/class-names.ts file.
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy and paste the Bubble component into your components/ui/Bubble.tsx file.
/**
* @description Displays conversational content in a message bubble with variants, alignment, grouping, and reactions.
*/
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/utils/class-names";
function BubbleGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="bubble-group"
className={cn("flex min-w-0 flex-col gap-1", className)}
{...props}
/>
);
}
const bubbleVariants = cva(
"group/bubble relative flex w-fit max-w-[80%] min-w-0 flex-col gap-0.5 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-accent-9 *:data-[slot=bubble-content]:text-accent-contrast [&>[data-slot=bubble-content]:is(button,a):hover]:bg-accent-10",
secondary:
"*:data-[slot=bubble-content]:bg-gray-3 *:data-[slot=bubble-content]:text-gray-12 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-gray-4",
muted:
"*:data-[slot=bubble-content]:bg-gray-2 *:data-[slot=bubble-content]:text-gray-12 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-gray-3",
tinted:
"*:data-[slot=bubble-content]:bg-accent-3 *:data-[slot=bubble-content]:text-gray-12 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-accent-4",
outline:
"*:data-[slot=bubble-content]:border-gray-7 *:data-[slot=bubble-content]:bg-gray-1 *:data-[slot=bubble-content]:text-gray-12 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-gray-3",
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-gray-3 [&>[data-slot=bubble-content]:is(button,a):hover]:text-gray-12",
destructive:
"*:data-[slot=bubble-content]:bg-danger *:data-[slot=bubble-content]:text-danger-contrast [&>[data-slot=bubble-content]:is(button,a):hover]:bg-danger/80",
},
},
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,
asChild = false,
...props
}: React.ComponentProps<"div"> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "div";
return (
<Comp
data-slot="bubble-content"
className={cn(
"w-fit max-w-full min-w-0 overflow-hidden rounded-xl border border-transparent px-1 py-0.5 text-sm leading-relaxed break-words group-data-[align=end]/bubble:self-end [button]:text-left [button,a]:transition-colors [button,a]:outline-none [button,a]:focus-visible:border-gray-8 [button,a]:focus-visible:ring-2 [button,a]:focus-visible:ring-gray-8/50",
className
)}
{...props}
/>
);
}
const bubbleReactionsVariants = cva(
"absolute z-10 flex w-fit shrink-0 items-center justify-center gap-0.5 rounded-full bg-gray-3 px-0.5 py-0.5 text-sm ring-2 ring-gray-1 has-[button]:p-0",
{
variants: {
side: {
top: "top-0 -translate-y-3/4",
bottom: "bottom-0 translate-y-3/4",
},
align: {
start: "left-1",
end: "right-1",
},
},
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,
bubbleVariants,
};
Usage
Variants
export const Variants = () => (
<div className="flex w-full max-w-md flex-col gap-2 py-2">
<Bubble variant="default" align="end">
<BubbleContent>This is the default primary bubble.</BubbleContent>
</Bubble>
<Bubble variant="secondary">
<BubbleContent>This is the secondary variant.</BubbleContent>
</Bubble>
<Bubble variant="muted">
<BubbleContent>This one is muted for quieter supporting content.</BubbleContent>
</Bubble>
<Bubble variant="tinted">
<BubbleContent>This one is tinted from the accent scale.</BubbleContent>
</Bubble>
<Bubble variant="outline">
<BubbleContent>Outlined bubble for secondary or rich content.</BubbleContent>
</Bubble>
<Bubble variant="destructive">
<BubbleContent>Destructive bubble for errors or failed actions.</BubbleContent>
<BubbleReactions role="img" aria-label="Reactions: fire">
<span>🔥</span>
</BubbleReactions>
</Bubble>
<Bubble variant="ghost">
<BubbleContent>
Ghost bubbles work for assistant text and content that should not be
framed.
</BubbleContent>
</Bubble>
</div>
)Alignment
export const Alignment = () => (
<div className="flex w-full max-w-md flex-col gap-2">
<Bubble variant="secondary" align="start">
<BubbleContent>
Aligned to the start. This is the default alignment.
</BubbleContent>
</Bubble>
<Bubble variant="default" align="end">
<BubbleContent>Aligned to the end. Use this for user messages.</BubbleContent>
</Bubble>
</div>
)Group
export const Group = () => (
<div className="flex w-full max-w-md flex-col gap-3">
<BubbleGroup>
<Bubble variant="secondary">
<BubbleContent>Can you tell me what's the issue?</BubbleContent>
</Bubble>
<Bubble variant="secondary">
<BubbleContent>You tell me!</BubbleContent>
</Bubble>
<Bubble variant="secondary">
<BubbleContent>Find the bug and fix it. 👀</BubbleContent>
</Bubble>
</BubbleGroup>
<BubbleGroup>
<Bubble variant="default" align="end">
<BubbleContent>
Want me to diff yesterday's you against today's you? It's
a bit embarrassing.
</BubbleContent>
</Bubble>
</BubbleGroup>
</div>
)AsLink
export const AsLink = () => (
<div className="flex w-full max-w-md flex-col gap-2">
<Bubble variant="muted">
<BubbleContent asChild>
<a href="#help">How can I help you today?</a>
</BubbleContent>
</Bubble>
<Bubble variant="muted" align="end">
<BubbleContent asChild>
<button type="button">Click here</button>
</BubbleContent>
</Bubble>
</div>
)Reactions
export const Reactions = () => (
<div className="flex w-full max-w-md flex-col gap-3 py-2">
<Bubble variant="secondary">
<BubbleContent>
Tests passed on the first try. All 142 of them. Looking good!
</BubbleContent>
<BubbleReactions
side="bottom"
align="end"
role="img"
aria-label="Reactions: thumbs up, party, and 8 more"
>
<span>👍</span>
<span>🎉</span>
<span>+8</span>
</BubbleReactions>
</Bubble>
<Bubble variant="default" align="end">
<BubbleContent>Ship it.</BubbleContent>
<BubbleReactions
side="top"
align="start"
role="img"
aria-label="Reactions: rocket"
>
<span>🚀</span>
</BubbleReactions>
</Bubble>
</div>
)