Skip to main content
Nexus Design System
GitHub

Bubble

Bubble shows one message in a conversation. Use it as the body of a chat or assistant turn. For the avatar, header and footer around it, use Message.

Start-aligned turns hug the inline start.
End-aligned turns hug the inline end.
'use client';

import { Bubble, BubbleContent, BubbleGroup } from '@/components/nxds/components/bubble/bubble';

const column = 'nx:w-full nx:max-w-md';

export default function BubbleAlignment() {
  return (
    <BubbleGroup className={column}>
      <Bubble align="start">
        <BubbleContent>Start-aligned turns hug the inline start.</BubbleContent>
      </Bubble>
      <Bubble align="end" variant="primary">
        <BubbleContent>End-aligned turns hug the inline end.</BubbleContent>
      </Bubble>
    </BubbleGroup>
  );
}

Installation

npx nxds-cli@latest add bubble

Run npx nxds-cli@latest init first if nxds isn’t set up in your app yet. See Install.

Usage

import {
  Bubble,
  BubbleContent,
  BubbleGroup,
  BubbleReactions,
} from '@/components/nxds/components/bubble/bubble';

Composition

BubbleGroup
└── Bubble
    ├── BubbleContent
    └── BubbleReactions

Examples

With reactions

Pin a reaction pill to the corner of each bubble.

Landed the marker primitive.
🎉 3
Reviewing it now.
👀 1
'use client';

import {
  Bubble,
  BubbleContent,
  BubbleGroup,
  BubbleReactions,
} from '@/components/nxds/components/bubble/bubble';

const column = 'nx:w-full nx:max-w-md';

export default function BubbleWithReactions() {
  return (
    <BubbleGroup className={column}>
      <Bubble align="start">
        <BubbleContent>Landed the marker primitive.</BubbleContent>
        <BubbleReactions>🎉 3</BubbleReactions>
      </Bubble>
      <Bubble align="end" variant="primary">
        <BubbleContent>Reviewing it now.</BubbleContent>
        <BubbleReactions>👀 1</BubbleReactions>
      </Bubble>
    </BubbleGroup>
  );
}

All variants

Compare every variant aligned to the start and the end.

muted

Aligned to the inline start.
Aligned to the inline end.

primary

Aligned to the inline start.
Aligned to the inline end.

outline

Aligned to the inline start.
Aligned to the inline end.

ghost

Aligned to the inline start.
Aligned to the inline end.

destructive

Aligned to the inline start.
Aligned to the inline end.
'use client';

import {
  Bubble,
  BubbleContent,
  BubbleGroup,
  type BubbleProps,
} from '@/components/nxds/components/bubble/bubble';

const column = 'nx:w-full nx:max-w-md';

const VARIANTS: NonNullable<BubbleProps['variant']>[] = [
  'muted',
  'primary',
  'outline',
  'ghost',
  'destructive',
];

export default function BubbleAllVariants() {
  return (
    <div className="nx:flex nx:flex-col nx:gap-6">
      {VARIANTS.map((variant) => (
        <div key={variant} className={column}>
          <p className="nx:mb-2 nx:typography-label-small nx:text-muted-foreground">
            {variant}
          </p>
          <BubbleGroup>
            <Bubble variant={variant} align="start">
              <BubbleContent>Aligned to the inline start.</BubbleContent>
            </Bubble>
            <Bubble variant={variant} align="end">
              <BubbleContent>Aligned to the inline end.</BubbleContent>
            </Bubble>
          </BubbleGroup>
        </div>
      ))}
    </div>
  );
}

Props

Bubble

Bubble props
PropTypeDefaultDescription
align"end" | "start" | nullNo default documentedNo description
variant"destructive" | "outline" | "ghost" | "muted" | "primary" | null"muted"No description

BubbleContent

BubbleContent props
PropTypeDefaultDescription
asChildbooleanfalse

Renders the child element instead, such as p, a or button. An actionable child gets the focus ring and pointer cursor.

BubbleGroup

BubbleGroup adds no props of its own; it accepts the props of the element or primitive it wraps.

BubbleReactions

BubbleReactions props
PropTypeDefaultDescription
align"end" | "start" | null"end"No description
side"bottom" | "top" | null"bottom"No description

Bubble shows one message in a conversation. Use it as the body of a chat or assistant turn. For the avatar, header and footer around it, use Message.

<script setup lang="ts">
import { Bubble, BubbleContent, BubbleGroup } from '@/components/bubble';
</script>

