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.
newt-message-groupimport { 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-groupnpx newtui@latest add message-groupyarn dlx newtui@latest add message-groupbunx newtui@latest add message-groupAdd 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
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.
boolean | undefinedephemeral?boolean | undefined
Tint the row as an ephemeral (visible-to-you-only) response.
boolean | undefinedMessageGroupBotTag
verified?boolean | undefined
Add the check Discord puts on verified applications.
boolean | undefinedThe 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.