Skip to main content
Nexus Design System
GitHub

Message

Message lays out one conversation turn, with an avatar, a header, a body and a footer. Use it for chat and assistant threads, with Bubble or Attachment as the body. Message holds no chat state, so your app owns the messages and MessageScroller handles scrolling.

AB
Ana Bianchi
Can you take a look at the failing run?
09:14
'use client';

import { AvatarFallback } from '@/components/nxds/components/avatar/avatar';
import { Bubble, BubbleContent } from '@/components/nxds/components/bubble/bubble';
import {
  Message,
  MessageAvatar,
  MessageContent,
  MessageFooter,
  MessageGroup,
  MessageHeader,
} from '@/components/nxds/components/message/message';

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

export default function MessageDefault() {
  return (
    <MessageGroup className={column}>
      <Message>
        <MessageAvatar>
          <AvatarFallback>AB</AvatarFallback>
        </MessageAvatar>
        <MessageContent>
          <MessageHeader>Ana Bianchi</MessageHeader>
          <Bubble>
            <BubbleContent>
              Can you take a look at the failing run?
            </BubbleContent>
          </Bubble>
          <MessageFooter>09:14</MessageFooter>
        </MessageContent>
      </Message>
    </MessageGroup>
  );
}

This example also needs: Bubble.

Installation

npx nxds-cli@latest add message

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

Usage

import {
  Message,
  MessageAvatar,
  MessageContent,
  MessageFooter,
  MessageGroup,
  MessageHeader,
} from '@/components/nxds/components/message/message';

Composition

MessageGroup
└── Message
    ├── MessageAvatar
    └── MessageContent
        ├── MessageHeader
        └── MessageFooter
Message
├── MessageAvatar
└── MessageContent
    └── MessageFooter

Examples

Alignment

Set align="end" on your own turns to place them on the opposite side.

AB
Ana Bianchi
Incoming turns keep the avatar rail at the inline start.
09:14
YOU
You
Own turns move it to the inline end.
09:16
'use client';

import { AvatarFallback } from '@/components/nxds/components/avatar/avatar';
import { Bubble, BubbleContent } from '@/components/nxds/components/bubble/bubble';
import {
  Message,
  MessageAvatar,
  MessageContent,
  MessageFooter,
  MessageGroup,
  MessageHeader,
} from '@/components/nxds/components/message/message';

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

export default function MessageAlignment() {
  return (
    <MessageGroup className={column}>
      <Message align="start">
        <MessageAvatar>
          <AvatarFallback>AB</AvatarFallback>
        </MessageAvatar>
        <MessageContent>
          <MessageHeader>Ana Bianchi</MessageHeader>
          <Bubble>
            <BubbleContent>
              Incoming turns keep the avatar rail at the inline start.
            </BubbleContent>
          </Bubble>
          <MessageFooter>09:14</MessageFooter>
        </MessageContent>
      </Message>
      <Message align="end">
        <MessageAvatar>
          <AvatarFallback>YOU</AvatarFallback>
        </MessageAvatar>
        <MessageContent>
          <MessageHeader>You</MessageHeader>
          <Bubble variant="primary">
            <BubbleContent>Own turns move it to the inline end.</BubbleContent>
          </Bubble>
          <MessageFooter>09:16</MessageFooter>
        </MessageContent>
      </Message>
    </MessageGroup>
  );
}

Without avatar

Leave out MessageAvatar for a compact thread with no avatar column.

System
A turn with no avatar rail still fills the row.
09:12
'use client';

import { Bubble, BubbleContent } from '@/components/nxds/components/bubble/bubble';
import {
  Message,
  MessageContent,
  MessageFooter,
  MessageGroup,
  MessageHeader,
} from '@/components/nxds/components/message/message';

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

export default function MessageWithoutAvatar() {
  return (
    <MessageGroup className={column}>
      <Message>
        <MessageContent>
          <MessageHeader>System</MessageHeader>
          <Bubble variant="outline">
            <BubbleContent>
              A turn with no avatar rail still fills the row.
            </BubbleContent>
          </Bubble>
          <MessageFooter>09:12</MessageFooter>
        </MessageContent>
      </Message>
    </MessageGroup>
  );
}

Typing indicator

Show typing in an always-mounted role="status" region next to the log, with the state also written in the footer.

AB
Ana Bianchi
Can you take a look at the failing run?
09:22
'use client';

