Bubble

Displays conversational content in a message bubble. Supports variants, alignment, grouping, and reactions.

Sizes
This is a one line bubble.
This bubble has multiple lines. It should wrap to the next line and you should see a different radius on the corners.

This bubble has multiple lines.

It should wrap to the next line and you should see a different radius on the corners.

Here is some more text to see how it wraps.

Variants
Default bubbles use the primary color for the active user side of a chat.
Secondary bubbles are the standard neutral surface for assistant and conversation content.
Muted bubbles lower the emphasis for quiet system notes or for displaying supporting content.
Tinted bubbles use a softer primary tint when primary fill is too strong.
Outline bubbles can be used to frame message content and give it a border.
Destructive bubbles flag errors or failed actions in a conversation.
Ghost bubbles work for assistant text and other content that should not be framed. This is perfect for assistant messages that should not have a frame and can take the full width of the container. Ghost bubbles are full width and can take the full width of the container.
Alignment
This bubble is aligned to the start.
This bubble is aligned to the end.
This multiline bubble is aligned to the start. The corners should adjust when the text wraps to show the grouped side of the conversation.
This multiline bubble is aligned to the end. It should sit on the opposite side with the matching corner radius for wrapped text.
Grouped
I finished the audit pass.
The registry output looks clean, but I found one stale route.
Want me to remove it now?
Yes, clean that up.
Then rerun the registry build.
Collapsible
The accessibility review found two focus states that were visually too subtle in dark mode. I checked the dialog, menu, and drawer paths because each one renders focusable control...
Ghost bubbles work for assistant text and other content that should not be framed. This is perfect for assistant messages that should not have a frame and can take the full width of the container. Use this for content that needs the whole row.
Button & Links
Chat Suggestions
How can I help you today?
Reaction Placement
side=bottom align=end
This is a one line message.
A longer message that wraps across lines so the reaction offset is easier to inspect.
A longer message that wraps across lines so the reaction offset is easier to inspect.
side=bottom align=start
This is a one line message.
A longer message that wraps across lines so the reaction offset is easier to inspect.
side=top align=start
This is a one line message.
A longer message that wraps across lines so the reaction offset is easier to inspect.
side=bottom align=end
This is a one line message.
A longer message that wraps across lines so the reaction offset.
Reactions Buttons
This is a one line message.
This is a one line message.
We are going to the movies first then dinner. Are you in?

Installation

Install the dependencies

pnpm add cn class-variance-authority

Copy the component

Copy the following files into src/lib/components/ui/bubble.

src/lib/components/ui/bubble/Bubble.svelte
<script lang="ts">
  import type { BubbleProps } from './types.js';
  import { cn } from 'cn';
  import { bubbleVariants } from './variants.js';
  let { children, class: classProp, variant = 'default', align = 'start', ...props }: BubbleProps = $props();
</script>
<div data-slot="bubble" data-variant={variant} data-align={align} {...props} class={cn(bubbleVariants({ variant }), classProp)}>
  {@render children?.()}
</div>
src/lib/components/ui/bubble/BubbleContent.svelte
<script lang="ts">
  import type { BubbleContentProps, BubbleContentState } from './types.js';
  import { cn } from 'cn';
  let { children, class: classProp, render, ...props }: BubbleContentProps = $props();
  const state: BubbleContentState = { slot: 'bubble-content' };
  const hostProps = $derived({
    'data-slot': state.slot,
    ...props,
    class: cn('rounded-xl border border-transparent px-3 py-2 text-sm leading-relaxed [button,a]:outline-none [button,a]:focus-visible:border-ring [button,a]:focus-visible:ring-3 [button,a]:focus-visible:ring-ring/50 group-data-[align=end]/bubble:self-end w-fit max-w-full min-w-0 overflow-hidden wrap-break-word [button]:text-left [button,a]:transition-colors', classProp),
  });
