# Message

> A chat message: the user's in a sunken bubble on the right, the assistant's flush on the left. Consecutive bubbles from one author merge, their touching corners tightening as the next one lands. Renders AI SDK UIMessage text and file parts.

- Tier: Free (MIT)
- Interaction: Type
- Page: https://eased.dev/components/message
- Installs: motion
- Registry: https://eased.dev/r/message.json

## Install

```bash
npx shadcn add https://eased.dev/r/message.json
```

A chat message for AI SDK `UIMessage`s: user messages in a sunken bubble, assistant messages flush.

**Motion signature:** consecutive bubbles from one author merge, and the corners where they meet tighten from 16px to 6px as the next one lands, so the run reflows instead of snapping. New messages rise 8px once.

```tsx
{groupMessages(messages).map((g) => (
  <div key={g.key} className="flex flex-col gap-1">
    {g.messages.map(({ message, position }) => (
      <Message key={message.id} message={message} position={position} actions={<CopyAction message={message} />} />
    ))}
  </div>
))}
```

## Props
- `message?`, `role?` (defaults to the message's), `position` (`single` | `first` | `middle` | `last`), `children` (replaces the text, e.g. a streaming Response), `actions`, `actionsVisible` (`hover` keeps its space while hidden | `always`), `animateIn`.
- `MessageAction`: `label`, `onClick`, `pressed`, `children` (a 16px icon).
- `CopyAction`: `message` or `text`; morphs to a check for 1.6s.

The free bubble. The Pro thread (streaming markdown, branches, edit in place, the lift out of the composer) arrives with Conversation and Response.

## Example

`components/eased/message/message-demo.tsx`

```tsx
"use client";

import { useState } from "react";
import { groupMessages, useStickToBottom, type ChatMessage } from "../_primitives/chat-core/chat-core";
import { scene } from "../attachment/attachment-demo";
import { Button } from "../button/button";
import { DemoFrame } from "../lib/demo-frame";
import { CopyAction, Message } from "./message";

const SEED: ChatMessage[] = [
  {
    id: "1",
    role: "user",
    parts: [
      { type: "file", mediaType: "image/png", filename: "kitchen-before.png", url: scene("#e9e4dc", "#c9a27e", "#b7c4b0", "#6f7f6a", 30) },
      { type: "text", text: "This is the kitchen now. What would make it feel bigger without moving walls?" },
    ],
  },
  {
    id: "2",
    role: "assistant",
    parts: [
      {
        type: "text",
        text: "Three things do most of the work: paint the upper cabinets the wall colour so they recede, swap the pendant for a flush light, and run one floor finish through to the dining area.",
      },
    ],
  },
];

const FOLLOW_UPS = ["Love the cabinet idea.", "What colour would you pick?", "Something warm, not stark white."];

/** Send the follow-ups one by one: each new bubble tightens the corners where it meets the last. */
export function MessageDemo() {
  const [messages, setMessages] = useState(SEED);
  const [step, setStep] = useState(0);
  const { scrollRef, contentRef, scrollToBottom } = useStickToBottom();
  const left = FOLLOW_UPS.length - step;

  const send = () => {
    if (step >= FOLLOW_UPS.length) {
      setStep(0);
      setMessages(SEED);
      return;
    }
    const text = FOLLOW_UPS[step];
    setStep(step + 1);
    setMessages((m) => [...m, { id: `f${step}-${Date.now()}`, role: "user", metadata: { live: true }, parts: [{ type: "text", text }] }]);
    scrollToBottom();
  };

  return (
    <DemoFrame title="Kitchen ideas" meta={`${messages.length} messages`}>
      <div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto [scrollbar-width:thin]">
        <div ref={contentRef} className="flex flex-col gap-4 p-4">
          {groupMessages(messages).map((g) => (
            <div key={g.key} className="flex flex-col gap-1">
              {g.messages.map(({ message, position }) => {
                return (
                  <Message
                    key={message.id}
                    message={message}
                    position={position}
                    animateIn={Boolean(message.metadata)}
                    actions={message.role === "assistant" ? <CopyAction message={message} /> : undefined}
                    actionsVisible="always"
                  />
                );
              })}
            </div>
          ))}
        </div>
      </div>
      <div className="flex shrink-0 items-center gap-3 border-t border-line-subtle px-4 py-3">
        <p className="min-w-0 flex-1 truncate text-ui-sm text-text-muted">
          {left > 0 ? `“${FOLLOW_UPS[step]}”` : "All follow-ups sent"}
        </p>
        <Button variant="primary" size="sm" onClick={send}>
          {left > 0 ? "Send follow-up" : "Reset"}
        </Button>
      </div>
    </DemoFrame>
  );
}
```