import * as React from 'react';

import { AvatarFallback } from '@/components/nxds/components/avatar/avatar';
import { Bubble, BubbleContent } from '@/components/nxds/components/bubble/bubble';
import { Button } from '@/components/nxds/components/button/button';
import {
  Message,
  MessageAvatar,
  MessageContent,
  MessageFooter,
  MessageGroup,
  MessageHeader,
} from '@/components/nxds/components/message/message';
import { cn } from '@/components/nxds/lib/utils';

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

const typingDotClassName =
  'nx:size-1.5 nx:animate-bounce nx:rounded-full nx:bg-muted-foreground';

function TypingIndicatorExample() {
  const [typing, setTyping] = React.useState(false);

  return (
    <div className={cn(column, 'nx:flex nx:flex-col nx:gap-4')}>
      <MessageGroup announce>
        <Message>
          <MessageAvatar>
            <AvatarFallback>AB</AvatarFallback>
          </MessageAvatar>
          <MessageContent>
            <MessageHeader>Ana Bianchi</MessageHeader>
            <Bubble>
              <BubbleContent>
                Can you take a look at the failing run?
              </BubbleContent>
            </Bubble>
            <MessageFooter>09:22</MessageFooter>
          </MessageContent>
        </Message>
      </MessageGroup>
      <div role="status">
        {typing && (
          <Message>
            <MessageAvatar placeholder />
            <MessageContent>
              <Bubble>
                <BubbleContent>
                  <span
                    aria-hidden="true"
                    className="nx:flex nx:items-center nx:gap-1 nx:py-1"
                  >
                    <span className={typingDotClassName} />
                    <span
                      className={cn(
                        typingDotClassName,
                        'nx:[animation-delay:150ms]'
                      )}
                    />
                    <span
                      className={cn(
                        typingDotClassName,
                        'nx:[animation-delay:300ms]'
                      )}
                    />
                  </span>
                </BubbleContent>
              </Bubble>
              <MessageFooter>Ana Bianchi is typing&hellip;</MessageFooter>
            </MessageContent>
          </Message>
        )}
      </div>
      <Button
        variant="outline"
        className="nx:self-start"
        onClick={() => setTyping((current) => !current)}
      >
        {typing ? 'Stop typing' : 'Start typing'}
      </Button>
    </div>
  );
}

export default function MessageTypingIndicator() {
  return <TypingIndicatorExample />;
}

This example also needs: Button.

Grouped turns

Show the avatar once per run of turns, with placeholder avatars keeping later turns aligned.

AB
Ana Bianchi
Can you take a look at the failing run?
09:14
It started after the token refactor.
09:15
YOU
You
Looking now.
09:16
Found it — a renamed token.
09:17
'use client';

import { AvatarFallback } from '@/components/nxds/components/avatar/avatar';
import { Bubble, BubbleContent } from '@/components/nxds/components/bubble/bubble';
import {
  Message,
  MessageAvatar,
  MessageContent,
  MessageFooter,
  MessageGroup,
  MessageHeader,
} from '@/components/nxds/components/message/message';

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

export default function MessageGroupedTurns() {
  return (
    <MessageGroup className={column}>
      <Message>
        <MessageAvatar>
          <AvatarFallback>AB</AvatarFallback>
        </MessageAvatar>
        <MessageContent>
          <MessageHeader>Ana Bianchi</MessageHeader>
          <Bubble>
            <BubbleContent>
              Can you take a look at the failing run?
            </BubbleContent>
          </Bubble>
          <MessageFooter>09:14</MessageFooter>
        </MessageContent>
      </Message>
      <Message>
        <MessageAvatar placeholder />
        <MessageContent>
          <Bubble>
            <BubbleContent>It started after the token refactor.</BubbleContent>
          </Bubble>
          <MessageFooter>09:15</MessageFooter>
        </MessageContent>
      </Message>
      <Message align="end">
        <MessageAvatar>
          <AvatarFallback>YOU</AvatarFallback>
        </MessageAvatar>
        <MessageContent>
          <MessageHeader>You</MessageHeader>
          <Bubble variant="primary">
            <BubbleContent>Looking now.</BubbleContent>
          </Bubble>
          <MessageFooter>09:16</MessageFooter>
        </MessageContent>
      </Message>
      <Message align="end">
        <MessageAvatar placeholder />
        <MessageContent>
          <Bubble variant="primary">
            <BubbleContent>Found it &mdash; a renamed token.</BubbleContent>
          </Bubble>
          <MessageFooter>09:17</MessageFooter>
        </MessageContent>
      </Message>
    </MessageGroup>
  );
}

