HextaUI

Message

アバター、名前、バブル、ステータスを備えたチャットのメッセージ行です。新しいメッセージは送信者の側からせり上がって表示されます。

PS
Preet
Did the message component land?
Send me something and see how it arrives.
"use client"

import * as React from "react"
import { IconArrowUp, IconChecks } from "@tabler/icons-react"

import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Bubble, BubbleContent, BubbleGroup } from "@/components/ui/bubble"
import {
  InputGroup,
  InputGroupAddon,
  InputGroupButton,
  InputGroupTextarea,
} from "@/components/ui/input-group"
import { Marker, MarkerContent, MarkerTime } from "@/components/ui/marker"
import {
  Message,
  MessageAvatar,
  MessageContent,
  MessageFooter,
  MessageGroup,
  MessageHeader,
} from "@/components/ui/message"

type Entry = { id: number; from: "me" | "preet"; text: string }

const replies = [
  "Ha, that rise-in is nice.",
  "Try sending a few in a row.",
  "Each one comes in from your side.",
]

export function MessageDemo() {
  const [entries, setEntries] = React.useState<Entry[]>([
    { id: 1, from: "preet", text: "Did the message component land?" },
    { id: 2, from: "preet", text: "Send me something and see how it arrives." },
  ])
  const [draft, setDraft] = React.useState("")
  const nextId = React.useRef(3)
  const replyIndex = React.useRef(0)
  const replyTimer = React.useRef<ReturnType<typeof setTimeout>>(undefined)
  const threadRef = React.useRef<HTMLDivElement>(null)

  React.useEffect(() => () => clearTimeout(replyTimer.current), [])

  React.useEffect(() => {
    const thread = threadRef.current
    if (thread) {
      thread.scrollTo({ top: thread.scrollHeight, behavior: "smooth" })
    }
  }, [entries])

  const send = () => {
    const text = draft.trim()
    if (!text) {
      return
    }
    setEntries((current) => [
      ...current,
      { id: nextId.current++, from: "me", text },
    ])
    setDraft("")
    clearTimeout(replyTimer.current)
    replyTimer.current = setTimeout(() => {
      const reply = replies[replyIndex.current % replies.length]
      replyIndex.current += 1
      setEntries((current) => [
        ...current,
        { id: nextId.current++, from: "preet", text: reply },
      ])
    }, 900)
  }

  const runs = entries.reduce<Entry[][]>((groups, entry) => {
    const last = groups.at(-1)
    if (last && last[0].from === entry.from) {
      last.push(entry)
    } else {
      groups.push([entry])
    }
    return groups
  }, [])

  return (
    <div className="flex w-full max-w-md flex-col overflow-hidden rounded-xl border">
      <div
        ref={threadRef}
        className="h-80 overflow-y-auto overscroll-none px-4 py-4"
      >
        <MessageGroup>
          <Marker variant="separator">
            <MarkerContent>
              <MarkerTime date={new Date()} />
            </MarkerContent>
          </Marker>
          {runs.map((run, index) => {
            const mine = run[0].from === "me"
            const lastRun = index === runs.length - 1
            return (
              <Message key={run[0].id} align={mine ? "end" : "start"}>
                {!mine && (
                  <MessageAvatar>
                    <Avatar>
                      <AvatarImage
                        src="https://github.com/preetsuthar17.png"
                        alt=""
                      />
                      <AvatarFallback>PS</AvatarFallback>
                    </Avatar>
                  </MessageAvatar>
                )}
                <MessageContent>
                  {!mine && <MessageHeader>Preet</MessageHeader>}
                  <BubbleGroup shape="tail">
                    {run.map((entry) => (
                      <Bubble
                        key={entry.id}
                        variant={mine ? "default" : "secondary"}
                      >
                        <BubbleContent>{entry.text}</BubbleContent>
                      </Bubble>
                    ))}
                  </BubbleGroup>
                  {mine && lastRun && (
                    <MessageFooter>
                      <IconChecks />
                      Read
                    </MessageFooter>
                  )}
                </MessageContent>
              </Message>
            )
          })}
        </MessageGroup>
      </div>
      <form
        className="border-t p-3"
        onSubmit={(event) => {
          event.preventDefault()
          send()
        }}
      >
        <InputGroup>
          <InputGroupTextarea
            value={draft}
            onChange={(event) => setDraft(event.target.value)}
            onKeyDown={(event) => {
              if (
                event.key === "Enter" &&
                !event.shiftKey &&
                !event.nativeEvent.isComposing
              ) {
                event.preventDefault()
                send()
              }
            }}
            rows={1}
            placeholder="Message Preet"
            aria-label="Message"
            className="min-h-10"
          />
          <InputGroupAddon align="inline-end">
            <InputGroupButton
              type="submit"
              variant="default"
              size="icon-xs"
              aria-label="Send"
              disabled={!draft.trim()}
            >
              <IconArrowUp />
            </InputGroupButton>
          </InputGroupAddon>
        </InputGroup>
      </form>
    </div>
  )
}
pnpm dlx shadcn@latest add https://hextaui.com/r/message.json

