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-default',
  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 ZardDemoBubbleDefaultComponent {}

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

default

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-default',
  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 ZardDemoBubbleDefaultComponent {}

variants

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

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 {}

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 {}

link button

How can I help you today?
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-link-button',
  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">
          <button type="button" z-bubble-content (click)="notify('You clicked help with subscription')">
            I need help with my subscription
          </button>
        </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 ZardDemoBubbleLinkButtonComponent {
  private readonly sonner = inject(ZardSonnerService);

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

reactions

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

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';

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, ...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 id="bubble-collapsible-text">{{ visibleText() }}</div>
          @if (isLong) {
            <button
              type="button"
              z-button
              zType="link"
              class="text-muted-foreground gap-1 p-0"
              aria-controls="bubble-collapsible-text"
              [attr.aria-expanded]="open()"
              (click)="open.set(!open())"
            >
              {{ open() ? 'Show less' : 'Show more' }}
              <ng-icon
                name="lucideChevronDown"
                data-icon="inline-end"
                class="transition-transform"
                [class.rotate-180]="open()"
              />
            </button>
          }
        </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

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

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 {}

shorthand

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 {}

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 .