bubble

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

PreviousNext
Hey there! what's up?Hey! Want to see chat bubbles? I can group messages, switch sides, and keep the whole thread easy to scan. 👍Sure. Hit me with your best demo. Yes. You are reading a demo that is demoing itself. Very meta. Very on-brand. 👍🔥👀+2
import { ChangeDetectionStrategy, Component } from '@angular/core';

import { ZardBubbleImports } from '@/shared/components/bubble/bubble.imports';

@Component({
  selector: 'z-demo-bubble-preview',
  imports: [...ZardBubbleImports],
  template: `
    <div class="flex w-full max-w-sm flex-col gap-8 py-12">
      <z-bubble zAlign="end">
        <z-bubble-content>Hey there! what's up?</z-bubble-content>
      </z-bubble>
      <z-bubble-group>
        <z-bubble zVariant="muted">
          <z-bubble-content>Hey! Want to see chat bubbles?</z-bubble-content>
        </z-bubble>
        <z-bubble zVariant="muted">
          <z-bubble-content>
            I can group messages, switch sides, and keep the whole thread easy to scan.
          </z-bubble-content>
          <z-bubble-reactions role="img" aria-label="Reaction: thumbs up">
            <span>👍</span>
          </z-bubble-reactions>
        </z-bubble>
      </z-bubble-group>
      <z-bubble zAlign="end">
        <z-bubble-content>Sure. Hit me with your best demo.</z-bubble-content>
      </z-bubble>
      <z-bubble zVariant="muted">
        <z-bubble-content>
          Yes. You are reading a demo that is demoing itself. Very meta. Very on-brand.
        </z-bubble-content>
        <z-bubble-reactions role="img" aria-label="Reactions: thumbs up, fire, eyes, and 2 more">
          <span>👍</span>
          <span>🔥</span>
          <span>👀</span>
          <span>+2</span>
        </z-bubble-reactions>
      </z-bubble>
    </div>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
  host: { class: 'contents' },
})
export class ZardDemoBubblePreviewComponent {}

Installation

Copy
npx zard-cli@latest add bubble

Usage

import { ZardBubbleImports } from '@/shared/components/bubble/bubble.imports';
Copy
<z-bubble>
  <z-bubble-content>I checked the registry output and removed the stale route.</z-bubble-content>
  <z-bubble-reactions>
    <span>👍</span>
  </z-bubble-reactions>
</z-bubble>
Copy

Examples

variants

The seven zVariant treatments on z-bubble — default, secondary, muted, tinted, outline, ghost, and destructive — from a strong primary bubble to unframed ghost content that spans the full row.
This is the default primary bubble.This is the secondary variant.This one is muted. It uses a lower emphasis color for the chat bubble.👍 This one is tinted. The tint is a softer color derived from the primary color. We can also use an outlined variant.Or a destructive variant with a reaction.🔥

Ghost bubbles work for assistant text, markdown, 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. You can also render code in it.

Ghost bubbles are full width and can take the full width of the container.

import { ChangeDetectionStrategy, Component } from '@angular/core';

import { ZardBubbleImports } from '@/shared/components/bubble/bubble.imports';

@Component({
  selector: 'z-demo-bubble-variants',
  imports: [...ZardBubbleImports],
  template: `
    <div class="flex w-full max-w-sm flex-col gap-12 py-12">
      <z-bubble>
        <z-bubble-content>This is the default primary bubble.</z-bubble-content>
      </z-bubble>
      <z-bubble zVariant="secondary" zAlign="end">
        <z-bubble-content>This is the secondary variant.</z-bubble-content>
      </z-bubble>
      <z-bubble zVariant="muted">
        <z-bubble-content>This one is muted. It uses a lower emphasis color for the chat bubble.</z-bubble-content>
        <z-bubble-reactions role="img" aria-label="Reaction: thumbs up">
          <span>👍</span>
        </z-bubble-reactions>
      </z-bubble>
      <z-bubble zVariant="tinted" zAlign="end">
        <z-bubble-content>
          This one is tinted. The tint is a softer color derived from the primary color.
        </z-bubble-content>
      </z-bubble>
      <z-bubble zVariant="outline">
        <z-bubble-content>We can also use an outlined variant.</z-bubble-content>
      </z-bubble>
      <z-bubble zVariant="destructive" zAlign="end">
        <z-bubble-content>Or a destructive variant with a reaction.</z-bubble-content>
        <z-bubble-reactions role="img" aria-label="Reaction: fire">
          <span>🔥</span>
        </z-bubble-reactions>
      </z-bubble>
      <z-bubble zVariant="ghost">
        <z-bubble-content class="flex flex-col gap-4">
          <!-- prettier-ignore -->
          <p>Ghost bubbles work for assistant text, <strong class="font-semibold">markdown</strong>, and other content that should not be framed.</p>
          <!-- prettier-ignore -->
          <p>This is perfect for assistant messages that should not have a frame and can take the full width of the container. You can also render <code class="bg-muted rounded-sm px-1 py-0.5 font-mono text-[0.8rem]">code</code> in it.</p>
          <p>Ghost bubbles are full width and can take the full width of the container.</p>
        </z-bubble-content>
      </z-bubble>
    </div>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
  host: { class: 'contents' },
})
export class ZardDemoBubbleVariantsComponent {}

