Skip to content

Message Group

A grouped chat message with author, timestamp, reply preview, reactions and an ephemeral state.

A grouped chat message with author, timestamp, reply preview, reactions and an ephemeral state. Compose it from the exported registry parts so local content and behaviour stay in the application while the visual contract stays token-driven.

Root class newt-message-group
import { Avatar } from "@/registry/bases/newt/ui/avatar"
import {
  MessageGroup,
  MessageGroupAuthor,
  MessageGroupAvatar,
  MessageGroupBody,
  MessageGroupBotTag,
  MessageGroupContent,
  MessageGroupEphemeralAction,
  MessageGroupEphemeralNotice,
  MessageGroupHeader,
  MessageGroupReactions,
  MessageGroupReply,
  MessageGroupTime,
} from "@/registry/bases/newt/ui/message-group"
import { Reaction } from "@/registry/bases/newt/ui/reaction"
import {
  ReplyPreview,
  ReplyPreviewAuthor,
  ReplyPreviewText,
} from "@/registry/bases/newt/ui/reply-preview"
import {
  StatusDot,
  StatusIndicator,
} from "@/registry/bases/newt/ui/status-indicator"

export default function MessageGroupDemo() {
  return (
    <div className="flex w-full max-w-md flex-col gap-2">
      <MessageGroup withReply>
        <MessageGroupReply>
          <ReplyPreview>
            <ReplyPreviewAuthor>@someone</ReplyPreviewAuthor>
            <ReplyPreviewText>original message text</ReplyPreviewText>
          </ReplyPreview>
        </MessageGroupReply>
        <MessageGroupAvatar>
          <StatusIndicator>
            <Avatar>U</Avatar>
            <StatusDot status="online" />
          </StatusIndicator>
        </MessageGroupAvatar>
        <MessageGroupBody>
          <MessageGroupHeader>
            <MessageGroupAuthor>username</MessageGroupAuthor>
            <MessageGroupBotTag verified />
            <MessageGroupTime dateTime="2026-09-04T14:23:00.000Z">
              Today at 14:23
            </MessageGroupTime>
          </MessageGroupHeader>
          <MessageGroupContent>Message content goes here.</MessageGroupContent>
          <MessageGroupReactions>
            <Reaction emoji="👍" count={1} />
          </MessageGroupReactions>
        </MessageGroupBody>
      </MessageGroup>
      <MessageGroup ephemeral>
        <MessageGroupAvatar>
          <Avatar>B</Avatar>
        </MessageGroupAvatar>
        <MessageGroupBody>
          <MessageGroupHeader>
            <MessageGroupAuthor>botname</MessageGroupAuthor>
            <MessageGroupBotTag>APP</MessageGroupBotTag>
          </MessageGroupHeader>
          <MessageGroupContent>Command ran successfully.</MessageGroupContent>
          <MessageGroupEphemeralNotice>
            Only you can see this •
            <MessageGroupEphemeralAction>
              Dismiss message
            </MessageGroupEphemeralAction>
          </MessageGroupEphemeralNotice>
        </MessageGroupBody>
      </MessageGroup>
    </div>
  )
}
<script setup lang="ts">
import { Avatar } from "~~/registry/bases/newt/ui/avatar"
import {
  MessageGroup,
  MessageGroupAuthor,
  MessageGroupAvatar,
  MessageGroupBody,
  MessageGroupBotTag,
  MessageGroupContent,
  MessageGroupEphemeralAction,
  MessageGroupEphemeralNotice,
  MessageGroupHeader,
  MessageGroupReactions,
  MessageGroupReply,
  MessageGroupTime,
} from "~~/registry/bases/newt/ui/message-group"
import { Reaction } from "~~/registry/bases/newt/ui/reaction"
import {
  ReplyPreview,
  ReplyPreviewAuthor,
  ReplyPreviewText,
} from "~~/registry/bases/newt/ui/reply-preview"
import {
  StatusDot,
  StatusIndicator,
} from "~~/registry/bases/newt/ui/status-indicator"
</script>