With actions

Put copy and retry icon buttons, each with an aria-label and a tooltip, in a turn's footer.

AI
The audit passes once the four renamed tokens are rerouted.
09:18
YOU
Rerun the contrast audit on main.
09:20 · Failed to send
'use client';

import { ArrowClockwiseIcon, CopyIcon } from '@phosphor-icons/react/ssr';

import { AvatarFallback } from '@/components/nxds/components/avatar/avatar';
import { Bubble, BubbleContent } from '@/components/nxds/components/bubble/bubble';
import { Button } from '@/components/nxds/components/button/button';
import {
  Message,
  MessageAvatar,
  MessageContent,
  MessageFooter,
  MessageGroup,
} from '@/components/nxds/components/message/message';
import {
  Tooltip,
  TooltipContent,
  TooltipProvider,
  TooltipTrigger,
} from '@/components/nxds/components/tooltip/tooltip';

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

export default function MessageWithActions() {
  return (
    <TooltipProvider delayDuration={0}>
      <MessageGroup className={column}>
        <Message>
          <MessageAvatar>
            <AvatarFallback>AI</AvatarFallback>
          </MessageAvatar>
          <MessageContent>
            <Bubble>
              <BubbleContent>
                The audit passes once the four renamed tokens are rerouted.
              </BubbleContent>
            </Bubble>
            <MessageFooter>
              09:18
              <Tooltip>
                <TooltipTrigger asChild>
                  <Button
                    size="icon-sm"
                    variant="ghost"
                    aria-label="Copy reply"
                  >
                    <CopyIcon />
                  </Button>
                </TooltipTrigger>
                <TooltipContent>Copy</TooltipContent>
              </Tooltip>
              <Tooltip>
                <TooltipTrigger asChild>
                  <Button
                    size="icon-sm"
                    variant="ghost"
                    aria-label="Regenerate reply"
                  >
                    <ArrowClockwiseIcon />
                  </Button>
                </TooltipTrigger>
                <TooltipContent>Regenerate</TooltipContent>
              </Tooltip>
            </MessageFooter>
          </MessageContent>
        </Message>
        <Message align="end">
          <MessageAvatar>
            <AvatarFallback>YOU</AvatarFallback>
          </MessageAvatar>
          <MessageContent>
            <Bubble variant="destructive">
              <BubbleContent>Rerun the contrast audit on main.</BubbleContent>
            </Bubble>
            <MessageFooter>
              09:20 &middot;{' '}
              <span className="nx:text-error-subtle-foreground">
                Failed to send
              </span>
              <Tooltip>
                <TooltipTrigger asChild>
                  <Button
                    size="icon-sm"
                    variant="ghost"
                    aria-label="Copy message"
                  >
                    <CopyIcon />
                  </Button>
                </TooltipTrigger>
                <TooltipContent>Copy</TooltipContent>
              </Tooltip>
              <Tooltip>
                <TooltipTrigger asChild>
                  <Button
                    size="icon-sm"
                    variant="ghost"
                    aria-label="Retry sending"
                  >
                    <ArrowClockwiseIcon />
                  </Button>
                </TooltipTrigger>
                <TooltipContent>Retry</TooltipContent>
              </Tooltip>
            </MessageFooter>
          </MessageContent>
        </Message>
      </MessageGroup>
    </TooltipProvider>
  );
}

This example also needs: Button, Tooltip.

With attachment

Use an Attachment as the body of a turn to share a file.

AB
Ana Bianchi
sales-dashboard.pdf

2.4 MB

09:14
YOU
contrast-audit.log

18 KB

Read 09:16
'use client';

import { FileTextIcon } from '@phosphor-icons/react/ssr';

import {
  Attachment,
  AttachmentContent,
  AttachmentDescription,
  AttachmentMedia,
  AttachmentTitle,
} from '@/components/nxds/components/attachment/attachment';
import { AvatarFallback } from '@/components/nxds/components/avatar/avatar';
import {
  Message,
  MessageAvatar,
  MessageContent,
  MessageFooter,
  MessageGroup,
  MessageHeader,
} from '@/components/nxds/components/message/message';

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