alignment

Set zAlign="start" (the default) or zAlign="end" on z-bubble to place a turn on the left or right of the thread — start for the other party, end for the current user.
This bubble is aligned to the start. This is the default alignment.This bubble is aligned to the end. Use this for user messages.
import { ChangeDetectionStrategy, Component } from '@angular/core';

import { ZardBubbleImports } from '@/shared/components/bubble/bubble.imports';

@Component({
  selector: 'z-demo-bubble-alignment',
  imports: [...ZardBubbleImports],
  template: `
    <div class="flex w-full max-w-sm flex-col gap-8 py-12">
      <z-bubble zVariant="muted">
        <z-bubble-content>This bubble is aligned to the start. This is the default alignment.</z-bubble-content>
      </z-bubble>
      <z-bubble zAlign="end">
        <z-bubble-content>This bubble is aligned to the end. Use this for user messages.</z-bubble-content>
      </z-bubble>
    </div>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
  host: { class: 'contents' },
})
export class ZardDemoBubbleAlignmentComponent {}

shorthand

A zard-only shorthand: skip z-bubble-content for plain text and project it straight into z-bubble — a bubble with no projected content gets the content surface for free. Reach for the explicit z-bubble-content element when you need a class override, or an interactive button/link surface.
Short turns do not need the content wrapper.
Hey there! what's up?
Project the content when you need to style it, or render it as a button or link.
import { ChangeDetectionStrategy, Component } from '@angular/core';

import { ZardBubbleImports } from '@/shared/components/bubble/bubble.imports';

@Component({
  selector: 'z-demo-bubble-shorthand',
  imports: [...ZardBubbleImports],
  template: `
    <div class="flex w-full max-w-sm flex-col gap-8 py-12">
      <z-bubble zVariant="muted">Short turns do not need the content wrapper.</z-bubble>
      <z-bubble zAlign="end">Hey there! what's up?</z-bubble>
      <z-bubble zVariant="muted">
        <z-bubble-content class="font-medium">
          Project the content when you need to style it, or render it as a button or link.
        </z-bubble-content>
      </z-bubble>
    </div>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
  host: { class: 'contents' },
})
export class ZardDemoBubbleShorthandComponent {}

group

Wrap consecutive z-bubble elements from the same sender in z-bubble-group to stack them with a tighter gap, so a multi-message reply reads as one turn instead of three separate bubbles. Set zAlign on each z-bubble, not on the group.
Can you tell me what's the issue?You tell me!It worked yesterday. You broke it!Find the bug and fix it.👀 Want me to diff yesterday's you against today's you? It's a bit embarrassing.
import { ChangeDetectionStrategy, Component } from '@angular/core';

import { ZardBubbleImports } from '@/shared/components/bubble/bubble.imports';