</script>
{#if render}
  {@render render(hostProps, state, children)}
{:else}
  <div {...hostProps}>{@render children?.()}</div>
{/if}
src/lib/components/ui/bubble/BubbleGroup.svelte
<script lang="ts">
  import type { BubbleGroupProps } from './types.js';
  import { cn } from 'cn';
  let { children, class: classProp, ...props }: BubbleGroupProps = $props();
</script>
<div data-slot="bubble-group" {...props} class={cn('gap-2 flex min-w-0 flex-col', classProp)}>
  {@render children?.()}
</div>
src/lib/components/ui/bubble/BubbleReactions.svelte
<script lang="ts">
  import type { BubbleReactionsProps } from './types.js';
  import { cn } from 'cn';
  import { bubbleReactionsVariants } from './variants.js';
  let { children, class: classProp, side = 'bottom', align = 'end', ...props }: BubbleReactionsProps = $props();
</script>
<div data-slot="bubble-reactions" data-align={align} data-side={side} {...props} class={cn(bubbleReactionsVariants({ side, align }), classProp)}>
  {@render children?.()}
</div>
src/lib/components/ui/bubble/types.ts
import type { HTMLAttributes } from 'svelte/elements';
import type { RenderSnippet } from '../shared/render.js';
import type { BubbleReactionsVariants, BubbleVariants } from './variants.js';

type NativeDivProps = HTMLAttributes<HTMLDivElement>;

export type BubbleAlign = 'start' | 'end';
export type BubbleGroupProps = NativeDivProps;
export type BubbleProps = NativeDivProps & BubbleVariants & { align?: BubbleAlign };
export type BubbleContentState = { slot: 'bubble-content' };
export type BubbleContentProps = HTMLAttributes<HTMLElement> & { render?: RenderSnippet<BubbleContentState> };
export type BubbleReactionsProps = NativeDivProps & BubbleReactionsVariants & { align?: BubbleAlign };
src/lib/components/ui/bubble/variants.ts
// Adapted from shadcn-ui/ui d75a96ab; MIT: packages/ui/THIRD_PARTY_NOTICES.md.
import { cva } from 'class-variance-authority';
import type { VariantProps } from 'class-variance-authority';
export const bubbleVariants = cva('gap-1 data-[align=end]:self-end max-w-[80%] data-[variant=ghost]:max-w-full group-data-[align=end]/message:self-end group/bubble relative flex w-fit min-w-0 flex-col', {
  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)] dark:*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.3_calc(c*0.4)_h)] *:data-[slot=bubble-content]:text-foreground [&>[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]:bg-background *:data-[slot=bubble-content]:border-border [&>[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: '*: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 border-none',
      destructive: '*:data-[slot=bubble-content]:bg-destructive/10 dark:*:data-[slot=bubble-content]:bg-destructive/20 *:data-[slot=bubble-content]:text-destructive [&>[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' },
});
export const bubbleReactionsVariants = cva('rounded-full ring-3 ring-card bg-muted shrink-0 gap-1 px-1.5 py-0.5 has-[button]:p-0 text-sm absolute z-10 flex w-fit items-center justify-center', {
  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' },
});
export type BubbleVariants = VariantProps<typeof bubbleVariants>;
export type BubbleReactionsVariants = VariantProps<typeof bubbleReactionsVariants>;
src/lib/components/ui/shared/render.ts
import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';

/**
 * Replaces a part's default element, like the `render` snippets of Sveltery Base.
 * Spread `props` onto the host element and render `children` inside it.
 * `Host` is the element the part renders by default, so its typed event handlers carry over.
 */
export type RenderSnippet<State, Host extends HTMLElement = HTMLElement> = Snippet<[props: HTMLAttributes<Host>, state: State, children: Snippet | undefined]>;
src/lib/components/ui/bubble/index.ts
export { default as BubbleGroup } from './BubbleGroup.svelte';
export { default as Bubble } from './Bubble.svelte';
export { default as BubbleContent } from './BubbleContent.svelte';
export { default as BubbleReactions } from './BubbleReactions.svelte';
export { bubbleVariants, bubbleReactionsVariants } from './variants.js';
export type { BubbleVariants, BubbleReactionsVariants } from './variants.js';
export type { BubbleAlign, BubbleGroupProps, BubbleProps, BubbleContentProps, BubbleContentState, BubbleReactionsProps } from './types.js';

Update the import paths

Change the imports to match your project setup.

Usage

<script lang="ts">
  import { Bubble, BubbleContent, BubbleGroup } from '$lib/components/ui/bubble';
</script>
<BubbleGroup>
  <Bubble>
    <BubbleContent>Hey, are we still on for today?</BubbleContent>
  </Bubble>
  <Bubble align="end" variant="secondary">
    <BubbleContent>Yes, see you at three.</BubbleContent>
  </Bubble>
</BubbleGroup>

Pass a render snippet to BubbleContent to render it as a link or a button. Spread props onto it and render children inside it.

<Bubble>
  <BubbleContent>
    {#snippet render(props, state, children)}
      <button type="button" {...props}>{@render children?.()}</button>
    {/snippet}
    Show me the report
  </BubbleContent>
</Bubble>