<template>
  <BubbleGroup class="nx:w-full nx:max-w-md">
    <Bubble align="start">
      <BubbleContent>Start-aligned turns hug the inline start.</BubbleContent>
    </Bubble>
    <Bubble align="end" variant="primary">
      <BubbleContent>End-aligned turns hug the inline end.</BubbleContent>
    </Bubble>
  </BubbleGroup>
</template>

Installation

  1. Install the following dependencies:

    npm install class-variance-authority@^0.7.1 reka-ui@^2.11.0
    
  2. Copy and paste the following code into your project.

    components/bubble/Bubble.vue
    <script setup lang="ts">
    import type { HTMLAttributes } from 'vue';
    
    import { cn } from '../../lib/utils';
    
    import { type BubbleVariants, bubbleVariants } from '.';
    
    export interface BubbleProps {
      variant?: BubbleVariants['variant'];
      align?: BubbleVariants['align'];
      class?: HTMLAttributes['class'];
    }
    
    const props = withDefaults(defineProps<BubbleProps>(), {
      variant: 'muted',
    });
    </script>
    
    <template>
      <div
        data-slot="bubble"
        :data-variant="variant"
        :data-align="align"
        :class="cn(bubbleVariants({ variant, align }), props.class)"
      >
        <slot />
      </div>
    </template>
    
    components/bubble/BubbleContent.vue
    <script setup lang="ts">
    import { Primitive, type PrimitiveProps } from 'reka-ui';
    import type { HTMLAttributes } from 'vue';
    
    import { cn } from '../../lib/utils';
    
    export interface BubbleContentProps extends PrimitiveProps {
      /**
       * Renders the child element instead, such as `p`, `a` or `button`.
       * An actionable child gets the focus ring and pointer cursor.
       */
      asChild?: boolean;
      class?: HTMLAttributes['class'];
    }
    
    const props = withDefaults(defineProps<BubbleContentProps>(), {
      as: 'div',
      asChild: false,
    });
    </script>
    
    <template>
      <Primitive
        data-slot="bubble-content"
        :as="as"
        :as-child="asChild"
        :class="
          cn(
            'nx:m-0 nx:block nx:min-w-0 nx:rounded-[inherit] nx:border-0 nx:bg-transparent nx:px-4 nx:py-3 nx:[font:inherit] nx:text-inherit nx:no-underline nx:text-start nx:wrap-break-word nx:focus-visible:outline-2 nx:focus-visible:outline-focus-default nx:[&:is(a[href],button:not(:disabled)):not([aria-disabled=true])]:cursor-pointer nx:[&:is(a[href]),&_:where(a[href])]:underline nx:[&_:where(a[href])]:text-inherit nx:[&:is(a[href]),&_:where(a[href])]:underline-offset-4 nx:[&_:where(pre)]:whitespace-pre-wrap nx:[&_:where(pre)]:wrap-break-word',
            props.class
          )
        "
      >
        <slot />
      </Primitive>
    </template>
    
    components/bubble/BubbleGroup.vue
    <script setup lang="ts">
    import type { HTMLAttributes } from 'vue';
    
    import { cn } from '../../lib/utils';
    
    export interface BubbleGroupProps {
      class?: HTMLAttributes['class'];
    }
    
    const props = defineProps<BubbleGroupProps>();
    </script>
    
    <template>
      <div
        data-slot="bubble-group"
        :class="cn('nx:flex nx:w-full nx:flex-col nx:gap-4', props.class)"
      >
        <slot />
      </div>
    </template>
    
    components/bubble/BubbleReactions.vue
    <script setup lang="ts">
    import type { HTMLAttributes } from 'vue';
    
    import { cn } from '../../lib/utils';
    
    import { type BubbleReactionsVariants, bubbleReactionsVariants } from '.';
    
    export interface BubbleReactionsProps {
      side?: BubbleReactionsVariants['side'];
      align?: BubbleReactionsVariants['align'];
      class?: HTMLAttributes['class'];
    }
    
    const props = withDefaults(defineProps<BubbleReactionsProps>(), {
      side: 'bottom',
      align: 'end',
    });
    </script>
    
    <template>
      <div
        data-slot="bubble-reactions"
        data-bubble-part="reactions"
        :data-side="side"
        :data-align="align"
        :class="cn(bubbleReactionsVariants({ side, align }), props.class)"
      >
        <slot />
      </div>
    </template>
    
    components/bubble/index.ts
    import { cva, type VariantProps } from 'class-variance-authority';
    
    export { default as Bubble, type BubbleProps } from './Bubble.vue';
    export {
      default as BubbleContent,
      type BubbleContentProps,
    } from './BubbleContent.vue';
    export {
      default as BubbleGroup,
      type BubbleGroupProps,
    } from './BubbleGroup.vue';
    export {
      default as BubbleReactions,
      type BubbleReactionsProps,
    } from './BubbleReactions.vue';
    
    export const bubbleVariants = cva(
      [
        'nx:relative nx:w-fit nx:max-w-[min(80%,45rem)] nx:rounded-xl nx:border-default nx:border-transparent nx:typography-body-default',
        'nx:has-[>[data-bubble-part=reactions][data-side=top]]:mt-3 nx:has-[>[data-bubble-part=reactions][data-side=bottom]]:mb-3',
      ],
      {
        variants: {
          variant: {
            primary: 'nx:bg-primary-background nx:text-primary-foreground',
            muted: 'nx:bg-muted nx:text-foreground',
            outline: 'nx:border-border-default nx:text-foreground',
            ghost: 'nx:text-foreground',
            destructive: 'nx:bg-error-subtle nx:text-error-subtle-foreground',
          },
          align: {
            start: 'nx:me-auto',
            end: 'nx:ms-auto',
          },
        },
        defaultVariants: {
          variant: 'muted',
        },
      }
    );
    
    export const bubbleReactionsVariants = cva(
      'nx:absolute nx:flex nx:w-fit nx:items-center nx:gap-1 nx:rounded-full nx:border-default nx:border-border-default nx:bg-popover nx:px-2 nx:py-0.5 nx:typography-label-small nx:text-popover-foreground nx:shadow-sm',
      {
        variants: {
          side: {
            top: 'nx:-top-3',
            bottom: 'nx:-bottom-3',
          },
          align: {
            start: 'nx:start-3',
            end: 'nx:end-3',
          },
        },
        defaultVariants: {
          side: 'bottom',
          align: 'end',
        },
      }
    );
    
    export type BubbleVariants = VariantProps<typeof bubbleVariants>;
    export type BubbleReactionsVariants = VariantProps<
      typeof bubbleReactionsVariants
    >;
    
  3. Keep each file at the path shown above it.

    Paths are relative to packages/vue/src/. Keep the same folders under your @/ root, and the imports between files work without edits.