コンポーネント、HextaUIのテーマトークン、依存するHextaUIコンポーネントを追加します。

import {
  Message,
  MessageAvatar,
  MessageContent,
  MessageFooter,
  MessageGroup,
  MessageHeader,
} from "@/components/ui/message"
<MessageGroup>
  <Message align="end">
    <MessageContent>
      <Bubble>
        <BubbleContent>On my way.</BubbleContent>
      </Bubble>
      <MessageFooter>Read</MessageFooter>
    </MessageContent>
  </Message>
</MessageGroup>

message は、アバター、名前、1つ以上の <Bubble />、ステータス行をレイアウトします。バブルは message の align を継承するので、設定は一度だけです。

MessageGroup
└── Message
    ├── MessageAvatar
    └── MessageContent
        ├── MessageHeader
        ├── Bubble
        └── MessageFooter

配置

align="end" は、自分のメッセージ用にアバターとバブルを反対側に置きます。

import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Bubble, BubbleContent } from "@/components/ui/bubble"
import {
  Message,
  MessageAvatar,
  MessageContent,
  MessageGroup,
} from "@/components/ui/message"

export function MessageAlignment() {
  return (
    <MessageGroup className="w-full max-w-md">
      <Message>
        <MessageAvatar>
          <Avatar>
            <AvatarImage src="https://github.com/preetsuthar17.png" alt="" />
            <AvatarFallback>PS</AvatarFallback>
          </Avatar>
        </MessageAvatar>
        <MessageContent>
          <Bubble variant="secondary" shape="tail">
            <BubbleContent>Start is for the people you talk to.</BubbleContent>
          </Bubble>
        </MessageContent>
      </Message>
      <Message align="end">
        <MessageAvatar>
          <Avatar>
            <AvatarFallback>ME</AvatarFallback>
          </Avatar>
        </MessageAvatar>
        <MessageContent>
          <Bubble shape="tail">
            <BubbleContent>End is for you. The bubble follows.</BubbleContent>
          </Bubble>
        </MessageContent>
      </Message>
    </MessageGroup>
  )
}

<MessageHeader /> は名前と時刻を、<MessageFooter /> は1行のステータスを保持します。フッターの有無にかかわらず、アバターは常に最後のバブルに揃います。

import { IconChecks, IconClock } from "@tabler/icons-react"

import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Bubble, BubbleContent, BubbleGroup } from "@/components/ui/bubble"
import {
  Message,
  MessageAvatar,
  MessageContent,
  MessageFooter,
  MessageGroup,
  MessageHeader,
} from "@/components/ui/message"

export function MessageHeaderFooter() {
  return (
    <MessageGroup className="w-full max-w-md">
      <Message>
        <MessageAvatar>
          <Avatar>
            <AvatarImage src="https://github.com/preetsuthar17.png" alt="" />
            <AvatarFallback>PS</AvatarFallback>
          </Avatar>
        </MessageAvatar>
        <MessageContent>
          <MessageHeader>
            Preet Suthar
            <span className="font-normal">9:41</span>
          </MessageHeader>
          <BubbleGroup shape="tail">
            <Bubble variant="secondary">
              <BubbleContent>Pushed the fix.</BubbleContent>
            </Bubble>
            <Bubble variant="secondary">
              <BubbleContent>Can you check the preview?</BubbleContent>
            </Bubble>
          </BubbleGroup>
          <MessageFooter>Edited</MessageFooter>
        </MessageContent>
      </Message>
      <Message align="end">
        <MessageContent>
          <Bubble shape="tail">
            <BubbleContent>Looks right. Merging.</BubbleContent>
          </Bubble>
          <MessageFooter>
            <IconChecks />
            Read 9:43
          </MessageFooter>
        </MessageContent>
      </Message>
      <Message align="end">
        <MessageContent>
          <Bubble variant="muted" shape="tail">
            <BubbleContent>Sending the release notes…</BubbleContent>
          </Bubble>
          <MessageFooter>
            <IconClock />
            Sending
          </MessageFooter>
        </MessageContent>
      </Message>
    </MessageGroup>
  )
}

アシスタント

ghost バブルは長い回答向けに全幅に広がり、<MessageAvatar /> 内のアイコンは丸いタイルになります。

import { IconSparkles } from "@tabler/icons-react"

import { Bubble, BubbleContent } from "@/components/ui/bubble"
import {
  Message,
  MessageAvatar,
  MessageContent,
  MessageGroup,
  MessageHeader,
} from "@/components/ui/message"

