Displays conversational content in a message bubble. Supports variants, alignment, grouping, and reactions.
Installation
Install the dependencies
pnpm add cn class-variance-authorityCopy the component
Copy the following files into src/lib/components/ui/bubble.
<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><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}<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><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>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 };// 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>;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]>;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>Link
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>