export default function MessageWithAttachment() {
  return (
    <MessageGroup className={column}>
      <Message>
        <MessageAvatar>
          <AvatarFallback>AB</AvatarFallback>
        </MessageAvatar>
        <MessageContent>
          <MessageHeader>Ana Bianchi</MessageHeader>
          <Attachment>
            <AttachmentMedia>
              <FileTextIcon />
            </AttachmentMedia>
            <AttachmentContent>
              <AttachmentTitle>sales-dashboard.pdf</AttachmentTitle>
              <AttachmentDescription>2.4 MB</AttachmentDescription>
            </AttachmentContent>
          </Attachment>
          <MessageFooter>09:14</MessageFooter>
        </MessageContent>
      </Message>
      <Message align="end">
        <MessageAvatar>
          <AvatarFallback>YOU</AvatarFallback>
        </MessageAvatar>
        <MessageContent>
          <Attachment>
            <AttachmentMedia>
              <FileTextIcon />
            </AttachmentMedia>
            <AttachmentContent>
              <AttachmentTitle>contrast-audit.log</AttachmentTitle>
              <AttachmentDescription>18 KB</AttachmentDescription>
            </AttachmentContent>
          </Attachment>
          <MessageFooter>Read 09:16</MessageFooter>
        </MessageContent>
      </Message>
    </MessageGroup>
  );
}

This example also needs: Attachment.

npm install @phosphor-icons/react@^2.1.10

Props

Message

Message props
PropTypeDefaultDescription
align"end" | "start""start"

Side of the conversation the turn sits on, mirrored under dir="rtl". MessageContent aligns its children to match, so a Bubble needs no align.

MessageAvatar

MessageAvatar props
PropTypeDefaultDescription
placeholderbooleanfalse

Keeps the avatar's space but hides it. Use it on follow-up turns from the same speaker so their bodies line up with the first turn.

ringbooleanfalse

Draws a non-interactive emphasis ring, such as for a selected person. Put focus rings on the interactive wrapper.

shape"rounded" | "circle" | null"circle"No description
size"sm" | "lg" | "2xs" | "xs" | "md" | "xl" | "2xl" | "3xl" | "4xl" | null"sm"No description

MessageContent

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

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

MessageGroup

MessageGroup props
PropTypeDefaultDescription
announcebooleanfalse

Announces new turns in a live chat. Leave it off for static, paged or virtualised transcripts, and set aria-busy on a turn while its reply streams.

MessageHeader

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

Message lays out one conversation turn, with an avatar, a header, a body and a footer. Use it for chat and assistant threads, with Bubble or Attachment as the body. Message holds no chat state, so your app owns the messages and MessageScroller handles scrolling.

<script setup lang="ts">
import { AvatarFallback } from '@/components/avatar';
import { Bubble, BubbleContent } from '@/components/bubble';
import {
  Message,
  MessageAvatar,
  MessageContent,
  MessageFooter,
  MessageGroup,
  MessageHeader,
} from '@/components/message';
</script>

<template>
  <MessageGroup class="nx:w-full nx:max-w-lg">
    <Message>
      <MessageAvatar><AvatarFallback>AB</AvatarFallback></MessageAvatar>
      <MessageContent>
        <MessageHeader>Ana Bianchi</MessageHeader>
        <Bubble>
          <BubbleContent>Can you take a look at the failing run?</BubbleContent>
        </Bubble>
        <MessageFooter>09:14</MessageFooter>
      </MessageContent>
    </Message>
  </MessageGroup>
</template>

This example also needs: Avatar, Bubble.