export function MessageAssistant() {
  return (
    <MessageGroup className="w-full max-w-md">
      <Message align="end">
        <MessageContent>
          <Bubble variant="secondary">
            <BubbleContent>What does a concentric radius mean?</BubbleContent>
          </Bubble>
        </MessageContent>
      </Message>
      <Message>
        <MessageAvatar>
          <IconSparkles />
        </MessageAvatar>
        <MessageContent>
          <MessageHeader>Assistant</MessageHeader>
          <Bubble variant="ghost">
            <BubbleContent>
              An inner corner whose radius is the outer radius minus the gap
              between the two edges. Nested shapes then curve together instead
              of fighting each other at the corners.
            </BubbleContent>
          </Bubble>
        </MessageContent>
      </Message>
    </MessageGroup>
  )
}

長いコンテンツ

バブルは長いリンクを折り返し、ヘッダーとフッターのテキストはアバターを押し出さずに切り詰められます。

import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Bubble, BubbleContent } from "@/components/ui/bubble"
import {
  Message,
  MessageAvatar,
  MessageContent,
  MessageFooter,
  MessageHeader,
} from "@/components/ui/message"

export function MessageLongContent() {
  return (
    <div className="w-72 max-w-full">
      <Message>
        <MessageAvatar>
          <Avatar>
            <AvatarImage src="https://github.com/preetsuthar17.png" alt="" />
            <AvatarFallback>PS</AvatarFallback>
          </Avatar>
        </MessageAvatar>
        <MessageContent>
          <MessageHeader>
            <span className="truncate">
              Preet Suthar from the HextaUI design systems team
            </span>
          </MessageHeader>
          <Bubble variant="secondary">
            <BubbleContent>
              https://example.com/a/very/long/link/that/does/not/break/naturally/at/all
            </BubbleContent>
          </Bubble>
          <MessageFooter>
            <span className="truncate">
              Delivered to every device signed in to this workspace
            </span>
          </MessageFooter>
        </MessageContent>
      </Message>
    </div>
  )
}

右から左

start と end の側が入れ替わり、新しいメッセージは対応する角から広がります。

import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Bubble, BubbleContent } from "@/components/ui/bubble"
import {
  Message,
  MessageAvatar,
  MessageContent,
  MessageFooter,
  MessageGroup,
  MessageHeader,
} from "@/components/ui/message"

export function MessageRtl() {
  return (
    <div dir="rtl" className="w-full max-w-md">
      <MessageGroup>
        <Message>
          <MessageAvatar>
            <Avatar>
              <AvatarImage src="https://github.com/preetsuthar17.png" alt="" />
              <AvatarFallback>م</AvatarFallback>
            </Avatar>
          </MessageAvatar>
          <MessageContent>
            <MessageHeader>ميرا</MessageHeader>
            <Bubble variant="secondary">
              <BubbleContent>هل وصل المكون الجديد؟</BubbleContent>
            </Bubble>
          </MessageContent>
        </Message>
        <Message align="end">
          <MessageContent>
            <Bubble>
              <BubbleContent>نعم، إنه على الصفحة الآن.</BubbleContent>
            </Bubble>
            <MessageFooter>تمت القراءة</MessageFooter>
          </MessageContent>
        </Message>
      </MessageGroup>
    </div>
  )
}
  • メッセージはプレーンなコンテンツです。ライブチャットでは、リストを role="log" 要素で囲むと、スクリーンリーダーが新しいメッセージを届いた時点で読み上げます。
  • 配置だけでは誰が書いたかわからないので、送信者の名前はヘッダーか視覚的に非表示のテキストに入れてください。
  • 登場アニメーションは、リストが最初に描画された後に追加されたメッセージにだけ再生されます。視差効果の軽減が有効な場合はフェードインになります。

すべてのパーツは <div> を描画し、render を受け付けます。

スレッド。最初の描画後に追加されたメッセージはアニメーションで登場します。

属性説明
data-slot="message-group"CSS でスレッドを指定します。
プロパティ型デフォルト
align会話のどちら側か。バブルはこれを継承します。
"start" | "end""start"
animated最初の描画後に MessageGroup へ追加されると、せり上がって表示されます。
booleantrue
render
ReactElement | (props, state) => ReactElement<div>
属性説明
data-slot="message"CSS でメッセージを指定します。
data-align側。
data-enteringアニメーションで登場するメッセージに付きます。
属性説明
data-slot="message-avatar"最後のバブルに揃います。アイコンや画像だけの場合は32pxにサイズ調整されます。
属性説明
data-slot="message-content"ヘッダー、バブル、フッターを積み重ねます。
属性説明
data-slot="message-header"バブルの上に表示する名前と時刻。
data-slot="message-footer"下に表示する1行のステータス。アバターの位置がずれないよう、1行に収めてください。

使用しているブロック

Message の上に構築されるブロック。