<template>
  <div class="flex w-full max-w-md flex-col gap-2">
    <MessageGroup with-reply>
      <MessageGroupReply>
        <ReplyPreview>
          <ReplyPreviewAuthor>@someone</ReplyPreviewAuthor>
          <ReplyPreviewText>original message text</ReplyPreviewText>
        </ReplyPreview>
      </MessageGroupReply>
      <MessageGroupAvatar>
        <StatusIndicator>
          <Avatar>U</Avatar>
          <StatusDot status="online" />
        </StatusIndicator>
      </MessageGroupAvatar>
      <MessageGroupBody>
        <MessageGroupHeader>
          <MessageGroupAuthor>username</MessageGroupAuthor>
          <MessageGroupBotTag verified />
          <MessageGroupTime datetime="2026-09-04T14:23:00.000Z">
            Today at 14:23
          </MessageGroupTime>
        </MessageGroupHeader>
        <MessageGroupContent>Message content goes here.</MessageGroupContent>
        <MessageGroupReactions>
          <Reaction emoji="👍" :count="1" />
        </MessageGroupReactions>
      </MessageGroupBody>
    </MessageGroup>
    <MessageGroup ephemeral>
      <MessageGroupAvatar>
        <Avatar>B</Avatar>
      </MessageGroupAvatar>
      <MessageGroupBody>
        <MessageGroupHeader>
          <MessageGroupAuthor>botname</MessageGroupAuthor>
          <MessageGroupBotTag>APP</MessageGroupBotTag>
        </MessageGroupHeader>
        <MessageGroupContent>Command ran successfully.</MessageGroupContent>
        <MessageGroupEphemeralNotice>
          Only you can see this •
          <MessageGroupEphemeralAction>
            Dismiss message
          </MessageGroupEphemeralAction>
        </MessageGroupEphemeralNotice>
      </MessageGroupBody>
    </MessageGroup>
  </div>
</template>

Installation

pnpm dlx newtui@latest add message-group
npx newtui@latest add message-group
yarn dlx newtui@latest add message-group
bunx newtui@latest add message-group

Add the components it builds on

Copy the source into your project

import * as React from "react"

import { cn } from "@/lib/utils"

export interface MessageGroupProps extends React.HTMLAttributes<HTMLDivElement> {
  /**
   * Lay the row out as a 2x2 grid so a `MessageGroupReply` can hook over the
   * avatar, the way the client stacks a reply above the message it answers.
   */
  withReply?: boolean
  /** Tint the row as an ephemeral (visible-to-you-only) response. */
  ephemeral?: boolean
}

const MessageGroup = React.forwardRef<HTMLDivElement, MessageGroupProps>(
  ({ className, withReply = false, ephemeral = false, ...props }, ref) => (
    <div
      ref={ref}
      data-slot="message-row"
      data-ephemeral={ephemeral || undefined}
      className={cn(
        "gap-4 py-1",
        withReply
          ? "grid grid-cols-[auto_minmax(0,1fr)] grid-rows-[auto_auto] items-start [&>[data-slot=message-avatar]]:col-start-1 [&>[data-slot=message-avatar]]:row-start-2 [&>[data-slot=message-body]]:col-start-2 [&>[data-slot=message-body]]:row-start-2 [&>[data-slot=message-reply]]:col-start-2 [&>[data-slot=message-reply]]:row-start-1"
          : "flex",
        ephemeral
          ? "border-s-2 border-[color-mix(in_srgb,var(--newt-text-link)_40%,transparent)] bg-[color-mix(in_srgb,var(--newt-text-link)_10%,transparent)] hover:bg-[color-mix(in_srgb,var(--newt-text-link)_15%,transparent)]"
          : "hover:bg-black/[0.06]",
        className
      )}
      {...props}
    />
  )
)
MessageGroup.displayName = "MessageGroup"

const MessageGroupAvatar = React.forwardRef<
  HTMLDivElement,
  React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
  <div
    ref={ref}
    data-slot="message-avatar"
    className={cn("shrink-0", className)}
    {...props}
  />
))
MessageGroupAvatar.displayName = "MessageGroupAvatar"

const MessageGroupReply = React.forwardRef<
  HTMLDivElement,
  React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
  <div
    ref={ref}
    data-slot="message-reply"
    className={cn("min-w-0", className)}
    {...props}
  />
))
MessageGroupReply.displayName = "MessageGroupReply"

const MessageGroupBody = React.forwardRef<
  HTMLDivElement,
  React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
  <div
    ref={ref}
    data-slot="message-body"
    className={cn("min-w-0 flex-1", className)}
    {...props}
  />
))
MessageGroupBody.displayName = "MessageGroupBody"

