For the complete documentation index, see llms.txt. Markdown variants are available by appending .md to any URL or sending an Accept: text/markdown header. An agent skill is available at /.well-known/agent-skills/site-skill.md.
0
Sponsor

Message

One turn of a transcript, assembled from parts: who it is from, the body, and the actions that appear once you are done reading.

Can the rail follow a long thread without re-rendering it?
It reads the scroll position into a motion value, so the rail moves on the compositor while React stays out of the way.
The thread was archived on 12 September.
"use client";

import { Copy } from "lucide-react";

Installation

$ pnpm dlx shadcn@latest add https://motif-ui.vercel.app/r/message.json

Usage

The root's one opinion is the arrangement; the parts inside it are the caller's to compose. A reader's turn sits against the trailing edge in a filled bubble, a turn to them runs full width, and a system note sits centred and quiet.

import {
  Message,
  MessageActions,
  MessageContent,
} from "@/components/ui/message";
 
export function Turn({ turn }: { turn: Turn }) {
  return (
    <Message from={turn.from}>
      <MessageContent>
        {turn.text}
        <MessageActions>
          <CopyButton value={turn.text} />
        </MessageActions>
      </MessageContent>
    </Message>
  );
}
PartWhat it is
MessageThe row: from picks the arrangement, data-from carries it outward
MessageContentThe body column, whose shape follows the role
MessageActionsA row under the body, revealed on hover or focus, empty while it is empty

from is "user", "assistant" or "system" today. It is not a closed set the parts police — the root writes it to data-from, so a role this registry has not met yet can be styled from the outside without a second prop.

The arrangement

The root's job is two rules: a reader's turn reverses the row and a system note centres it. Everything else about the body — how wide it is, whether it draws a fill — belongs to MessageContent, which reads the role from context.

RoleRootBody
"user"Row reversedBubble against the trailing edge, max-w-[85%]
"assistant"Row as writtenFull width, no fill
"system"CentredMuted, centred

A reader's bubble is a fill inside the thread — bg-muted, not a surface of its own — so it lands the same way on a page-level thread in either theme. It keeps whitespace, because what a person typed is what they typed.

The actions

A reader is reading until they are not, and four controls under every answer is four controls of noise, so MessageActions stays out of the way until the turn is hovered or something inside it is focused. It is an ordinary flex row: the controls that go in it are whatever the surface already uses, and a control with no handler behind it is not drawn.

The reveal is deliberately CSS. A tap that sticks a :hover open is the right touch gesture for a row of actions, which is the opposite of the magnetic pulls and hover lifts elsewhere in this registry — that is why useHoverCapable is not involved. Nothing is tabbable while it is invisible either: focus-within brings the row back the moment a keyboard reaches it.

Component source

message.tsx
"use client";

import { createContext, useContext } from "react";
import type { ComponentProps } from "react";

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

/* -- One turn ------------------------------------------------------------------
 * A message is three decisions, and each one is a part: who it is from, the body, and
 * what you can do with the body after reading it. The root's only opinion is the
 * arrangement — a turn from the reader sits against the trailing edge in a filled
 * bubble, a turn to them runs full width on the page's own surface, a system note sits
 * centred and recedes — and it carries that opinion as `data-from` so a caller can
 * restyle a role the registry has not met yet.
 *
 * Alignment is the root's; the shape of the body is the content's. A bubble is a fill
 * *inside* the thread (`muted`), never a surface of its own, so it lands the same way
 * on a page-level thread in either theme.
 *
 * The actions part is revealed on hover and on focus — deliberately, and in CSS. A
 * reader is reading until they are not, and four controls under every answer is four
 * controls of noise; but a tap that reveals them is the right touch gesture, unlike
 * the magnetic pulls elsewhere in this registry, so a sticky `:hover` is a feature
 * here rather than the bug `useHoverCapable` exists to prevent. Nothing is tabbable
 * while it is invisible either: `focus-within` brings the row back the moment a
 * keyboard reaches it.
 * --------------------------------------------------------------------------- */

export type MessageFrom = "assistant" | "system" | "user";

const MessageContext = createContext<MessageFrom | null>(null);

const useMessageFrom = () => {
  const from = useContext(MessageContext);
  if (!from) {
    throw new Error("Message parts must be used within a Message.");
  }

  return from;
};

export interface MessageProps extends ComponentProps<"article"> {
  /** Whose turn it is. Drives the arrangement and the body's shape. */
  from: MessageFrom;
}

export const Message = ({ className, from, ...props }: MessageProps) => (
  <MessageContext.Provider value={from}>
    <article
      className={cn(
        "group/message flex w-full",
        from === "user" && "flex-row-reverse",
        from === "system" && "justify-center",
        className
      )}
      data-from={from}
      data-slot="message"
      {...props}
    />
  </MessageContext.Provider>
);

/**
 * The body. It is a column, so a caller can stack a reasoning block, the answer, and
 * the actions row in the order the answer was written, and it takes its shape from
 * the turn's role: a reader's bubble against the trailing edge, a full-width answer,
 * or a centred system note.
 */
export const MessageContent = ({
  className,
  ...props
}: ComponentProps<"div">) => {
  const from = useMessageFrom();

  return (
    <div
      className={cn(
        "flex min-w-0 flex-col gap-1.5 text-sm leading-relaxed",
        from === "user" &&
          "max-w-[85%] rounded-3xl bg-muted px-4 py-2.5 whitespace-pre-wrap break-words",
        from === "assistant" && "flex-1",
        from === "system" && "max-w-[85%] text-center text-muted-foreground",
        className
      )}
      data-slot="message-content"
      {...props}
    />
  );
};

/**
 * The row under the body: copy, retry, a vote, whatever the surface knows how to do
 * with an answer that has already landed. It is an ordinary flex row — the controls
 * that go in it are the platform's — and it draws nothing while it holds nothing.
 *
 * On a device with a fine pointer it stays out of the way until the turn is hovered
 * or something inside it is focused.
 */
export const MessageActions = ({
  className,
  ...props
}: ComponentProps<"div">) => (
  <div
    className={cn(
      "flex items-center gap-0.5 empty:hidden",
      "pointer-events-none opacity-0 transition-opacity duration-150 ease-out",
      "group-hover/message:pointer-events-auto group-hover/message:opacity-100",
      "group-focus-within/message:pointer-events-auto group-focus-within/message:opacity-100",
      "motion-reduce:transition-none",
      className
    )}
    data-slot="message-actions"
    {...props}
  />
);