@Component({
  selector: 'z-demo-bubble-group',
  imports: [...ZardBubbleImports],
  template: `
    <div class="flex w-full max-w-sm flex-col gap-8 py-12">
      <z-bubble zVariant="muted">
        <z-bubble-content>Can you tell me what's the issue?</z-bubble-content>
      </z-bubble>
      <z-bubble-group>
        <z-bubble zAlign="end">
          <z-bubble-content>You tell me!</z-bubble-content>
        </z-bubble>
        <z-bubble zAlign="end">
          <z-bubble-content>It worked yesterday. You broke it!</z-bubble-content>
        </z-bubble>
        <z-bubble zAlign="end">
          <z-bubble-content>Find the bug and fix it.</z-bubble-content>
          <z-bubble-reactions role="img" aria-label="Reactions: eyes" zAlign="start">
            <span>👀</span>
          </z-bubble-reactions>
        </z-bubble>
      </z-bubble-group>
      <z-bubble zVariant="muted">
        <z-bubble-content>
          Want me to diff yesterday's you against today's you? It's a bit embarrassing.
        </z-bubble-content>
      </z-bubble>
    </div>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
  host: { class: 'contents' },
})
export class ZardDemoBubbleGroupComponent {}

links and buttons

Apply z-bubble-content as an attribute on a native button or a instead of projecting it as an element, so the whole bubble becomes an interactive quick reply or link — the bubble already styles that hover state through [data-slot=bubble-content]:is(button,a).
How can I help you today?Read the docs instead
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';

import { ZardBubbleImports } from '@/shared/components/bubble/bubble.imports';
import { ZardSonnerService } from '@/shared/components/sonner/sonner.service';

@Component({
  selector: 'z-demo-bubble-links-and-buttons',
  imports: [...ZardBubbleImports],
  template: `
    <div class="flex w-full max-w-sm flex-col gap-8 py-12">
      <z-bubble zVariant="muted">
        <z-bubble-content>How can I help you today?</z-bubble-content>
      </z-bubble>
      <z-bubble-group>
        <z-bubble zVariant="tinted" zAlign="end">
          <button type="button" z-bubble-content (click)="notify('You clicked forgot password')">
            I forgot my password
          </button>
        </z-bubble>
        <z-bubble zVariant="tinted" zAlign="end">
          <a z-bubble-content href="/docs/components/bubble" target="_blank" rel="noreferrer">Read the docs instead</a>
        </z-bubble>
        <z-bubble zVariant="tinted" zAlign="end">
          <button type="button" z-bubble-content (click)="notify('You clicked something else. Talk to a human.')">
            Something else. Talk to a human.
          </button>
        </z-bubble>
      </z-bubble-group>
    </div>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
  host: { class: 'contents' },
})
export class ZardDemoBubbleLinksAndButtonsComponent {
  private readonly sonner = inject(ZardSonnerService);

  protected notify(message: string) {
    this.sonner.show(message);
  }
}

reactions

Use z-bubble-reactions to render a row of reactions or quick-action buttons anchored to a corner of the bubble. zSide (top/bottom) and zAlign (start/end) position it — the row overlaps the bubble edge, so give it room with a larger gap.
I don't need tests, I know my code works.👍😮Bold. Fine I'll add some tests. I'll let you know when they're done.👀🚀+2Tests passed on the first try. All 142 of them. Looking good!🎉👏Are you sure I can run this command?
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';

import { ZardBubbleImports } from '@/shared/components/bubble/bubble.imports';
import { ZardButtonComponent } from '@/shared/components/button/button.component';
import { ZardSonnerService } from '@/shared/components/sonner/sonner.service';