const MessageGroupHeader = React.forwardRef<
  HTMLDivElement,
  React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
  <div
    ref={ref}
    className={cn("mb-1 flex items-baseline gap-2", className)}
    {...props}
  />
))
MessageGroupHeader.displayName = "MessageGroupHeader"

const MessageGroupAuthor = React.forwardRef<
  HTMLSpanElement,
  React.HTMLAttributes<HTMLSpanElement>
>(({ className, ...props }, ref) => (
  <span
    ref={ref}
    className={cn(
      "text-[15px] font-semibold text-newt-text-primary",
      className
    )}
    {...props}
  />
))
MessageGroupAuthor.displayName = "MessageGroupAuthor"

export interface MessageGroupBotTagProps extends React.HTMLAttributes<HTMLSpanElement> {
  /** Add the check Discord puts on verified applications. */
  verified?: boolean
}

const MessageGroupBotTag = React.forwardRef<
  HTMLSpanElement,
  MessageGroupBotTagProps
>(({ className, verified = false, children = "BOT", ...props }, ref) => (
  <span
    ref={ref}
    role="img"
    aria-label={verified ? "Verified application" : "Application"}
    className={cn(
      "inline-flex items-center gap-0.5 rounded-[3px] bg-newt-brand px-1 py-px text-[10px] font-semibold uppercase tracking-[0.02em] text-white",
      className
    )}
    {...props}
  >
    {verified ? (
      <svg
        viewBox="0 0 24 24"
        aria-hidden="true"
        focusable="false"
        className="h-2.5 w-2.5"
      >
        <path
          fill="currentColor"
          d="M9.55 17.6 4.4 12.45l1.4-1.4 3.75 3.75 8.65-8.65 1.4 1.4z"
        />
      </svg>
    ) : null}
    {children}
  </span>
))
MessageGroupBotTag.displayName = "MessageGroupBotTag"

/* A real `<time>`: the timestamp is machine-readable, not just small text. */
const MessageGroupTime = React.forwardRef<
  HTMLTimeElement,
  React.ComponentProps<"time">
>(({ className, ...props }, ref) => (
  <time
    ref={ref}
    className={cn("text-[11px] text-newt-text-muted", className)}
    {...props}
  />
))
MessageGroupTime.displayName = "MessageGroupTime"

const MessageGroupContent = React.forwardRef<
  HTMLDivElement,
  React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
  <div
    ref={ref}
    className={cn(
      "text-[15px] leading-[1.5] text-newt-text-secondary",
      className
    )}
    {...props}
  />
))
MessageGroupContent.displayName = "MessageGroupContent"

const MessageGroupReactions = React.forwardRef<
  HTMLDivElement,
  React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
  <div
    ref={ref}
    className={cn("mt-1.5 flex flex-wrap gap-1", className)}
    {...props}
  />
))
MessageGroupReactions.displayName = "MessageGroupReactions"

/*
 * The "only you can see this" footer Discord shows under an ephemeral reply.
 * Children replace the default copy; the dismiss control is the caller's.
 */
const MessageGroupEphemeralNotice = React.forwardRef<
  HTMLDivElement,
  React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
  <div
    ref={ref}
    role="status"
    className={cn(
      "mt-1.5 flex items-center gap-1 text-[13px] text-newt-text-muted",
      className
    )}
    {...props}
  />
))
MessageGroupEphemeralNotice.displayName = "MessageGroupEphemeralNotice"

const MessageGroupEphemeralAction = React.forwardRef<
  HTMLButtonElement,
  React.ComponentProps<"button">
>(({ className, type = "button", ...props }, ref) => (
  <button
    ref={ref}
    type={type}
    className={cn(
      "cursor-pointer border-0 bg-transparent p-0 font-[inherit] text-newt-text-link hover:underline",
      className
    )}
    {...props}
  />
))
MessageGroupEphemeralAction.displayName = "MessageGroupEphemeralAction"

export {
  MessageGroup,
  MessageGroupAvatar,
  MessageGroupReply,
  MessageGroupBody,
  MessageGroupHeader,
  MessageGroupAuthor,
  MessageGroupBotTag,
  MessageGroupTime,
  MessageGroupContent,
  MessageGroupReactions,
  MessageGroupEphemeralNotice,
  MessageGroupEphemeralAction,
}
<script setup lang="ts">
import type { HTMLAttributes } from "vue"

import { cn } from "@/lib/utils"