Installation

  1. Install these components first:

    Avatar.

  2. Copy and paste the following code into your project.

    components/message/Message.vue
    <script setup lang="ts">
    import type { HTMLAttributes } from 'vue';
    
    import { cn } from '../../lib/utils';
    
    export interface MessageProps {
      /**
       * Side of the conversation the turn sits on, mirrored under `dir="rtl"`.
       * `MessageContent` aligns its children to match, so a `Bubble` needs no
       * `align`.
       */
      align?: 'start' | 'end';
      class?: HTMLAttributes['class'];
    }
    
    const props = withDefaults(defineProps<MessageProps>(), {
      align: 'start',
    });
    </script>
    
    <template>
      <div
        data-slot="message"
        :data-align="align"
        :class="
          cn(
            'nx:group/message nx:flex nx:w-full nx:min-w-0 nx:gap-3 nx:data-[align=end]:flex-row-reverse',
            props.class
          )
        "
      >
        <slot />
      </div>
    </template>
    
    components/message/MessageAvatar.vue
    <script setup lang="ts">
    import { reactiveOmit } from '@vueuse/core';
    import type { AvatarRootProps } from 'reka-ui';
    import type { HTMLAttributes } from 'vue';
    
    import { cn } from '../../lib/utils';
    import { Avatar, type AvatarVariants } from '../avatar';
    
    export interface MessageAvatarProps extends AvatarRootProps {
      size?: AvatarVariants['size'];
      shape?: AvatarVariants['shape'];
      /**
       * Draws a non-interactive emphasis ring, such as for a selected person. Put focus rings on the interactive wrapper.
       */
      ring?: boolean;
      /**
       * Keeps the avatar's space but hides it. Use it on follow-up turns from the
       * same speaker so their bodies line up with the first turn.
       */
      placeholder?: boolean;
      class?: HTMLAttributes['class'];
    }
    
    const props = withDefaults(defineProps<MessageAvatarProps>(), {
      size: 'sm',
      ring: false,
      placeholder: false,
    });
    
    const delegated = reactiveOmit(props, 'class', 'placeholder');
    </script>
    
    <template>
      <Avatar
        v-bind="delegated"
        data-slot="message-avatar"
        :data-placeholder="placeholder || undefined"
        :class="cn('nx:self-start', placeholder && 'nx:invisible', props.class)"
      >
        <slot />
      </Avatar>
    </template>
    
    components/message/MessageContent.vue
    <script setup lang="ts">
    import type { HTMLAttributes } from 'vue';
    
    import { cn } from '../../lib/utils';
    
    export interface MessageContentProps {
      class?: HTMLAttributes['class'];
    }
    
    const props = defineProps<MessageContentProps>();
    </script>
    
    <template>
      <div
        data-slot="message-content"
        :class="
          cn(
            'nx:flex nx:w-full nx:min-w-0 nx:flex-col nx:gap-1.5 nx:wrap-break-word nx:group-data-[align=end]/message:items-end',
            props.class
          )
        "
      >
        <slot />
      </div>
    </template>
    
    components/message/MessageFooter.vue
    <script setup lang="ts">
    import type { HTMLAttributes } from 'vue';
    
    import { cn } from '../../lib/utils';
    
    import { messageMetaClassName } from '.';
    
    export interface MessageFooterProps {
      class?: HTMLAttributes['class'];
    }
    
    const props = defineProps<MessageFooterProps>();
    </script>
    
    <template>
      <div
        data-slot="message-footer"
        :class="cn(messageMetaClassName, props.class)"
      >
        <slot />
      </div>
    </template>
    
    components/message/MessageGroup.vue
    <script setup lang="ts">
    import type { HTMLAttributes } from 'vue';
    
    import { cn } from '../../lib/utils';
    
    export interface MessageGroupProps {
      /**
       * Announces new turns in a live chat. Leave it off for static, paged or
       * virtualised transcripts, and set `aria-busy` on a turn while its reply
       * streams.
       */
      announce?: boolean;
      class?: HTMLAttributes['class'];
    }
    
    const props = withDefaults(defineProps<MessageGroupProps>(), {
      announce: false,
    });
    </script>
    
    <template>
      <div
        data-slot="message-group"
        :role="announce ? 'log' : undefined"
        :class="
          cn('nx:flex nx:w-full nx:min-w-0 nx:flex-col nx:gap-4', props.class)
        "
      >
        <slot />
      </div>
    </template>
    
    components/message/MessageHeader.vue
    <script setup lang="ts">
    import type { HTMLAttributes } from 'vue';
    
    import { cn } from '../../lib/utils';
    
    import { messageMetaClassName } from '.';
    
    export interface MessageHeaderProps {
      class?: HTMLAttributes['class'];
    }
    
    const props = defineProps<MessageHeaderProps>();
    </script>
    
    <template>
      <div
        data-slot="message-header"
        :class="cn(messageMetaClassName, props.class)"
      >
        <slot />
      </div>
    </template>
    
    components/message/index.ts
    export { default as Message, type MessageProps } from './Message.vue';
    export {
      default as MessageAvatar,
      type MessageAvatarProps,
    } from './MessageAvatar.vue';
    export {
      default as MessageContent,
      type MessageContentProps,
    } from './MessageContent.vue';
    export {
      default as MessageFooter,
      type MessageFooterProps,
    } from './MessageFooter.vue';
    export {
      default as MessageGroup,
      type MessageGroupProps,
    } from './MessageGroup.vue';
    export {
      default as MessageHeader,
      type MessageHeaderProps,
    } from './MessageHeader.vue';
    
    export const messageMetaClassName =
      'nx:flex nx:max-w-full nx:items-center nx:gap-2 nx:px-4 nx:wrap-anywhere nx:typography-label-small nx:text-muted-foreground';
    
  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 { Message, MessageAvatar, MessageContent, MessageFooter, MessageGroup, MessageHeader } from '@/components/message';