@Component({
  selector: 'z-demo-bubble-reactions',
  imports: [ZardButtonComponent, ...ZardBubbleImports],
  template: `
    <div class="flex w-full max-w-sm flex-col gap-12 py-12">
      <z-bubble zVariant="muted" zAlign="end">
        <z-bubble-content>I don't need tests, I know my code works.</z-bubble-content>
        <z-bubble-reactions role="img" aria-label="Reactions: thumbs up, surprised" zAlign="start">
          <span>👍</span>
          <span>😮</span>
        </z-bubble-reactions>
      </z-bubble>
      <z-bubble zVariant="muted">
        <z-bubble-content>Bold. Fine I'll add some tests. I'll let you know when they're done.</z-bubble-content>
        <z-bubble-reactions role="img" aria-label="Reactions: eyes, rocket, and 2 more">
          <span>👀</span>
          <span>🚀</span>
          <span>+2</span>
        </z-bubble-reactions>
      </z-bubble>
      <z-bubble zVariant="default" zAlign="end">
        <z-bubble-content>Tests passed on the first try. All 142 of them. Looking good!</z-bubble-content>
        <z-bubble-reactions role="img" aria-label="Reactions: party popper, clapping hands" zSide="top" zAlign="start">
          <span>🎉</span>
          <span>👏</span>
        </z-bubble-reactions>
      </z-bubble>
      <z-bubble zVariant="destructive">
        <z-bubble-content>Are you sure I can run this command?</z-bubble-content>
        <z-bubble-reactions>
          <button type="button" z-button zType="ghost" zSize="xs" (click)="runCommand()">Yes, run it</button>
        </z-bubble-reactions>
      </z-bubble>
    </div>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
  host: { class: 'contents' },
})
export class ZardDemoBubbleReactionsComponent {
  private readonly sonner = inject(ZardSonnerService);

  protected runCommand() {
    this.sonner.success('You clicked yes, running command...');
  }
}

collapsible

Long bubble content is not truncated automatically — compose z-collapsible inside z-bubble-content and pair a z-button with [z-collapsible-trigger] for a real "Show more"/"Show less" toggle.
How can I help you today?
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...
import { ChangeDetectionStrategy, Component, computed, signal } from '@angular/core';

import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideChevronDown } from '@ng-icons/lucide';

import { ZardBubbleImports } from '@/shared/components/bubble/bubble.imports';
import { ZardButtonComponent } from '@/shared/components/button/button.component';
import { ZardCollapsibleImports } from '@/shared/components/collapsible/collapsible.imports';

const TEXT = `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 controls inside a layered surface.

The dialog and drawer are fine. The menu needs the hover and focus tokens split so keyboard focus stays visible when the pointer is not involved.

I also recommend keeping the change in the style file instead of the primitive so the other themes can choose their own focus treatment later.`;

const PREVIEW_LENGTH = 180;

@Component({
  selector: 'z-demo-bubble-collapsible',
  imports: [NgIcon, ZardButtonComponent, ...ZardCollapsibleImports, ...ZardBubbleImports],
  template: `
    <div class="flex w-full max-w-sm flex-col gap-8 py-12">
      <z-bubble zVariant="muted">
        <z-bubble-content>How can I help you today?</z-bubble-content>
      </z-bubble>

      <z-bubble zVariant="muted" zAlign="end">
        <z-bubble-content class="whitespace-pre-line">
          <div z-collapsible #turn="zCollapsible" [zOpen]="open()" (zOpenChange)="open.set($event)">
            <div [id]="turn.contentId()">{{ visibleText() }}</div>
            @if (isLong) {
              <button type="button" z-button z-collapsible-trigger zType="link" class="text-muted-foreground gap-1 p-0">
                {{ open() ? 'Show less' : 'Show more' }}
                <ng-icon
                  name="lucideChevronDown"
                  data-icon="inline-end"
                  class="transition-transform"
                  [class.rotate-180]="open()"
                />
              </button>
            }
          </div>
        </z-bubble-content>
      </z-bubble>
    </div>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
  viewProviders: [provideIcons({ lucideChevronDown })],
  host: { class: 'contents' },
})
export class ZardDemoBubbleCollapsibleComponent {
  protected readonly open = signal(false);
  protected readonly isLong = TEXT.length > PREVIEW_LENGTH;
  protected readonly visibleText = computed(() =>
    this.open() || !this.isLong ? TEXT : `${TEXT.slice(0, PREVIEW_LENGTH)}...`,
  );
}

tooltip

Compose a bubble reaction with [zTooltip] to reveal metadata on hover, such as when a message was read.
Did you remove the stale route?Yes, removed it from the registry.
import { ChangeDetectionStrategy, Component } from '@angular/core';

import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideCheck } from '@ng-icons/lucide';

import { ZardBubbleImports } from '@/shared/components/bubble/bubble.imports';
import { ZardButtonComponent } from '@/shared/components/button/button.component';
import { ZardTooltipImports } from '@/shared/components/tooltip/tooltip.imports';