const props = defineProps<{
  class?: HTMLAttributes["class"]
  /**
   * Lay the row out as a 2x2 grid so a `MessageGroupReply` can hook over the
   * avatar, the way the client stacks a reply above the message it answers.
   */
  withReply?: boolean
  /** Tint the row as an ephemeral (visible-to-you-only) response. */
  ephemeral?: boolean
}>()
</script>

<template>
  <div
    data-slot="message-row"
    :data-ephemeral="props.ephemeral || undefined"
    :class="
      cn(
        'gap-4 py-1',
        props.withReply
          ? 'grid grid-cols-[auto_minmax(0,1fr)] grid-rows-[auto_auto] items-start [&>[data-slot=message-avatar]]:col-start-1 [&>[data-slot=message-avatar]]:row-start-2 [&>[data-slot=message-body]]:col-start-2 [&>[data-slot=message-body]]:row-start-2 [&>[data-slot=message-reply]]:col-start-2 [&>[data-slot=message-reply]]:row-start-1'
          : 'flex',
        props.ephemeral
          ? 'border-s-2 border-[color-mix(in_srgb,var(--newt-text-link)_40%,transparent)] bg-[color-mix(in_srgb,var(--newt-text-link)_10%,transparent)] hover:bg-[color-mix(in_srgb,var(--newt-text-link)_15%,transparent)]'
          : 'hover:bg-black/[0.06]',
        props.class
      )
    "
  >
    <slot />
  </div>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from "vue"

import { cn } from "@/lib/utils"

const props = defineProps<{ class?: HTMLAttributes["class"] }>()
</script>

<template>
  <div data-slot="message-avatar" :class="cn('shrink-0', props.class)">
    <slot />
  </div>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from "vue"

import { cn } from "@/lib/utils"

const props = defineProps<{ class?: HTMLAttributes["class"] }>()
</script>

<template>
  <div data-slot="message-reply" :class="cn('min-w-0', props.class)">
    <slot />
  </div>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from "vue"

import { cn } from "@/lib/utils"

const props = defineProps<{ class?: HTMLAttributes["class"] }>()
</script>

<template>
  <div data-slot="message-body" :class="cn('min-w-0 flex-1', props.class)">
    <slot />
  </div>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from "vue"

import { cn } from "@/lib/utils"

const props = defineProps<{ class?: HTMLAttributes["class"] }>()
</script>

<template>
  <div :class="cn('mb-1 flex items-baseline gap-2', props.class)">
    <slot />
  </div>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from "vue"

import { cn } from "@/lib/utils"

const props = defineProps<{ class?: HTMLAttributes["class"] }>()
</script>

<template>
  <span
    :class="cn('text-[15px] font-semibold text-newt-text-primary', props.class)"
  >
    <slot />
  </span>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from "vue"

import { cn } from "@/lib/utils"

const props = defineProps<{
  class?: HTMLAttributes["class"]
  /** Add the check Discord puts on verified applications. */
  verified?: boolean
}>()
</script>

<template>
  <span
    role="img"
    :aria-label="props.verified ? 'Verified application' : 'Application'"
    :class="
      cn(
        'inline-flex items-center gap-0.5 rounded-[3px] bg-newt-brand px-1 py-px text-[10px] font-semibold uppercase tracking-[0.02em] text-white',
        props.class
      )
    "
  >
    <svg
      v-if="props.verified"
      viewBox="0 0 24 24"
      aria-hidden="true"
      focusable="false"
      class="h-2.5 w-2.5"
    >
      <path
        fill="currentColor"
        d="M9.55 17.6 4.4 12.45l1.4-1.4 3.75 3.75 8.65-8.65 1.4 1.4z"
      />
    </svg>
    <slot>BOT</slot>
  </span>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from "vue"

import { cn } from "@/lib/utils"

const props = defineProps<{
  class?: HTMLAttributes["class"]
  /** Machine-readable timestamp for the `datetime` attribute. */
  datetime?: string
}>()
</script>

<!-- A real `<time>`: the timestamp is machine-readable, not just small text. -->
<template>
  <time
    :datetime="props.datetime"
    :class="cn('text-[11px] text-newt-text-muted', props.class)"
  >
    <slot />
  </time>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from "vue"

import { cn } from "@/lib/utils"

const props = defineProps<{ class?: HTMLAttributes["class"] }>()
</script>

<template>
  <div
    :class="
      cn('text-[15px] leading-[1.5] text-newt-text-secondary', props.class)
    "
  >
    <slot />
  </div>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from "vue"