Examples

Alignment

Set align="end" on your own turns to place them on the opposite side.

<script setup lang="ts">
import { AvatarFallback } from '@/components/avatar';
import { Bubble, BubbleContent } from '@/components/bubble';
import {
  Message,
  MessageAvatar,
  MessageContent,
  MessageFooter,
  MessageGroup,
  MessageHeader,
} from '@/components/message';
</script>

<template>
  <MessageGroup class="nx:w-full nx:max-w-lg">
    <Message align="start">
      <MessageAvatar><AvatarFallback>AB</AvatarFallback></MessageAvatar>
      <MessageContent>
        <MessageHeader>Ana Bianchi</MessageHeader>
        <Bubble>
          <BubbleContent
            >Incoming turns keep the avatar rail at the inline
            start.</BubbleContent
          >
        </Bubble>
        <MessageFooter>09:14</MessageFooter>
      </MessageContent>
    </Message>
    <Message align="end">
      <MessageAvatar><AvatarFallback>YOU</AvatarFallback></MessageAvatar>
      <MessageContent>
        <MessageHeader>You</MessageHeader>
        <Bubble variant="primary">
          <BubbleContent>Own turns move it to the inline end.</BubbleContent>
        </Bubble>
        <MessageFooter>09:16</MessageFooter>
      </MessageContent>
    </Message>
  </MessageGroup>
</template>

Without avatar

Leave out MessageAvatar for a compact thread with no avatar column.

<script setup lang="ts">
import { Bubble, BubbleContent } from '@/components/bubble';
import {
  Message,
  MessageContent,
  MessageFooter,
  MessageGroup,
  MessageHeader,
} from '@/components/message';
</script>

<template>
  <MessageGroup class="nx:w-full nx:max-w-lg">
    <Message>
      <MessageContent>
        <MessageHeader>System</MessageHeader>
        <Bubble variant="outline">
          <BubbleContent
            >A turn with no avatar rail still fills the row.</BubbleContent
          >
        </Bubble>
        <MessageFooter>09:12</MessageFooter>
      </MessageContent>
    </Message>
  </MessageGroup>
</template>

Typing indicator

Show typing in an always-mounted role="status" region next to the log, with the state also written in the footer.

<script setup lang="ts">
import { ref } from 'vue';

import { AvatarFallback } from '@/components/avatar';
import { Bubble, BubbleContent } from '@/components/bubble';
import { Button } from '@/components/button';
import {
  Message,
  MessageAvatar,
  MessageContent,
  MessageFooter,
  MessageGroup,
  MessageHeader,
} from '@/components/message';

const typing = ref(false);
</script>

<template>
  <div class="nx:w-full nx:max-w-lg nx:flex nx:flex-col nx:gap-4">
    <MessageGroup announce>
      <Message>
        <MessageAvatar><AvatarFallback>AB</AvatarFallback></MessageAvatar>
        <MessageContent>
          <MessageHeader>Ana Bianchi</MessageHeader>
          <Bubble>
            <BubbleContent
              >Can you take a look at the failing run?</BubbleContent
            >
          </Bubble>
          <MessageFooter>09:22</MessageFooter>
        </MessageContent>
      </Message>
    </MessageGroup>
    <div role="status">
      <Message v-if="typing">
        <MessageAvatar placeholder />
        <MessageContent>
          <Bubble>
            <BubbleContent>
              <span
                aria-hidden="true"
                class="nx:flex nx:items-center nx:gap-1 nx:py-1"
              >
                <span
                  class="nx:size-1.5 nx:animate-bounce nx:rounded-full nx:bg-muted-foreground"
                />
                <span
                  class="nx:size-1.5 nx:animate-bounce nx:rounded-full nx:bg-muted-foreground nx:[animation-delay:150ms]"
                />
                <span
                  class="nx:size-1.5 nx:animate-bounce nx:rounded-full nx:bg-muted-foreground nx:[animation-delay:300ms]"
                />
              </span>
            </BubbleContent>
          </Bubble>
          <MessageFooter>Ana Bianchi is typing&hellip;</MessageFooter>
        </MessageContent>
      </Message>
    </div>
    <Button variant="outline" class="nx:self-start" @click="typing = !typing">{{
      typing ? 'Stop typing' : 'Start typing'
    }}</Button>
  </div>
