# Chat Thread

> A single message thread inside a phone frame, with heart reactions and simulated replies.

A shadcn/ui block from the [@esuiss](https://suiss.app/ui?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/esuiss/chat-thread).

- Page, with a live preview: https://shadcn.exchange/r/esuiss/chat-thread
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/esuiss/chat-thread-light-ba3db122de84ccc8.png
- Registry item: https://suiss.app/ui/r/new-york-v4/chat-thread.json
- Categories: blocks, chat
- Listed: 2026-10-20, source last changed 2026-10-20

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/esuiss/chat-thread.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- Registry items: `@esuiss/avatar`, `@esuiss/bubble`, `@esuiss/button`, `@esuiss/input`, `@esuiss/message`, `@esuiss/message-scroller`

## Files

### `bases/radix/components/blocks/chat-thread.tsx`

```tsx
"use client"

import * as React from "react"

import { IconPlaceholder } from "@/app/(create)/components/icon-placeholder"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Bubble, BubbleContent, BubbleReactions } from "@/components/ui/bubble"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Message, MessageContent } from "@/components/ui/message"
import {
  MessageScroller,
  MessageScrollerContent,
  MessageScrollerItem,
  MessageScrollerProvider,
  MessageScrollerViewport,
} from "@/components/ui/message-scroller"

type Line = { id: string; from: "me" | "them"; text: string; liked?: boolean }

const opening: Line[] = [
  { id: "0", from: "them", text: "Is the weekend trip to the coast still on?" },
  { id: "1", from: "me", text: "Yes! We leave Friday evening" },
  { id: "2", from: "me", text: "I booked the boat tour, it leaves at 5 AM" },
  { id: "3", from: "them", text: "Great, I'll bring the camera" },
]

const replies = [
  "Sounds good",
  "Great, see you then!",
  "Ha, absolutely",
  "Let me check and get back to you",
]

export function ChatThread() {
  const [lines, setLines] = React.useState(opening)
  const [typing, setTyping] = React.useState(false)
  const [draft, setDraft] = React.useState("")
  const replyCount = React.useRef(0)
  const ready = draft.trim().length > 0

  const toggleLike = (id: string) =>
    setLines((current) =>
      current.map((line) =>
        line.id === id ? { ...line, liked: !line.liked } : line
      )
    )

  const send = (text: string) => {
    setLines((current) => [
      ...current,
      { id: String(current.length), from: "me", text },
    ])
    window.setTimeout(() => setTyping(true), 500)
    window.setTimeout(() => {
      setTyping(false)
      setLines((current) => {
        const text = replies[replyCount.current++ % replies.length]
        if (text === undefined) return current
        return [...current, { id: String(current.length), from: "them", text }]
      })
    }, 1800)
  }

  return (
    <section className="flex min-h-170 flex-col items-center justify-center gap-12 bg-muted sm:p-5 md:flex-row">
      <div className="relative flex h-170 w-full flex-col overflow-hidden bg-background sm:h-160 sm:w-82.5 sm:shrink-0 sm:rounded-[3rem] sm:border-10 sm:border-black sm:shadow-[0_0_0_1.5px_var(--color-muted-foreground),0_30px_60px_-20px_rgb(0_0_0/0.45)]">
        <span
          aria-hidden
          className="absolute top-2.5 left-1/2 z-10 hidden h-7 w-24 -translate-x-1/2 rounded-full bg-black sm:block"
        />
        <a
          href="#contact"
          className="flex shrink-0 flex-col items-center gap-1 border-b border-border bg-background/85 pt-6 pb-2.5 outline-none backdrop-blur-xl focus-visible:ring-3 focus-visible:ring-ring/50 sm:pt-12.5"
        >
          <Avatar className="size-12">
            <AvatarFallback className="bg-linear-to-br from-[oklch(0.78_0.1_150)] to-[oklch(0.6_0.14_180)] text-lg text-white dark:from-[oklch(0.78_0.1_150)] dark:to-[oklch(0.6_0.14_180)]">
              RC
            </AvatarFallback>
          </Avatar>
          <span className="flex items-center gap-0.5 text-xs font-semibold">
            Riley
            <IconPlaceholder
              lucide="ChevronRightIcon"
              tabler="IconChevronRight"
              hugeicons="ArrowRight01Icon"
              phosphor="CaretRightIcon"
              remixicon="RiArrowRightSLine"
              className="size-2.5 rtl:rotate-180"
            />
          </span>
        </a>
        <MessageScrollerProvider>
          <MessageScroller className="flex-1">
            <MessageScrollerViewport aria-label="Conversation with Riley">
              <MessageScrollerContent className="gap-1 px-3 py-3">
                <p className="mt-1 mb-2.5 text-center text-xs text-muted-foreground">
                  Today 9:30 AM
                </p>
                {lines.map((line) => (
                  <MessageScrollerItem key={line.id} messageId={line.id}>
                    <Message
                      align={line.from === "me" ? "end" : "start"}
                      className="transition-[opacity,translate] duration-700 ease-[cubic-bezier(0.32,0.72,0,1)] starting:translate-y-2 starting:opacity-0 motion-reduce:transition-none"
                    >
                      <MessageContent>
                        <Bubble
                          variant={line.from === "me" ? "default" : "muted"}
                          align={line.from === "me" ? "end" : "start"}
                          className="max-w-57.5"
                        >
                          <BubbleContent
                            onDoubleClick={() => toggleLike(line.id)}
                            onClick={(event) => {
                              if (event.detail === 0) toggleLike(line.id)
                            }}
                            aria-label={`${line.text}${line.liked ? ", liked" : ""}. Double-click to ${line.liked ? "remove the" : "add a"} heart.`}
                            className="select-none"
                            asChild
                          >
                            <button type="button">{line.text}</button>
                          </BubbleContent>
                          {line.liked && (
                            <BubbleReactions
                              side="top"
                              align={line.from === "me" ? "start" : "end"}
                              className="size-6.5 bg-primary p-0 text-primary-foreground ring-2 ring-background transition-[scale] duration-300 ease-[cubic-bezier(0.3,1.25,0.5,1)] starting:scale-0 dark:bg-primary"
                            >
                              <IconPlaceholder
                                lucide="HeartIcon"
                                tabler="IconHeart"
                                hugeicons="FavouriteIcon"
                                phosphor="HeartIcon"
                                remixicon="RiHeartLine"
                                className="size-3.25 fill-current"
                              />
                            </BubbleReactions>
                          )}
                        </Bubble>
                      </MessageContent>
                    </Message>
                  </MessageScrollerItem>
                ))}
                {typing && (
                  <MessageScrollerItem messageId="typing">
                    <Message>
                      <MessageContent>
                        <Bubble variant="muted" aria-label="Typing">
                          <BubbleContent className="flex gap-1 px-3.5 py-3">
                            {[0, 1, 2].map((dot) => (
                              <span
                                key={dot}
                                style={{ animationDelay: `${dot * 180}ms` }}
                                className="size-2 animate-[pulse_1.1s_ease-in-out_infinite] rounded-full bg-muted-foreground"
                              />
                            ))}
                          </BubbleContent>
                        </Bubble>
                      </MessageContent>
                    </Message>
                  </MessageScrollerItem>
                )}
              </MessageScrollerContent>
            </MessageScrollerViewport>
          </MessageScroller>
        </MessageScrollerProvider>
        <form
          onSubmit={(event) => {
            event.preventDefault()
            if (!ready) return
            send(draft.trim())
            setDraft("")
          }}
          className="flex shrink-0 items-center gap-2 px-2.5 pt-1.5 pb-6"
        >
          <Button
            type="button"
            variant="secondary"
            size="icon-sm"
            aria-label="Add attachment"
            className="text-muted-foreground"
          >
            <IconPlaceholder
              lucide="PlusIcon"
              tabler="IconPlus"
              hugeicons="PlusSignIcon"
              phosphor="PlusIcon"
              remixicon="RiAddLine"
              className="size-4.5"
            />
          </Button>
          <div className="flex min-h-8.5 flex-1 items-center rounded-full border border-input ps-3.5 pe-0.75 has-focus-visible:border-ring has-focus-visible:ring-3 has-focus-visible:ring-ring/50">
            <Input
              value={draft}
              onChange={(event) => setDraft(event.target.value)}
              placeholder="Message"
              aria-label="Message"
              className="h-8 flex-1 rounded-none bg-transparent px-0 focus-visible:border-transparent focus-visible:ring-0 aria-invalid:ring-0"
            />
            <Button
              type="submit"
              size="icon-xs"
              aria-label="Send"
              disabled={!ready}
              data-ready={ready ? "" : undefined}
              className="size-6.75 scale-0 transition-[scale] duration-300 ease-[cubic-bezier(0.3,1.25,0.5,1)] data-ready:scale-100 motion-reduce:transition-none"
            >
              <IconPlaceholder
                lucide="ArrowUpIcon"
                tabler="IconArrowUp"
                hugeicons="ArrowUp02Icon"
                phosphor="ArrowUpIcon"
                remixicon="RiArrowUpLine"
                className="size-4"
              />
            </Button>
          </div>
        </form>
      </div>
      <div className="flex max-w-75 flex-col gap-2 px-6 pb-10 max-md:text-center md:px-0 md:pb-0">
        <p className="text-sm font-semibold text-muted-foreground">Phone</p>
        <h2 className="text-4xl font-semibold tracking-tight">Messages.</h2>
        <p className="text-lg text-muted-foreground">
          Double-click a bubble to add a heart. Send a message and you&apos;ll
          get a reply.
        </p>
      </div>
    </section>
  )
}
```