@Component({
  selector: 'z-demo-bubble-tooltip',
  imports: [NgIcon, ZardButtonComponent, ZardTooltipImports, ...ZardBubbleImports],
  template: `
    <div class="flex w-full max-w-sm flex-col gap-4 py-12">
      <z-bubble zVariant="secondary">
        <z-bubble-content>Did you remove the stale route?</z-bubble-content>
      </z-bubble>
      <z-bubble zAlign="end">
        <z-bubble-content>Yes, removed it from the registry.</z-bubble-content>
        <z-bubble-reactions>
          <button
            type="button"
            z-button
            zType="ghost"
            zSize="icon-xs"
            zTooltip="Read on Jan 5, 2026 at 4:32 PM"
            aria-label="Read receipt"
          >
            <ng-icon name="lucideCheck" />
          </button>
        </z-bubble-reactions>
      </z-bubble>
    </div>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
  viewProviders: [provideIcons({ lucideCheck })],
  host: { class: 'contents' },
})
export class ZardDemoBubbleTooltipComponent {}

popover

Pair a bubble reaction with [zPopover] to surface more information on demand, such as the full error message behind a failed action.
Run the build script.Failed to run the command.
import { ChangeDetectionStrategy, Component } from '@angular/core';

import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideInfo } from '@ng-icons/lucide';

import { ZardBubbleImports } from '@/shared/components/bubble/bubble.imports';
import { ZardButtonComponent } from '@/shared/components/button/button.component';
import { ZardPopoverImports } from '@/shared/components/popover/popover.imports';

@Component({
  selector: 'z-demo-bubble-popover',
  imports: [NgIcon, ZardButtonComponent, ...ZardPopoverImports, ...ZardBubbleImports],
  template: `
    <div class="flex w-full max-w-sm flex-col gap-4 py-12">
      <z-bubble zAlign="end">
        <z-bubble-content>Run the build script.</z-bubble-content>
      </z-bubble>
      <z-bubble zVariant="destructive">
        <z-bubble-content>Failed to run the command.</z-bubble-content>
        <z-bubble-reactions>
          <button
            type="button"
            z-button
            zType="ghost"
            zSize="icon-xs"
            zPopover
            aria-label="Show error details"
            class="aria-expanded:text-destructive"
            [zContent]="errorDetails"
          >
            <ng-icon name="lucideInfo" />
          </button>
        </z-bubble-reactions>
      </z-bubble>
    </div>

    <ng-template #errorDetails>
      <z-popover>
        <div z-popover-header>
          <h4 z-popover-title class="text-sm">Command failed with exit code 1</h4>
          <p z-popover-description class="text-sm">ENOENT: no such file or directory, open pnpm-lock.yaml</p>
        </div>
      </z-popover>
    </ng-template>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
  viewProviders: [provideIcons({ lucideInfo })],
  host: { class: 'contents' },
})
export class ZardDemoBubblePopoverComponent {}

API Reference

z-bubbleComponent

The root bubble wrapper. Content projected straight into it gets the bubble surface, so a plain turn needs no sub-component.

PropertyDescriptionTypeDefault
[zVariant] The bubble visual treatment. default | secondary | muted | tinted | outline | ghost | destructive default
[zAlign] The inline alignment of the bubble. start | end start
[class] Additional classes to apply to the root element. ClassValue -

z-bubble-contentComponent

The bubble content wrapper. Project it to style the surface, or use it as an attribute on a button or anchor to render the content as an interactive element.

PropertyDescriptionTypeDefault
[class] Additional classes to apply to the content element. ClassValue -

z-bubble-reactionsComponent

Displays overlapped reactions for a bubble.

PropertyDescriptionTypeDefault
[zSide] The side of the bubble to anchor the reactions. top | bottom bottom
[zAlign] The inline alignment of the reactions. start | end end
[class] Additional classes to apply to the reaction row. ClassValue -

z-bubble-groupComponent

Groups consecutive bubbles from the same sender.

PropertyDescriptionTypeDefault
[class] Additional classes to apply to the group root. ClassValue -
github iconwhatsapp icondiscord iconX icon

Made with ❤ in Brazil. Open source and available on GitHub .