</template>

This example also needs: Button.

Grouped turns

Show the avatar once per run of turns, with placeholder avatars keeping later turns aligned.

<script setup lang="ts">
import { AvatarFallback } from '@/components/avatar';
import { Bubble, BubbleContent } from '@/components/bubble';
import {
  Message,
  MessageAvatar,
  MessageContent,
  MessageFooter,
  MessageGroup,
  MessageHeader,
} from '@/components/message';
</script>

<template>
  <MessageGroup class="nx:w-full nx:max-w-lg">
    <Message>
      <MessageAvatar><AvatarFallback>AB</AvatarFallback></MessageAvatar>
      <MessageContent>
        <MessageHeader>Ana Bianchi</MessageHeader>
        <Bubble>
          <BubbleContent>Can you take a look at the failing run?</BubbleContent>
        </Bubble>
        <MessageFooter>09:14</MessageFooter>
      </MessageContent>
    </Message>
    <Message>
      <MessageAvatar placeholder />
      <MessageContent>
        <Bubble>
          <BubbleContent>It started after the token refactor.</BubbleContent>
        </Bubble>
        <MessageFooter>09:15</MessageFooter>
      </MessageContent>
    </Message>
    <Message align="end">
      <MessageAvatar><AvatarFallback>YOU</AvatarFallback></MessageAvatar>
      <MessageContent>
        <MessageHeader>You</MessageHeader>
        <Bubble variant="primary">
          <BubbleContent>Looking now.</BubbleContent>
        </Bubble>
        <MessageFooter>09:16</MessageFooter>
      </MessageContent>
    </Message>
    <Message align="end">
      <MessageAvatar placeholder />
      <MessageContent>
        <Bubble variant="primary">
          <BubbleContent>Found it &mdash; a renamed token.</BubbleContent>
        </Bubble>
        <MessageFooter>09:17</MessageFooter>
      </MessageContent>
    </Message>
  </MessageGroup>
</template>

With actions

Put copy and retry icon buttons, each with an aria-label and a tooltip, in a turn's footer.

<script setup lang="ts">
import { PhArrowClockwise, PhCopy } from '@phosphor-icons/vue';

import { AvatarFallback } from '@/components/avatar';
import { Bubble, BubbleContent } from '@/components/bubble';
import { Button } from '@/components/button';
import {
  Message,
  MessageAvatar,
  MessageContent,
  MessageFooter,
  MessageGroup,
} from '@/components/message';
import {
  Tooltip,
  TooltipContent,
  TooltipProvider,
  TooltipTrigger,
} from '@/components/tooltip';
</script>