Usage

import { Bubble, BubbleContent, BubbleGroup, BubbleReactions } from '@/components/bubble';

Examples

With reactions

Pin a reaction pill to the corner of each bubble.

<script setup lang="ts">
import {
  Bubble,
  BubbleContent,
  BubbleGroup,
  BubbleReactions,
} from '@/components/bubble';
</script>

<template>
  <BubbleGroup class="nx:w-full nx:max-w-md">
    <Bubble align="start">
      <BubbleContent>Landed the marker primitive.</BubbleContent>
      <BubbleReactions>🎉 3</BubbleReactions>
    </Bubble>
    <Bubble align="end" variant="primary">
      <BubbleContent>Reviewing it now.</BubbleContent>
      <BubbleReactions>👀 1</BubbleReactions>
    </Bubble>
  </BubbleGroup>
</template>

All variants

Compare every variant aligned to the start and the end.

<script setup lang="ts">
import { Bubble, BubbleContent, BubbleGroup } from '@/components/bubble';

const variants = ['muted', 'primary', 'outline', 'ghost', 'destructive'];
</script>

<template>
  <div class="nx:flex nx:flex-col nx:gap-6">
    <div
      v-for="variant in variants"
      :key="variant"
      class="nx:w-full nx:max-w-md"
    >
      <p class="nx:mb-2 nx:typography-label-small nx:text-muted-foreground">
        {{ variant }}
      </p>
      <BubbleGroup>
        <Bubble :variant="variant" align="start">
          <BubbleContent>Aligned to the inline start.</BubbleContent>
        </Bubble>
        <Bubble :variant="variant" align="end">
          <BubbleContent>Aligned to the inline end.</BubbleContent>
        </Bubble>
      </BubbleGroup>
    </div>
  </div>
</template>

Props

Bubble

Bubble props
PropTypeDefaultDescription
align"start" | "end" | nullNo default documentedNo description
variant"destructive" | "outline" | "ghost" | "muted" | "primary" | null"muted"No description

BubbleContent

BubbleContent props
PropTypeDefaultDescription
asChildbooleanfalse

Renders the child element instead, such as p, a or button. An actionable child gets the focus ring and pointer cursor.

BubbleGroup

BubbleGroup adds no props of its own; it accepts the props of the element or primitive it wraps.

BubbleReactions

BubbleReactions props
PropTypeDefaultDescription
align"start" | "end" | null"end"No description
side"bottom" | "top" | null"bottom"No description