diff --git a/apps/www/src/content/docs/ai-elements/message/demo.ts b/apps/www/src/content/docs/ai-elements/message/demo.ts
new file mode 100644
index 000000000..6f44dc631
--- /dev/null
+++ b/apps/www/src/content/docs/ai-elements/message/demo.ts
@@ -0,0 +1,112 @@
+'use client';
+
+export const preview = {
+ type: 'code',
+ code: `
+
+
+
+
+ Assistant
+
+ Here's the summary you asked for.
+
+ 1:52 AM
+
+
+
+ Thanks — looks great!
+
+
+`
+};
+
+export const anatomyDemo = {
+ type: 'code',
+ code: `
+
+
+
+
+ Assistant
+
+ Hover me for actions.
+
+ 1:52 AM
+
+ ⧉
+
+
+
+
+ Me too — actions on my side.
+
+
+ ✎
+
+
+`
+};
+
+export const groupDemo = {
+ type: 'code',
+ code: `
+
+
+ Ana
+
+ Pushed the fix.
+
+
+
+
+ CI is green again.
+
+
+
+
+
+
+
+ Deploying now.
+
+ 1:54 AM
+
+
+
+
+
+ Nice, thank you!
+
+
+
+
+ I'll verify on staging.
+
+ 1:55 AM
+
+
+`
+};
+
+export const bubbleVariantsDemo = {
+ type: 'code',
+ tabs: [
+ {
+ name: 'Solid',
+ code: `
+ The default: solid neutral.
+ High-emphasis accent bubble.
+ Something went wrong.
+`
+ },
+ {
+ name: 'Outline',
+ code: `
+ Bordered bubble on the base background.
+ Accent outline bubble.
+ Danger outline bubble.
+`
+ }
+ ]
+};
diff --git a/apps/www/src/content/docs/ai-elements/message/index.mdx b/apps/www/src/content/docs/ai-elements/message/index.mdx
new file mode 100644
index 000000000..cfb89efb5
--- /dev/null
+++ b/apps/www/src/content/docs/ai-elements/message/index.mdx
@@ -0,0 +1,85 @@
+---
+title: Message
+description: Inert message layout — avatar, header, content, footer, hover actions and bubbles, aligned to either side of a conversation.
+source: packages/raystack/components/message
+tag: new
+---
+
+import { preview, anatomyDemo, groupDemo, bubbleVariantsDemo } from "./demo.ts";
+
+
+
+## Anatomy
+
+```tsx
+import { Message } from '@raystack/apsara'
+
+
+ {/* Avatar */}
+ Ana
+
+ Hi there!
+
+ 1:52 AM
+ {/* copy / edit */}
+
+```
+
+`Message` is pure layout — no context, no effects, no chat coupling. It works
+anywhere: inside a `Chat` scroller, a comment thread, or a standalone
+transcript. Scroll behavior (visibility tracking, turn anchoring) belongs to
+[Chat](/docs/ai-elements/chat) — wrap a message in `Chat.Item` to opt in.
+
+## API Reference
+
+### Message
+
+One message. `align` drives which side everything sits on.
+
+
+
+Sub-parts are layout slots: `Message.Avatar` (bottom-aligned beside the
+message), `Message.Header` (sender line), `Message.Content` (the body
+column), `Message.Footer` (timestamp line) and `Message.Actions`
+(hover-revealed controls beside the bubble; always visible on touch devices).
+
+### Message.Group
+
+A flex column that pulls consecutive messages from the same sender closer
+together. Render the avatar and footer once — typically on the group's last
+message.
+
+### Message.Bubble
+
+The message surface.
+
+
+
+## Examples
+
+### Message anatomy
+
+Avatar, header, footer and hover-revealed actions around the content column.
+
+
+
+### Grouped messages
+
+Consecutive messages from one sender share a group; the avatar and timestamp
+appear once.
+
+
+
+### Bubble variants
+
+`variant` picks the surface treatment (`solid` or `outline`) and `color` the
+palette (`neutral`, `accent` or `danger`).
+
+
+
+## Accessibility
+
+- `Message.Actions` reveal on hover **and** on focus-within, and stay visible
+ on touch devices, so keyboard and touch users are not locked out.
+- Message bodies are regular document content — screen readers announce them
+ in reading order with no extra semantics to configure.
diff --git a/apps/www/src/content/docs/ai-elements/message/props.ts b/apps/www/src/content/docs/ai-elements/message/props.ts
new file mode 100644
index 000000000..b1f055b9d
--- /dev/null
+++ b/apps/www/src/content/docs/ai-elements/message/props.ts
@@ -0,0 +1,29 @@
+export interface MessageProps {
+ /**
+ * Which side of the conversation the message sits on. `"end"` aligns the
+ * message to the trailing edge (the sender's own messages); `"start"` to
+ * the leading edge.
+ * @defaultValue "start"
+ */
+ align?: 'start' | 'end';
+
+ /** Custom CSS class names. */
+ className?: string;
+}
+
+export interface MessageBubbleProps {
+ /**
+ * Visual style of the message surface.
+ * @defaultValue "solid"
+ */
+ variant?: 'solid' | 'outline';
+
+ /**
+ * Color of the message surface.
+ * @defaultValue "neutral"
+ */
+ color?: 'accent' | 'neutral' | 'danger';
+
+ /** Custom CSS class names. */
+ className?: string;
+}
diff --git a/apps/www/src/content/docs/ai-elements/meta.json b/apps/www/src/content/docs/ai-elements/meta.json
index 88524ea4a..7482ceb5c 100644
--- a/apps/www/src/content/docs/ai-elements/meta.json
+++ b/apps/www/src/content/docs/ai-elements/meta.json
@@ -1,4 +1,4 @@
{
"title": "AI Elements",
- "pages": ["prompt-input"]
+ "pages": ["message", "prompt-input"]
}
diff --git a/packages/raystack/components/message/__tests__/message.test.tsx b/packages/raystack/components/message/__tests__/message.test.tsx
new file mode 100644
index 000000000..7258c4fff
--- /dev/null
+++ b/packages/raystack/components/message/__tests__/message.test.tsx
@@ -0,0 +1,97 @@
+import { render, screen } from '@testing-library/react';
+import { createRef } from 'react';
+import { describe, expect, it } from 'vitest';
+import { Message } from '../message';
+import styles from '../message.module.css';
+
+describe('Message', () => {
+ it('renders alignment as a data attribute', () => {
+ render(
+
+ hi
+
+ );
+ expect(screen.getByTestId('message')).toHaveAttribute('data-align', 'end');
+ });
+
+ it('defaults to start alignment', () => {
+ render(hi);
+ expect(screen.getByTestId('message')).toHaveAttribute(
+ 'data-align',
+ 'start'
+ );
+ });
+
+ it('renders all sub-parts with their classes', () => {
+ render(
+
+ A
+ Ana
+ body
+ 1:52
+
+
+
+
+ );
+
+ expect(screen.getByTestId('avatar')).toHaveClass(styles['message-avatar']);
+ expect(screen.getByTestId('header')).toHaveClass(styles['message-header']);
+ expect(screen.getByTestId('content')).toHaveClass(
+ styles['message-content']
+ );
+ expect(screen.getByTestId('footer')).toHaveClass(styles['message-footer']);
+ expect(screen.getByTestId('actions')).toHaveClass(
+ styles['message-actions']
+ );
+ });
+
+ it('wraps consecutive messages in a group', () => {
+ render(
+
+ first
+ second
+
+ );
+ expect(screen.getByTestId('group')).toHaveClass(styles.group);
+ expect(screen.getByText('first')).toBeInTheDocument();
+ expect(screen.getByText('second')).toBeInTheDocument();
+ });
+
+ it('forwards ref', () => {
+ const ref = createRef();
+ render(hi);
+ expect(ref.current).toBeInstanceOf(HTMLDivElement);
+ });
+
+ describe('Message.Bubble', () => {
+ it('defaults to the solid neutral appearance', () => {
+ render(text);
+ const bubble = screen.getByTestId('bubble');
+ expect(bubble).toHaveAttribute('data-variant', 'solid');
+ expect(bubble).toHaveAttribute('data-color', 'neutral');
+ expect(bubble).toHaveClass(styles['bubble-solid']);
+ expect(bubble).toHaveClass(styles['bubble-neutral']);
+ });
+
+ it.each([
+ ['solid', 'accent'],
+ ['solid', 'neutral'],
+ ['solid', 'danger'],
+ ['outline', 'accent'],
+ ['outline', 'neutral'],
+ ['outline', 'danger']
+ ] as const)('renders the %s %s appearance', (variant, color) => {
+ render(
+
+ text
+
+ );
+ const bubble = screen.getByTestId('bubble');
+ expect(bubble).toHaveAttribute('data-variant', variant);
+ expect(bubble).toHaveAttribute('data-color', color);
+ expect(bubble).toHaveClass(styles[`bubble-${variant}`]);
+ expect(bubble).toHaveClass(styles[`bubble-${color}`]);
+ });
+ });
+});
diff --git a/packages/raystack/components/message/index.tsx b/packages/raystack/components/message/index.tsx
new file mode 100644
index 000000000..f24ffffb1
--- /dev/null
+++ b/packages/raystack/components/message/index.tsx
@@ -0,0 +1,2 @@
+export { Message, type MessageAlign, type MessageProps } from './message';
+export type { MessageBubbleProps } from './message-bubble';
diff --git a/packages/raystack/components/message/message-bubble.tsx b/packages/raystack/components/message/message-bubble.tsx
new file mode 100644
index 000000000..753017729
--- /dev/null
+++ b/packages/raystack/components/message/message-bubble.tsx
@@ -0,0 +1,56 @@
+'use client';
+
+import { cva, type VariantProps } from 'class-variance-authority';
+import { ComponentProps } from 'react';
+import styles from './message.module.css';
+
+const bubble = cva(styles.bubble, {
+ variants: {
+ variant: {
+ solid: styles['bubble-solid'],
+ outline: styles['bubble-outline']
+ },
+ color: {
+ accent: styles['bubble-accent'],
+ neutral: styles['bubble-neutral'],
+ danger: styles['bubble-danger']
+ }
+ },
+ defaultVariants: {
+ variant: 'solid',
+ color: 'neutral'
+ }
+});
+
+export interface MessageBubbleProps
+ extends ComponentProps<'div'>,
+ VariantProps {
+ /**
+ * Visual style of the message surface.
+ * @defaultValue "solid"
+ */
+ variant?: 'solid' | 'outline';
+ /**
+ * Color of the message surface.
+ * @defaultValue "neutral"
+ */
+ color?: 'accent' | 'neutral' | 'danger';
+}
+
+export function MessageBubble({
+ className,
+ variant = 'solid',
+ color = 'neutral',
+ ...props
+}: MessageBubbleProps) {
+ return (
+
+ );
+}
+
+MessageBubble.displayName = 'Message.Bubble';
diff --git a/packages/raystack/components/message/message.module.css b/packages/raystack/components/message/message.module.css
new file mode 100644
index 000000000..f58884ca8
--- /dev/null
+++ b/packages/raystack/components/message/message.module.css
@@ -0,0 +1,164 @@
+/* ---------------------------------- group ---------------------------------- */
+
+.group {
+ display: flex;
+ flex-direction: column;
+ gap: var(--rs-space-2);
+ min-width: 0;
+}
+
+/* --------------------------------- message -------------------------------- */
+
+/* The content column shrink-wraps (capped like the bubble used to be) with a
+ flexible spacer on the far side, so the hover actions sit next to the
+ content instead of drifting to the row's opposite edge. */
+.message {
+ display: grid;
+ grid-template-columns: auto fit-content(85%) auto 1fr;
+ grid-template-areas:
+ ". header . ."
+ "avatar content actions ."
+ ". footer . .";
+ align-items: start;
+}
+
+.message[data-align="end"] {
+ grid-template-columns: 1fr auto fit-content(85%) auto;
+ grid-template-areas:
+ ". . header ."
+ ". actions content avatar"
+ ". . footer .";
+ justify-items: end;
+}
+
+/* The avatar area only spans the content row, so flex-end anchors it to the
+ bubble's bottom edge rather than to the footer line below it. */
+.message-avatar {
+ grid-area: avatar;
+ align-self: flex-end;
+ flex-shrink: 0;
+ margin-right: var(--rs-space-3);
+}
+
+.message[data-align="end"] .message-avatar {
+ margin-right: 0;
+ margin-left: var(--rs-space-3);
+}
+
+.message-header {
+ grid-area: header;
+ display: flex;
+ align-items: center;
+ gap: var(--rs-space-2);
+ margin-bottom: var(--rs-space-1);
+ font-size: var(--rs-font-size-mini);
+ line-height: var(--rs-line-height-mini);
+ font-weight: var(--rs-font-weight-medium);
+ color: var(--rs-color-foreground-base-secondary);
+}
+
+.message-content {
+ grid-area: content;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: var(--rs-space-2);
+ min-width: 0;
+ width: 100%;
+ font-size: var(--rs-font-size-small);
+ line-height: var(--rs-line-height-small);
+ color: var(--rs-color-foreground-base-primary);
+}
+
+.message[data-align="end"] .message-content {
+ align-items: flex-end;
+}
+
+.message-footer {
+ grid-area: footer;
+ display: flex;
+ align-items: center;
+ gap: var(--rs-space-2);
+ margin-top: var(--rs-space-1);
+ font-size: var(--rs-font-size-mini);
+ line-height: var(--rs-line-height-mini);
+ color: var(--rs-color-foreground-base-tertiary);
+}
+
+.message-actions {
+ grid-area: actions;
+ align-self: center;
+ display: flex;
+ align-items: center;
+ gap: var(--rs-space-1);
+ margin: 0 var(--rs-space-2);
+ opacity: 0;
+}
+
+@media (prefers-reduced-motion: no-preference) {
+ .message-actions {
+ transition: opacity var(--rs-duration-fast) var(--rs-ease-out);
+ }
+}
+
+.message:hover .message-actions,
+.message:focus-within .message-actions {
+ opacity: 1;
+}
+
+/* Touch devices have no hover: keep actions reachable. */
+@media (hover: none) {
+ .message-actions {
+ opacity: 1;
+ }
+}
+
+/* --------------------------------- bubble --------------------------------- */
+
+.bubble {
+ box-sizing: border-box;
+ width: fit-content;
+ /* The message grid's content column already caps at 85% of the row;
+ inside it the bubble just fills what the text needs. */
+ max-width: 100%;
+ min-width: 0;
+ padding: var(--rs-space-3) var(--rs-space-4);
+ border-radius: var(--rs-radius-5);
+ font-size: var(--rs-font-size-small);
+ line-height: var(--rs-line-height-small);
+ overflow-wrap: anywhere;
+ white-space: pre-wrap;
+}
+
+/* Variant sets the surface treatment; color picks the palette. The token
+ pairs mirror Button's solid/outline color mapping. */
+.bubble-solid.bubble-neutral {
+ background: var(--rs-color-background-neutral-secondary);
+ color: var(--rs-color-foreground-base-primary);
+}
+
+.bubble-solid.bubble-accent {
+ background: var(--rs-color-background-accent-emphasis);
+ color: var(--rs-color-foreground-accent-emphasis);
+}
+
+.bubble-solid.bubble-danger {
+ background: var(--rs-color-background-danger-emphasis);
+ color: var(--rs-color-foreground-danger-emphasis);
+}
+
+.bubble-outline {
+ background: var(--rs-color-background-base-primary);
+ border: 0.5px solid var(--rs-color-border-base-primary);
+ color: var(--rs-color-foreground-base-primary);
+}
+
+.bubble-outline.bubble-accent {
+ border-color: var(--rs-color-border-accent-emphasis);
+ color: var(--rs-color-foreground-accent-primary);
+}
+
+.bubble-outline.bubble-danger {
+ border-color: var(--rs-color-border-danger-emphasis);
+ color: var(--rs-color-foreground-danger-primary);
+}
diff --git a/packages/raystack/components/message/message.tsx b/packages/raystack/components/message/message.tsx
new file mode 100644
index 000000000..1dd8f966b
--- /dev/null
+++ b/packages/raystack/components/message/message.tsx
@@ -0,0 +1,92 @@
+'use client';
+
+import { cx } from 'class-variance-authority';
+import { ComponentProps } from 'react';
+import styles from './message.module.css';
+import { MessageBubble } from './message-bubble';
+
+export type MessageAlign = 'start' | 'end';
+
+export interface MessageProps extends ComponentProps<'div'> {
+ /**
+ * Which side of the conversation the message sits on. `"end"` aligns the
+ * message to the trailing edge (the sender's own messages); `"start"` (the
+ * default) to the leading edge.
+ * @defaultValue "start"
+ */
+ align?: MessageAlign;
+}
+
+function MessageRoot({ className, align = 'start', ...props }: MessageProps) {
+ return (
+
+ );
+}
+
+MessageRoot.displayName = 'Message';
+
+export interface MessageGroupProps extends ComponentProps<'div'> {}
+
+export function MessageGroup({ className, ...props }: MessageGroupProps) {
+ return ;
+}
+
+MessageGroup.displayName = 'Message.Group';
+
+export interface MessageAvatarProps extends ComponentProps<'div'> {}
+
+export function MessageAvatar({ className, ...props }: MessageAvatarProps) {
+ return ;
+}
+
+MessageAvatar.displayName = 'Message.Avatar';
+
+export interface MessageHeaderProps extends ComponentProps<'div'> {}
+
+export function MessageHeader({ className, ...props }: MessageHeaderProps) {
+ return ;
+}
+
+MessageHeader.displayName = 'Message.Header';
+
+export interface MessageContentProps extends ComponentProps<'div'> {}
+
+export function MessageContent({ className, ...props }: MessageContentProps) {
+ return (
+
+ );
+}
+
+MessageContent.displayName = 'Message.Content';
+
+export interface MessageFooterProps extends ComponentProps<'div'> {}
+
+export function MessageFooter({ className, ...props }: MessageFooterProps) {
+ return ;
+}
+
+MessageFooter.displayName = 'Message.Footer';
+
+export interface MessageActionsProps extends ComponentProps<'div'> {}
+
+export function MessageActions({ className, ...props }: MessageActionsProps) {
+ return (
+
+ );
+}
+
+MessageActions.displayName = 'Message.Actions';
+
+export const Message = Object.assign(MessageRoot, {
+ Group: MessageGroup,
+ Avatar: MessageAvatar,
+ Header: MessageHeader,
+ Content: MessageContent,
+ Footer: MessageFooter,
+ Actions: MessageActions,
+ Bubble: MessageBubble
+});
diff --git a/packages/raystack/index.tsx b/packages/raystack/index.tsx
index 7f4988464..b500e4dbf 100644
--- a/packages/raystack/index.tsx
+++ b/packages/raystack/index.tsx
@@ -74,6 +74,7 @@ export { Link } from './components/link';
export { List } from './components/list';
export { Menu } from './components/menu';
export { Menubar } from './components/menubar';
+export { Message } from './components/message';
export { Meter } from './components/meter';
export { Navbar } from './components/navbar';
export { NumberField } from './components/number-field';