<template>
  <TooltipProvider :delay-duration="0">
    <MessageGroup class="nx:w-full nx:max-w-lg">
      <Message>
        <MessageAvatar><AvatarFallback>AI</AvatarFallback></MessageAvatar>
        <MessageContent>
          <Bubble>
            <BubbleContent
              >The audit passes once the four renamed tokens are
              rerouted.</BubbleContent
            >
          </Bubble>
          <MessageFooter
            >09:18<Tooltip>
              <TooltipTrigger as-child>
                <Button size="icon-sm" variant="ghost" aria-label="Copy reply"
                  ><PhCopy
                /></Button>
              </TooltipTrigger>
              <TooltipContent>Copy</TooltipContent>
            </Tooltip>
            <Tooltip>
              <TooltipTrigger as-child>
                <Button
                  size="icon-sm"
                  variant="ghost"
                  aria-label="Regenerate reply"
                  ><PhArrowClockwise
                /></Button>
              </TooltipTrigger>
              <TooltipContent>Regenerate</TooltipContent>
            </Tooltip>
          </MessageFooter>
        </MessageContent>
      </Message>
      <Message align="end">
        <MessageAvatar><AvatarFallback>YOU</AvatarFallback></MessageAvatar>
        <MessageContent>
          <Bubble variant="destructive">
            <BubbleContent>Rerun the contrast audit on main.</BubbleContent>
          </Bubble>
          <MessageFooter
            >09:20 &middot;
            <span class="nx:text-error-subtle-foreground">Failed to send</span>
            <Tooltip>
              <TooltipTrigger as-child>
                <Button size="icon-sm" variant="ghost" aria-label="Copy message"
                  ><PhCopy
                /></Button>
              </TooltipTrigger>
              <TooltipContent>Copy</TooltipContent>
            </Tooltip>
            <Tooltip>
              <TooltipTrigger as-child>
                <Button
                  size="icon-sm"
                  variant="ghost"
                  aria-label="Retry sending"
                  ><PhArrowClockwise
                /></Button>
              </TooltipTrigger>
              <TooltipContent>Retry</TooltipContent>
            </Tooltip>
          </MessageFooter>
        </MessageContent>
      </Message>
    </MessageGroup>
  </TooltipProvider>
</template>

This example also needs: Button, Tooltip.

npm install @phosphor-icons/vue@^2.2.1

With attachment

Use an Attachment as the body of a turn to share a file.

<script setup lang="ts">
import { PhFileText } from '@phosphor-icons/vue';

import {
  Attachment,
  AttachmentContent,
  AttachmentDescription,
  AttachmentMedia,
  AttachmentTitle,
} from '@/components/attachment';
import { AvatarFallback } from '@/components/avatar';
import {
  Message,
  MessageAvatar,
  MessageContent,
  MessageFooter,
  MessageGroup,
  MessageHeader,
} from '@/components/message';
</script>

<template>
  <MessageGroup class="nx:w-full nx:max-w-lg">
    <Message>
      <MessageAvatar><AvatarFallback>AB</AvatarFallback></MessageAvatar>
      <MessageContent>
        <MessageHeader>Ana Bianchi</MessageHeader>
        <Attachment>
          <AttachmentMedia><PhFileText /></AttachmentMedia>
          <AttachmentContent>
            <AttachmentTitle>sales-dashboard.pdf</AttachmentTitle>
            <AttachmentDescription>2.4 MB</AttachmentDescription>
          </AttachmentContent>
        </Attachment>
        <MessageFooter>09:14</MessageFooter>
      </MessageContent>
    </Message>
    <Message align="end">
      <MessageAvatar><AvatarFallback>YOU</AvatarFallback></MessageAvatar>
      <MessageContent>
        <Attachment>
          <AttachmentMedia><PhFileText /></AttachmentMedia>
          <AttachmentContent>
            <AttachmentTitle>contrast-audit.log</AttachmentTitle>
            <AttachmentDescription>18 KB</AttachmentDescription>
          </AttachmentContent>
        </Attachment>
        <MessageFooter>Read 09:16</MessageFooter>
      </MessageContent>
    </Message>
  </MessageGroup>
</template>

This example also needs: Attachment.

npm install @phosphor-icons/vue@^2.2.1

Props

Message

Message props
PropTypeDefaultDescription
align"start" | "end""start"

Side of the conversation the turn sits on, mirrored under dir="rtl". MessageContent aligns its children to match, so a Bubble needs no align.

MessageAvatar

MessageAvatar props
PropTypeDefaultDescription
placeholderbooleanfalse

Keeps the avatar's space but hides it. Use it on follow-up turns from the same speaker so their bodies line up with the first turn.

ringbooleanfalse

Draws a non-interactive emphasis ring, such as for a selected person. Put focus rings on the interactive wrapper.

shape"circle" | "rounded" | null"circle"No description
size"sm" | "lg" | "2xs" | "xs" | "md" | "xl" | "2xl" | "3xl" | "4xl" | null"sm"No description

MessageContent

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

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

MessageGroup

MessageGroup props
PropTypeDefaultDescription
announcebooleanfalse

Announces new turns in a live chat. Leave it off for static, paged or virtualised transcripts, and set aria-busy on a turn while its reply streams.

MessageHeader

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