import { cn } from "@/lib/utils"

const props = defineProps<{ class?: HTMLAttributes["class"] }>()
</script>

<template>
  <div :class="cn('mt-1.5 flex flex-wrap gap-1', props.class)">
    <slot />
  </div>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from "vue"

import { cn } from "@/lib/utils"

const props = defineProps<{ class?: HTMLAttributes["class"] }>()
</script>

<!--
  The "only you can see this" footer Discord shows under an ephemeral reply.
  Slot content replaces the copy; the dismiss control is the caller's.
-->
<template>
  <div
    role="status"
    :class="
      cn(
        'mt-1.5 flex items-center gap-1 text-[13px] text-newt-text-muted',
        props.class
      )
    "
  >
    <slot />
  </div>
</template>
<script setup lang="ts">
import type { ButtonHTMLAttributes, HTMLAttributes } from "vue"

import { cn } from "@/lib/utils"

const props = withDefaults(
  defineProps<{
    class?: HTMLAttributes["class"]
    type?: ButtonHTMLAttributes["type"]
  }>(),
  { type: "button" }
)
</script>

<template>
  <button
    :type="props.type"
    :class="
      cn(
        'cursor-pointer border-0 bg-transparent p-0 font-[inherit] text-newt-text-link hover:underline',
        props.class
      )
    "
  >
    <slot />
  </button>
</template>
export { default as MessageGroup } from "./MessageGroup.vue"
export { default as MessageGroupAvatar } from "./MessageGroupAvatar.vue"
export { default as MessageGroupReply } from "./MessageGroupReply.vue"
export { default as MessageGroupBody } from "./MessageGroupBody.vue"
export { default as MessageGroupHeader } from "./MessageGroupHeader.vue"
export { default as MessageGroupAuthor } from "./MessageGroupAuthor.vue"
export { default as MessageGroupBotTag } from "./MessageGroupBotTag.vue"
export { default as MessageGroupTime } from "./MessageGroupTime.vue"
export { default as MessageGroupContent } from "./MessageGroupContent.vue"
export { default as MessageGroupReactions } from "./MessageGroupReactions.vue"
export { default as MessageGroupEphemeralNotice } from "./MessageGroupEphemeralNotice.vue"
export { default as MessageGroupEphemeralAction } from "./MessageGroupEphemeralAction.vue"

Update the import paths

The files import @/lib/utils and sibling components through the aliases in your components.json. Point them at wherever those live in your project.

Usage

import { MessageGroup } from "@/components/ui/message-group"

export function Example() {
  return <MessageGroup />
}
<script setup lang="ts">
import { MessageGroup, MessageGroupAvatar, MessageGroupReply, MessageGroupBody, MessageGroupHeader, MessageGroupAuthor, MessageGroupBotTag, MessageGroupTime, MessageGroupContent, MessageGroupReactions, MessageGroupEphemeralNotice, MessageGroupEphemeralAction } from "@/components/ui/message-group"
</script>

<template>
  <MessageGroup />
</template>

API reference

MessageGroup

PropType
withReply?boolean | undefined

Lay the row out as a 2x2 grid so a `MessageGroupReply` can hook over the avatar, the way the client stacks a reply above the message it answers.

Typeboolean | undefined
ephemeral?boolean | undefined

Tint the row as an ephemeral (visible-to-you-only) response.

Typeboolean | undefined

MessageGroupBotTag

PropType
verified?boolean | undefined

Add the check Discord puts on verified applications.

Typeboolean | undefined

The registry exports MessageGroup, MessageGroupAvatar, MessageGroupReply, MessageGroupBody, MessageGroupHeader, MessageGroupAuthor, MessageGroupBotTag, MessageGroupTime, MessageGroupContent, MessageGroupReactions, MessageGroupEphemeralNotice, MessageGroupEphemeralAction. Parts without a table accept the native attributes for the element they render; React uses className, while Vue uses class and fallthrough attributes.

Accessibility

Preserve the native semantics of the rendered elements and pass through ARIA attributes when the surrounding context needs a label or description. Any interactive child needs a visible focus indicator, and meaning must not rely on colour alone.

Tokens

Message Group reads its colours, radii and motion from the --newt-* tokens installed with the library. Override a token and every component that reads it follows, including this one — see the token layer. In the plain HTML and CSS distribution the same component is newt-message-group.