Message

free

Message lays out one row of a conversation with an optional avatar and a role-aware content column; compound parts add reactions, a hover-reveal action row, a typing indicator, tight grouping, and open marker rows for status notes or separators.

Preview
Dark
Assistant
Here is a quick summary of the latest deployment. Three services updated cleanly and no errors were reported.
10:14 AM
You
Thanks, can you also check the staging environment?
10:15 AM
import Message from '@/components/composites/Message';
import Avatar from '@/components/ui/Avatar';

export default function UsageDemo() {
  return (
    <div className="flex flex-col gap-6">
      <Message
        align="start"
        avatar={
          <Avatar shape="round"
            size={32}
            src="/img/avatars/thumb-1.jpg"
            alt="Assistant"
          />
        }
      >
        <Message.Content variant="subtle">
          Here is a quick summary of the latest deployment. Three
          services updated cleanly and no errors were reported.
        </Message.Content>
        <Message.Meta>10:14 AM</Message.Meta>
      </Message>
      <Message
        align="end"
        avatar={
          <Avatar shape="round" size={32} src="/img/avatars/thumb-2.jpg" alt="You" />
        }
      >
        <Message.Content variant="solid">
          Thanks, can you also check the staging environment?
        </Message.Content>
        <Message.Meta>10:15 AM</Message.Meta>
      </Message>
    </div>
  );
}

Installation

Add this component with the NateUI CLI.

npx nateui@latest add Message

Examples

Variant

Preview
Dark
Solid gets a strong primary fill.
Subtle gets a soft secondary fill.
Default gets a bordered neutral surface.
Ghost sits directly on the page with no surface.
import Message from '@/components/composites/Message';

export default function VariantDemo() {
  return (
    <div className="flex flex-col gap-4 w-full">
      <Message align="start">
        <Message.Content variant="solid">
          Solid gets a strong primary fill.
        </Message.Content>
      </Message>
      <Message align="start">
        <Message.Content variant="subtle">
          Subtle gets a soft secondary fill.
        </Message.Content>
      </Message>
      <Message align="start">
        <Message.Content variant="default">
          Default gets a bordered neutral surface.
        </Message.Content>
      </Message>
      <Message align="start">
        <Message.Content variant="ghost">
          Ghost sits directly on the page with no surface.
        </Message.Content>
      </Message>
    </div>
  );
}

Alignment

Preview
Dark
Start-aligned row.
End-aligned row.
import Message from '@/components/composites/Message';

export default function AlignmentDemo() {
  return (
    <div className="flex flex-col gap-4 w-full">
      <Message align="start">
        <Message.Content variant="solid">Start-aligned row.</Message.Content>
      </Message>
      <Message align="end">
        <Message.Content variant="solid">End-aligned row.</Message.Content>
      </Message>
    </div>
  );
}

Actions

Message.Actions lays out a row of caller-supplied controls. revealOnHover hides the row until the message is hovered or a control inside it receives focus, so keyboard users can still reach it.

Preview
Dark
Hover this message, or tab into it, to reveal the action row.
import { useState } from 'react';
import Message from '@/components/composites/Message';
import Button from '@/components/ui/Button';
import Tooltip from '@/components/ui/Tooltip';
import { PiCopy, PiThumbsDown, PiThumbsUp } from 'react-icons/pi'

export default function ActionsDemo() {
  const [copied, setCopied] = useState(false);

  return (
    <Message align="start">
      <Message.Content variant="ghost">
        Hover this message, or tab into it, to reveal the action row.
      </Message.Content>
      <Message.Actions revealOnHover>
        <Tooltip title={copied ? 'Copied' : 'Copy'}>
          <Button
            type="button"
            variant="ghost"
            shape="circle"
            size="sm"
            icon={<PiCopy />}
            aria-label="Copy message"
            onClick={() => {
              setCopied(true);
              setTimeout(() => setCopied(false), 1500);
            }}
          />
        </Tooltip>
        <Tooltip title="Good response">
          <Button
            type="button"
            variant="ghost"
            shape="circle"
            size="sm"
            icon={<PiThumbsUp />}
            aria-label="Good response"
          />
        </Tooltip>
        <Tooltip title="Bad response">
          <Button
            type="button"
            variant="ghost"
            shape="circle"
            size="sm"
            icon={<PiThumbsDown />}
            aria-label="Bad response"
          />
        </Tooltip>
      </Message.Actions>
    </Message>
  );
}

