Aura Design System

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&apos;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/bubble

Manual

Install the following dependencies:

pnpm install @radix-ui/react-slot class-variance-authority

Copy 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&apos;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&apos;s you against today&apos;s you? It&apos;s
          a bit embarrassing.
        </BubbleContent>
      </Bubble>
    </BubbleGroup>
  </div>
)
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>
)