Reactions

Message.Reactions overlaps the bottom corner of Message.Content — render it as a child of Message.Content, not a sibling. align picks which corner it hangs off, matching the row's own align. Leave extra gap between stacked messages so the overlap has room.

Preview
Dark
Deploying to prod real quick.
It's always a one-line change.
Alright, let me take a look.
import Message from '@/components/composites/Message';

export default function ReactionsDemo() {
  return (
    <div className="flex flex-col gap-8 w-full">
      <Message align="start">
        <Message.Content variant="subtle">
          Deploying to prod real quick.
        </Message.Content>
      </Message>
      <Message align="start">
        <Message.Content variant="subtle">
          It&apos;s always a one-line change.
          <Message.Reactions align="start" aria-label="Reactions: thumbs up">
            <span>👍</span>
          </Message.Reactions>
        </Message.Content>
      </Message>
      <Message align="end">
        <Message.Content variant="solid">
          Alright, let me take a look.
          <Message.Reactions align="end" aria-label="Reactions: eyes, rocket">
            <span>👀</span>
            <span>🚀</span>
          </Message.Reactions>
        </Message.Content>
      </Message>
    </div>
  );
}

Type Indicator

Preview
Dark
You
Thanks, can you also check the staging environment?
10:15 AM
Assistant
Assistant is typing
import Message from '@/components/composites/Message';
import Avatar from '@/components/ui/Avatar';

export default function TypeIndicatorDemo() {
  return (
    <div className="flex flex-col gap-6 w-full">
      <Message
        align="end"
        avatar={
          <Avatar shape="round" size={32} src="/img/avatars/thumb-2.jpg" alt="You" />
        }
      >
        <Message.Content variant="solid">
          Thanks, can you also check the staging environment?
        </Message.Content>
        <Message.Meta>10:15 AM</Message.Meta>
      </Message>
      <Message
        align="start"
        avatar={
          <Avatar shape="round"
            size={32}
            src="/img/avatars/thumb-1.jpg"
            alt="Assistant"
          />
        }
      >
        <Message.Content variant="ghost">
          <Message.TypeIndicator />
        </Message.Content>
      </Message>
    </div>
  );
}

Group

Message.Group stacks consecutive Message rows from the same sender with a tighter vertical rhythm than separate turns get.

Preview
Dark
Found the issue — the retry loop was swallowing the timeout error.
Assistant
Pushed a fix, re-running the job now.
10:16 AM
import Message from '@/components/composites/Message';
import Avatar from '@/components/ui/Avatar';

export default function GroupDemo() {
  return (
    <Message.Group>
      <Message align="start" avatar={<div className="size-8" />}>
        <Message.Content variant="subtle">
          Found the issue — the retry loop was swallowing the timeout error.
        </Message.Content>
      </Message>
      <Message
        align="start"
        avatar={
          <Avatar shape="round"
            size={32}
            src="/img/avatars/thumb-1.jpg"
            alt="Assistant"
          />
        }
      >
        <Message.Content variant="subtle">
          Pushed a fix, re-running the job now.
        </Message.Content>
        <Message.Meta>10:16 AM</Message.Meta>
      </Message>
    </Message.Group>
  );
}

Marker

Message.Marker is an open row slot for date separators, system events, or live status notes. Pass separator to add flanking hairlines, and add any live-region role yourself when the marker represents changing status.

Preview
Dark
Jun 12, 2026
Assistant
Found the issue — the retry loop was swallowing the timeout error.
4:32 PM
Assistant
Pushed a fix, re-running the job now.
4:35 PM
Today
Assistant
Here is a quick summary of the latest deployment. Three services updated cleanly and no errors were reported.
10:14 AM
You
Thanks, can you also check the staging environment?
10:15 AM
import Message from '@/components/composites/Message';
import Avatar from '@/components/ui/Avatar';
import Tag from '@/components/ui/Tag';

export default function MarkerDemo() {
  return (
    <div className="flex flex-col gap-6 w-full">
      <Message.Marker>
        <Tag className="bg-card">Jun 12, 2026</Tag>
      </Message.Marker>
      <Message
        align="start"
        avatar={
          <Avatar shape="round"
            size={32}
            src="/img/avatars/thumb-1.jpg"
            alt="Assistant"
          />
        }
      >
        <Message.Content variant="subtle">
          Found the issue — the retry loop was swallowing the timeout error.
        </Message.Content>
        <Message.Meta>4:32 PM</Message.Meta>
      </Message>
      <Message
        align="start"
        avatar={
          <Avatar shape="round"
            size={32}
            src="/img/avatars/thumb-1.jpg"
            alt="Assistant"
          />
        }
      >
        <Message.Content variant="subtle">
          Pushed a fix, re-running the job now.
        </Message.Content>
        <Message.Meta>4:35 PM</Message.Meta>
      </Message>
      <Message.Marker separator>Today</Message.Marker>
      <Message
        align="start"
        avatar={
          <Avatar shape="round"
            size={32}
            src="/img/avatars/thumb-1.jpg"
            alt="Assistant"
          />
        }
      >
        <Message.Content variant="subtle">
          Here is a quick summary of the latest deployment. Three services
          updated cleanly and no errors were reported.
        </Message.Content>
        <Message.Meta>10:14 AM</Message.Meta>
      </Message>
      <Message
        align="end"
        avatar={
          <Avatar shape="round" size={32} src="/img/avatars/thumb-2.jpg" alt="You" />
        }
      >
        <Message.Content variant="solid">
          Thanks, can you also check the staging environment?
        </Message.Content>
        <Message.Meta>10:15 AM</Message.Meta>
      </Message>
    </div>
  );
}

API

Message

PropDescriptionTypeDefault
alignWhich side of the conversation the row belongs to.MessageAlign'start'
avatarOptional avatar node rendered at the row's outer edge and anchored to the lower edge of the direct Message.Content surface. Message.Meta and Message.Actions flow below without changing that anchor.ReactNode
childrenRow content, typically Message.Content and Message.Meta.ReactNode
...divPropsStandard <div> attributes forwarded to the row.ComponentProps<'div'>

Message.Content

PropDescriptionTypeDefault
variant'solid' for a strong primary fill, 'subtle' for a soft secondary fill, 'default' for a bordered neutral surface, 'ghost' for bare text with no surface. Matches Button's variant vocabulary.MessageContentVariant'default'
...divPropsStandard <div> attributes forwarded to the content block.ComponentProps<'div'>

Message.Meta

PropDescriptionTypeDefault
...divPropsStandard <div> attributes forwarded to the meta line. With an avatar, the meta line flows below the message surface without changing the avatar anchor.ComponentProps<'div'>

Message.Reactions

Overlaps the bottom (or top) corner of Message.Content — render it as a child of Message.Content, which is position: relative for exactly this. Defaults role="img" so a screen reader announces the whole row once from a single aria-label, rather than reading each emoji individually — pass a descriptive aria-label summarizing the reactions.

PropDescriptionTypeDefault
alignWhich corner of the content block the reactions hang off. Match the row's own align.MessageReactionsAlign'start'
sideWhich edge of the content block the reactions anchor to.MessageReactionsSide'bottom'
...divPropsStandard <div> attributes forwarded to the reactions row.ComponentProps<'div'>

Message.TypeIndicator

PropDescriptionTypeDefault
labelText announced to screen readers via a visually-hidden span.string'Assistant is typing'
...divPropsStandard <div> attributes forwarded to the root.ComponentProps<'div'>

Message.Group

Stacks consecutive Message rows from the same sender. Avatar-collapse (keeping text columns flush across the group) is a caller convention — pass an empty same-sized placeholder as avatar on every message but the last, not a prop on Message.Group itself. The final avatar anchors to the visible message surface; metadata remains below it.

PropDescriptionTypeDefault
childrenConsecutive Message rows from the same sender.ReactNode
...divPropsStandard <div> attributes forwarded to the group root.ComponentProps<'div'>

Message.Marker

PropDescriptionTypeDefault
separatorAdds a hairline on either side of the marker's children.booleanfalse
...divPropsStandard <div> attributes forwarded to the marker row.ComponentProps<'div'>

Message.Actions

PropDescriptionTypeDefault
revealOnHoverHides the action row until the message is hovered or focused within.booleanfalse
childrenAction controls, typically icon buttons.ReactNode
...divPropsStandard <div> attributes forwarded to the action row.ComponentProps<'div'>

Types

type MessageAlign = 'start' | 'end'
type MessageContentVariant = 'default' | 'subtle' | 'solid' | 'ghost'
type MessageReactionsAlign = 'start' | 'end'
type MessageReactionsSide = 'top' | 'bottom'
type MessageMarkerProps = ComponentProps<'div'> & { separator?: boolean }