# Chat Thread

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

Chat Thread is an open-source block from suiss/ui for React, React Native, SwiftUI and Jetpack Compose. The @esuiss/ui CLI copies its source into your project, so you own the code.

Source: https://ui.suiss.app/docs/blocks/chat-thread

**React**

**Example**

```tsx title="components/blocks/chat-thread.tsx"
"use client"

import {
  ArrowUpIcon,
  CaretRightIcon,
  HeartIcon,
  PlusIcon,
} from "@phosphor-icons/react"
import * as React from "react"

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
            <CaretRightIcon 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
                            render={<button type="button" />}
                            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"
                          >
                            {line.text}
                          </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"
                            >
                              <HeartIcon 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"
          >
            <PlusIcon 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"
            >
              <ArrowUpIcon 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'll get a
          reply.
        </p>
      </div>
    </section>
  )
}
```

A single conversation in a phone frame, with a contact header, message bubbles, heart reactions and a composer. Double-clicking a bubble, or pressing Enter on it, toggles a heart. The frame appears from small tablets up while the smallest screens show the thread edge to edge, and on desktop a caption sits beside the phone. It works for showing off a chat feature on a marketing page or as the base of a one-to-one chat view.

## Installation

### CLI

```bash title="Terminal"
npx shadcn@latest add @esuiss/chat-thread
```

Works in any project with a `components.json`. See [Installation](https://ui.suiss.app/docs/installation#add-components) for the one-line registry setup. Or use the suiss/ui CLI:

```bash title="Terminal"
npx @esuiss/ui@latest add blocks chat-thread
```

### Manual

1. Install the dependencies:

```bash title="Terminal"
npm install @phosphor-icons/react
```

2. Add the pieces it builds on: [Avatar](https://ui.suiss.app/docs/components/avatar), [Bubble](https://ui.suiss.app/docs/components/bubble), [Button](https://ui.suiss.app/docs/components/button), [Input](https://ui.suiss.app/docs/components/input), [Message](https://ui.suiss.app/docs/components/message), [Message Scroller](https://ui.suiss.app/docs/components/message-scroller).

3. Copy the source into `components/blocks/chat-thread.tsx`:

```tsx title="components/blocks/chat-thread.tsx"
"use client"

import {
  ArrowUpIcon,
  CaretRightIcon,
  HeartIcon,
  PlusIcon,
} from "@phosphor-icons/react"
import * as React from "react"

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
            <CaretRightIcon 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
                            render={<button type="button" />}
                            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"
                          >
                            {line.text}
                          </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"
                            >
                              <HeartIcon 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"
          >
            <PlusIcon 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"
            >
              <ArrowUpIcon 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'll get a
          reply.
        </p>
      </div>
    </section>
  )
}
```

4. Update the import paths to match your project.

## Usage

```tsx
import { ChatThread } from "@/components/blocks/chat-thread"
```

```tsx
<ChatThread />
```

Replace `opening` with your initial messages, each with an id, sender, text and an optional liked flag. The `send` function fakes a typing indicator and a reply from `replies`, so wire it to your messaging backend instead. Update the contact name and initials, and point the `#contact` link at a real profile.

## Notes

Each bubble is a button whose label includes the message and how to toggle the heart, so reactions work from the keyboard.

**React Native, SwiftUI, Jetpack Compose**

![Preview](https://ui.suiss.app/previews/blocks-chat-thread--default.light.webp)

```tsx title="uniwind/components/blocks/chat-thread.tsx"
import {
  ArrowUpIcon,
  CaretRightIcon,
  HeartIcon,
  PlusIcon,
  XIcon,
} from "phosphor-react-native"
import * as React from "react"
import { I18nManager, Image, Pressable, View } from "react-native"
import {
  cancelAnimation,
  Easing,
  FadeInDown,
  ReduceMotion,
  useAnimatedStyle,
  useSharedValue,
  withDelay,
  withRepeat,
  withSequence,
  withTiming,
  ZoomIn,
} from "react-native-reanimated"
import { AnimatedView } from "@/components/ui/animated-view"
import {
  Attachment,
  AttachmentAction,
  AttachmentActions,
  AttachmentContent,
  AttachmentDescription,
  AttachmentMedia,
  AttachmentTitle,
} from "@/components/ui/attachment"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { BottomBar, BottomBarContent } from "@/components/ui/bottom-bar"
import { Bubble, BubbleContent, BubbleReactions } from "@/components/ui/bubble"
import { Button } from "@/components/ui/button"
import { Icon } from "@/components/ui/icon"
import {
  InputGroup,
  InputGroupAddon,
  InputGroupButton,
  InputGroupInput,
} from "@/components/ui/input-group"
import { Message, MessageContent } from "@/components/ui/message"
import {
  MessageScroller,
  MessageScrollerItem,
  MessageScrollerProvider,
  MessageScrollerViewport,
  useMessageScroller,
} from "@/components/ui/message-scroller"
import { Screen } from "@/components/ui/screen"
import { Text } from "@/components/ui/text"

type Photo = { name: string; size: string; image: string }

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

type Row =
  | { id: string; kind: "stamp" }
  | { id: string; kind: "typing" }
  | ({ kind: "line" } & Line)

const PHOTO: Photo = {
  name: "IMG_0142.jpeg",
  size: "1.8 MB",
  image:
    "https://images.unsplash.com/photo-1714972384975-fbd4f19b1bb3?w=200&q=80&auto=format&fit=crop",
}

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",
]

const DOUBLE_TAP = 300

const SEND_EASING = Easing.bezier(0.3, 1.25, 0.5, 1)

function TypingDot({ index }: { index: number }) {
  const opacity = useSharedValue(0.35)

  React.useEffect(() => {
    opacity.value = withDelay(
      index * 180,
      withRepeat(
        withSequence(
          withTiming(1, { duration: 550 }),
          withTiming(0.35, { duration: 550 })
        ),
        -1,
        false,
        undefined,
        ReduceMotion.System
      )
    )
    return () => cancelAnimation(opacity)
  }, [index, opacity])

  const animatedStyle = useAnimatedStyle(() => ({ opacity: opacity.value }))

  return (
    <AnimatedView
      className="size-2 rounded-full bg-muted-foreground"
      style={animatedStyle}
    />
  )
}

function TypingDots() {
  return (
    <Message>
      <MessageContent>
        <Bubble variant="muted" accessible accessibilityLabel="Typing">
          <BubbleContent className="flex-row gap-1 px-3.5 py-3">
            {[0, 1, 2].map((dot) => (
              <TypingDot key={dot} index={dot} />
            ))}
          </BubbleContent>
        </Bubble>
      </MessageContent>
    </Message>
  )
}

function PhotoAttachment({
  photo,
  onRemove,
}: {
  photo: Photo
  onRemove?: () => void
}) {
  return (
    <Attachment size="sm" className="w-56">
      <AttachmentMedia variant="image">
        <Image
          source={{ uri: photo.image }}
          resizeMode="cover"
          accessibilityIgnoresInvertColors
          className="size-full"
        />
      </AttachmentMedia>
      <AttachmentContent>
        <AttachmentTitle>{photo.name}</AttachmentTitle>
        <AttachmentDescription>{photo.size}</AttachmentDescription>
      </AttachmentContent>
      {onRemove ? (
        <AttachmentActions>
          <AttachmentAction
            accessibilityLabel="Remove attachment"
            onPress={onRemove}
          >
            <Icon as={XIcon} />
          </AttachmentAction>
        </AttachmentActions>
      ) : null}
    </Attachment>
  )
}

function Composer({
  onSend,
  onAttach,
}: {
  onSend: (text: string, photo: Photo | null) => void
  onAttach?: () => void
}) {
  const [draft, setDraft] = React.useState("")
  const [photo, setPhoto] = React.useState<Photo | null>(null)
  const { scrollToEnd } = useMessageScroller()
  const ready = draft.trim().length > 0 || photo !== null
  const scale = useSharedValue(ready ? 1 : 0)

  React.useEffect(() => {
    scale.value = withTiming(ready ? 1 : 0, {
      duration: 300,
      easing: SEND_EASING,
      reduceMotion: ReduceMotion.System,
    })
  }, [ready, scale])

  const sendStyle = useAnimatedStyle(() => ({
    transform: [{ scale: scale.value }],
  }))

  const send = () => {
    if (!ready) return
    onSend(draft.trim(), photo)
    setDraft("")
    setPhoto(null)
    requestAnimationFrame(() => scrollToEnd())
  }

  return (
    <BottomBar>
      <BottomBarContent className="gap-2 px-2.5 pt-1.5 pb-3">
        {photo ? (
          <AnimatedView
            entering={FadeInDown.duration(300).reduceMotion(
              ReduceMotion.System
            )}
            className="ps-10"
          >
            <PhotoAttachment photo={photo} onRemove={() => setPhoto(null)} />
          </AnimatedView>
        ) : null}
        <View className="flex-row items-center gap-2">
          <Button
            variant="secondary"
            size="icon-sm"
            accessibilityLabel="Add attachment"
            onPress={() => {
              onAttach?.()
              setPhoto(PHOTO)
            }}
          >
            <Icon
              as={PlusIcon}
              weight="bold"
              className="size-4.5 text-muted-foreground"
            />
          </Button>
          <InputGroup className="h-9 flex-1 rounded-full">
            <InputGroupInput
              value={draft}
              onChangeText={setDraft}
              placeholder="Message"
              accessibilityLabel="Message"
              returnKeyType="send"
              submitBehavior="submit"
              onSubmitEditing={send}
              className="ps-3.5"
            />
            <InputGroupAddon align="inline-end" className="pe-1">
              <AnimatedView style={sendStyle}>
                <InputGroupButton
                  variant="default"
                  size="icon-xs"
                  accessibilityLabel="Send"
                  disabled={!ready}
                  onPress={send}
                  className="size-6.75"
                >
                  <Icon as={ArrowUpIcon} weight="bold" className="size-4" />
                </InputGroupButton>
              </AnimatedView>
            </InputGroupAddon>
          </InputGroup>
        </View>
      </BottomBarContent>
    </BottomBar>
  )
}

function LineRow({
  line,
  onTap,
  onToggle,
}: {
  line: Line
  onTap: () => void
  onToggle: () => void
}) {
  const mine = line.from === "me"
  const label = `${line.text ?? line.photo?.name ?? ""}${line.liked ? ", liked" : ""}. Double-tap to ${line.liked ? "remove the" : "add a"} heart.`
  return (
    <AnimatedView
      entering={FadeInDown.duration(700)
        .easing(Easing.bezier(0.32, 0.72, 0, 1))
        .reduceMotion(ReduceMotion.System)}
    >
      <Message align={mine ? "end" : "start"}>
        <MessageContent>
          {line.photo ? <PhotoAttachment photo={line.photo} /> : null}
          {line.text ? (
            <Bubble
              variant={mine ? "default" : "muted"}
              align={mine ? "end" : "start"}
            >
              <BubbleContent
                onPress={onTap}
                accessibilityLabel={label}
                accessibilityActions={[
                  { name: "activate" },
                  {
                    name: "like",
                    label: line.liked ? "Remove heart" : "Add heart",
                  },
                ]}
                onAccessibilityAction={(event) => {
                  const name = event.nativeEvent.actionName
                  if (name === "activate" || name === "like") onToggle()
                }}
              >
                {line.text}
              </BubbleContent>
              {line.liked ? (
                <BubbleReactions
                  side="top"
                  align={mine ? "start" : "end"}
                  className="size-7 bg-primary p-0 dark:bg-primary"
                >
                  <AnimatedView
                    entering={ZoomIn.duration(300).reduceMotion(
                      ReduceMotion.System
                    )}
                  >
                    <Icon
                      as={HeartIcon}
                      weight="fill"
                      className="size-3.25 text-primary-foreground"
                    />
                  </AnimatedView>
                </BubbleReactions>
              ) : null}
            </Bubble>
          ) : null}
        </MessageContent>
      </Message>
    </AnimatedView>
  )
}

function ChatThread({
  onSend,
  onAttach,
  onOpenContact,
  onLikeChange,
}: {
  onSend?: (text: string, attachment?: string) => void
  onAttach?: () => void
  onOpenContact?: () => void
  onLikeChange?: (id: string, liked: boolean) => void
}) {
  const [lines, setLines] = React.useState(OPENING)
  const [typing, setTyping] = React.useState(false)
  const replyCount = React.useRef(0)
  const lastTap = React.useRef<{ id: string; time: number } | null>(null)
  const timers = React.useRef<ReturnType<typeof setTimeout>[]>([])

  React.useEffect(
    () => () => {
      for (const timer of timers.current) clearTimeout(timer)
    },
    []
  )

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

  const tap = (line: Line) => {
    const now = Date.now()
    const previous = lastTap.current
    if (
      previous &&
      previous.id === line.id &&
      now - previous.time < DOUBLE_TAP
    ) {
      lastTap.current = null
      toggleLike(line)
      return
    }
    lastTap.current = { id: line.id, time: now }
  }

  const send = (text: string, photo: Photo | null) => {
    onSend?.(text, photo?.name)
    setLines((current) => [
      ...current,
      {
        id: String(current.length),
        from: "me",
        text: text || undefined,
        photo: photo ?? undefined,
      },
    ])
    timers.current.push(setTimeout(() => setTyping(true), 500))
    timers.current.push(
      setTimeout(() => {
        setTyping(false)
        setLines((current) => {
          const reply = REPLIES[replyCount.current++ % REPLIES.length]
          if (reply === undefined) return current
          return [
            ...current,
            { id: String(current.length), from: "them", text: reply },
          ]
        })
      }, 1800)
    )
  }

  const rows: Row[] = [
    { id: "stamp", kind: "stamp" },
    ...lines.map((line) => ({ kind: "line" as const, ...line })),
    ...(typing ? [{ id: "typing", kind: "typing" as const }] : []),
  ]

  return (
    <Screen edges={["top", "right", "left"]}>
      <MessageScrollerProvider>
        <Pressable
          role="button"
          accessibilityLabel="Riley"
          accessibilityHint="Shows contact details"
          onPress={onOpenContact}
          className="items-center gap-1 border-b border-border bg-background pt-2 pb-2.5 active:opacity-80"
        >
          <Avatar size="lg" className="size-12">
            <AvatarFallback className="bg-green-500">
              <Text className="text-lg">RC</Text>
            </AvatarFallback>
          </Avatar>
          <View className="flex-row items-center gap-0.5">
            <Text className="text-xs font-semibold">Riley</Text>
            <Icon
              as={CaretRightIcon}
              weight="bold"
              mirrored={I18nManager.isRTL}
              className="size-2.5 text-foreground"
            />
          </View>
        </Pressable>
        <MessageScroller>
          <MessageScrollerViewport
            data={rows}
            keyExtractor={(row) => row.id}
            accessibilityLabel="Conversation with Riley"
            contentContainerClassName="gap-1 px-0.5 py-3"
            renderItem={({ item }) => {
              if (item.kind === "stamp") {
                return (
                  <MessageScrollerItem>
                    <View className="mt-1 mb-2.5 items-center gap-1 px-6">
                      <Text className="text-center text-xs text-muted-foreground">
                        Today 9:30 AM
                      </Text>
                      <Text className="text-center text-xs text-muted-foreground">
                        Double-tap a bubble to add a heart. Send a message and
                        you'll get a reply.
                      </Text>
                    </View>
                  </MessageScrollerItem>
                )
              }
              return (
                <MessageScrollerItem>
                  {item.kind === "typing" ? (
                    <TypingDots />
                  ) : (
                    <LineRow
                      line={item}
                      onTap={() => tap(item)}
                      onToggle={() => toggleLike(item)}
                    />
                  )}
                </MessageScrollerItem>
              )
            }}
          />
        </MessageScroller>
        <Composer onSend={send} onAttach={onAttach} />
      </MessageScrollerProvider>
    </Screen>
  )
}

export { ChatThread }
```

```swift title="swift/Blocks/ChatThread.swift"
import PhosphorSwift
import SwiftUI

public struct ChatThread: View {
  struct Line: Identifiable, Equatable {
    let id: String
    let fromMe: Bool
    let text: String
    var liked = false
  }

  enum Entry: Identifiable {
    case day
    case line(Line)
    case typing

    var id: String {
      switch self {
      case .day: "day"
      case .line(let line): line.id
      case .typing: "typing"
      }
    }
  }

  static var opening: [Line] {
    [
      Line(id: "0", fromMe: false, text: "Is the weekend trip to the coast still on?"),
      Line(id: "1", fromMe: true, text: "Yes! We leave Friday evening"),
      Line(id: "2", fromMe: true, text: "I booked the boat tour, it leaves at 5 AM"),
      Line(id: "3", fromMe: false, text: "Great, I'll bring the camera"),
    ]
  }

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

  private var onSend: ((String) -> Void)?
  private var onOpenContact: (() -> Void)?
  private var onAttach: (() -> Void)?
  private var onLikeChange: ((String, Bool) -> Void)?
  @State private var lines: [Line]
  @State private var typing: Bool
  @State private var draft: String
  @State private var replyCount = 0
  @State private var replyTasks: [Task<Void, Never>] = []
  @Environment(\.accessibilityReduceMotion) private var reduceMotion

  public init(
    onSend: ((_ text: String) -> Void)? = nil,
    onAttach: (() -> Void)? = nil,
    onOpenContact: (() -> Void)? = nil,
    onLikeChange: ((_ id: String, _ liked: Bool) -> Void)? = nil
  ) {
    self.init(lines: Self.opening)
    self.onSend = onSend
    self.onOpenContact = onOpenContact
    self.onAttach = onAttach
    self.onLikeChange = onLikeChange
  }

  init(lines: [Line], typing: Bool = false, draft: String = "") {
    _lines = State(initialValue: lines)
    _typing = State(initialValue: typing)
    _draft = State(initialValue: draft)
  }

  public var body: some View {
    var entries: [Entry] = [.day] + lines.map(Entry.line)
    if typing { entries.append(.typing) }
    let ready = !draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
    return Screen {
      Button {
        onOpenContact?()
      } label: {
        VStack(spacing: 4) {
          Text(verbatim: "RC")
            .font(.system(size: TextStyle.lg.size, weight: .semibold))
            .foregroundStyle(EsuissColor.primaryForeground)
            .frame(width: 48, height: 48)
            .background(Palette.green500, in: Circle())
          HStack(spacing: 2) {
            Text(verbatim: "Riley")
              .scaledFont(.xs, weight: .semibold)
            Ph.caretRight.bold
              .frame(width: 10, height: 10)
              .flipsForRightToLeftLayoutDirection(true)
          }
          .foregroundStyle(EsuissColor.foreground)
        }
        .frame(maxWidth: .infinity)
        .padding(.top, 8)
        .padding(.bottom, 10)
        .contentShape(Rectangle())
      }
      .buttonStyle(ChatThreadContactStyle())
      .accessibilityLabel(Text(verbatim: "Riley"))
      .accessibilityHint(Text("Show contact"))
      .overlay(alignment: .bottom) {
        EsuissColor.border.frame(height: 1)
      }
      MessageScroller(entries, spacing: 4) { entry in
        switch entry {
        case .day:
          Text("Today 9:30 AM")
            .scaledFont(.xs)
            .foregroundStyle(EsuissColor.mutedForeground)
            .frame(maxWidth: .infinity)
            .padding(.top, 4)
            .padding(.bottom, 10)
        case .line(let line):
          ChatThreadLine(line: line) {
            toggleLike(line.id)
          }
          .transition(.opacity.combined(with: .offset(y: 8)))
        case .typing:
          ChatThreadTyping()
        }
      }
      .accessibilityLabel(Text("Conversation with Riley"))
      .frame(maxHeight: .infinity)
      BottomBar {
        BottomBarContent {
          HStack(spacing: 8) {
            Button {
              onAttach?()
            } label: {
              Ph.plus.bold.frame(width: 18, height: 18)
            }
            .buttonStyle(.variant(.secondary, size: .iconSm))
            .accessibilityLabel(Text("Add attachment"))
            InputGroup {
              InputGroupInput("Message", text: $draft)
                .submitLabel(.send)
                .onSubmit(send)
              InputGroupAddon(align: .inlineEnd) {
                InputGroupButton(variant: .default, size: .iconXs, action: send) {
                  Ph.arrowUp.bold.frame(width: 14, height: 14)
                }
                .scaleEffect(ready ? 1 : 0)
                .disabled(!ready)
                .accessibilityLabel(Text("Send"))
                .animation(reduceMotion ? nil : .spring(response: 0.3, dampingFraction: 0.6), value: ready)
              }
            }
          }
        }
      }
    }
    .onDisappear {
      replyTasks.forEach { $0.cancel() }
      replyTasks.removeAll()
    }
  }

  private func toggleLike(_ id: String) {
    guard let index = lines.firstIndex(where: { $0.id == id }) else { return }
    withAnimation(reduceMotion ? nil : .spring(response: 0.3, dampingFraction: 0.6)) {
      lines[index].liked.toggle()
    }
    onLikeChange?(id, lines[index].liked)
  }

  private func send() {
    let text = draft.trimmingCharacters(in: .whitespacesAndNewlines)
    guard !text.isEmpty else { return }
    draft = ""
    onSend?(text)
    withAnimation(reduceMotion ? nil : .timingCurve(0.32, 0.72, 0, 1, duration: 0.7)) {
      lines.append(Line(id: String(lines.count), fromMe: true, text: text))
    }
    let reply = Self.replies[replyCount % Self.replies.count]
    replyCount += 1
    replyTasks.append(Task {
      guard (try? await Task.sleep(for: .milliseconds(500))) != nil else { return }
      typing = true
      guard (try? await Task.sleep(for: .milliseconds(1300))) != nil else { return }
      typing = false
      withAnimation(reduceMotion ? nil : .timingCurve(0.32, 0.72, 0, 1, duration: 0.7)) {
        lines.append(Line(id: String(lines.count), fromMe: false, text: reply))
      }
    })
  }
}

private struct ChatThreadLine: View {
  let line: ChatThread.Line
  let toggle: () -> Void

  var body: some View {
    Message(align: line.fromMe ? .end : .start) {
      MessageContent {
        Bubble(variant: line.fromMe ? .default : .muted, align: line.fromMe ? .end : .start) {
          BubbleContent(verbatim: line.text)
            .onTapGesture(count: 2, perform: toggle)
          if line.liked {
            BubbleReactions(side: .top, align: line.fromMe ? .start : .end) {
              Ph.heart.fill
                .frame(width: 13, height: 13)
                .foregroundStyle(EsuissColor.primaryForeground)
                .frame(width: 22, height: 22)
                .background(EsuissColor.primary, in: Circle())
                .padding(.horizontal, -6)
                .padding(.vertical, -2)
            }
            .transition(.scale)
          }
        }
      }
    }
    .accessibilityElement(children: .combine)
    .accessibilityValue(line.liked ? Text("Liked") : Text(verbatim: ""))
    .accessibilityAction(named: line.liked ? Text("Remove heart") : Text("Add heart"), toggle)
  }
}

private struct ChatThreadContactStyle: ButtonStyle {
  func makeBody(configuration: Configuration) -> some View {
    configuration.label.opacity(configuration.isPressed ? 0.8 : 1)
  }
}

private struct ChatThreadTyping: View {
  @Environment(\.accessibilityReduceMotion) private var reduceMotion

  var body: some View {
    Message {
      MessageContent {
        Bubble(variant: .muted) {
          BubbleContent {
            TimelineView(.animation(paused: reduceMotion)) { context in
              let time = context.date.timeIntervalSinceReferenceDate
              HStack(spacing: 4) {
                ForEach(0..<3, id: \.self) { dot in
                  Circle()
                    .fill(EsuissColor.mutedForeground)
                    .frame(width: 8, height: 8)
                    .opacity(reduceMotion ? 1 : 0.55 + 0.45 * abs(sin((time - Double(dot) * 0.18) * .pi / 1.1)))
                }
              }
              .padding(.vertical, 6)
            }
          }
        }
      }
    }
    .accessibilityElement(children: .ignore)
    .accessibilityLabel(Text("Typing"))
  }
}
```

```kt title="kotlin/blocks/ChatThread.kt"
package com.esuiss.ui.blocks

import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.onClick
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Bold
import com.adamglin.phosphoricons.Fill
import com.adamglin.phosphoricons.Regular
import com.adamglin.phosphoricons.bold.ArrowUp
import com.adamglin.phosphoricons.bold.CaretRight
import com.adamglin.phosphoricons.bold.Plus
import com.adamglin.phosphoricons.bold.X
import com.adamglin.phosphoricons.fill.Heart
import com.adamglin.phosphoricons.regular.Image
import com.esuiss.ui.components.Attachment
import com.esuiss.ui.components.AttachmentAction
import com.esuiss.ui.components.AttachmentActions
import com.esuiss.ui.components.AttachmentContent
import com.esuiss.ui.components.AttachmentDescription
import com.esuiss.ui.components.AttachmentMedia
import com.esuiss.ui.components.AttachmentMediaVariant
import com.esuiss.ui.components.AttachmentSize
import com.esuiss.ui.components.AttachmentTitle
import com.esuiss.ui.components.Avatar
import com.esuiss.ui.components.AvatarSize
import com.esuiss.ui.components.BottomBar
import com.esuiss.ui.components.Bubble
import com.esuiss.ui.components.BubbleAlign
import com.esuiss.ui.components.BubbleContent
import com.esuiss.ui.components.BubbleReactions
import com.esuiss.ui.components.BubbleReactionsSide
import com.esuiss.ui.components.BubbleVariant
import com.esuiss.ui.components.Button
import com.esuiss.ui.components.ButtonSize
import com.esuiss.ui.components.ButtonVariant
import com.esuiss.ui.components.Icon
import com.esuiss.ui.components.InputGroup
import com.esuiss.ui.components.InputGroupAddon
import com.esuiss.ui.components.InputGroupAddonAlign
import com.esuiss.ui.components.InputGroupButton
import com.esuiss.ui.components.InputGroupButtonSize
import com.esuiss.ui.components.InputGroupInput
import com.esuiss.ui.components.LocalContentStyle
import com.esuiss.ui.components.Message
import com.esuiss.ui.components.MessageAlign
import com.esuiss.ui.components.MessageContent
import com.esuiss.ui.components.MessageScroller
import com.esuiss.ui.components.Screen
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Palette
import com.esuiss.ui.theme.Typography
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch

private val ThreadPopEasing = CubicBezierEasing(0.3f, 1.25f, 0.5f, 1f)

private val ThreadEnterEasing = CubicBezierEasing(0.32f, 0.72f, 0f, 1f)

private class ThreadLine(
    val id: String,
    val mine: Boolean,
    val text: String,
    val liked: Boolean = false,
    val photo: Boolean = false,
)

private const val ThreadPhotoName = "IMG_0142.jpeg"

private const val ThreadPhotoSize = "1.8 MB"

private sealed interface ThreadRow {
    val key: String
}

private object ThreadStamp : ThreadRow {
    override val key = "stamp"
}

private object ThreadTyping : ThreadRow {
    override val key = "typing"
}

private class ThreadText(val line: ThreadLine) : ThreadRow {
    override val key get() = line.id
}

private val ThreadOpening = listOf(
    ThreadLine("0", false, "Is the weekend trip to the coast still on?"),
    ThreadLine("1", true, "Yes! We leave Friday evening"),
    ThreadLine("2", true, "I booked the boat tour, it leaves at 5 AM"),
    ThreadLine("3", false, "Great, I'll bring the camera"),
)

private val ThreadReplies = listOf(
    "Sounds good",
    "Great, see you then!",
    "Ha, absolutely",
    "Let me check and get back to you",
)

@Composable
fun ChatThread(
    modifier: Modifier = Modifier,
    onSend: (text: String, attachment: String?) -> Unit = { _, _ -> },
    onAttach: () -> Unit = {},
    onOpenContact: () -> Unit = {},
    onLikeChange: (id: String, liked: Boolean) -> Unit = { _, _ -> },
) {
    val colors = EsuissTheme.colors
    val lines = remember { mutableStateListOf<ThreadLine>().apply { addAll(ThreadOpening) } }
    var typing by remember { mutableStateOf(false) }
    var replyCount by remember { mutableIntStateOf(0) }
    val scope = rememberCoroutineScope()

    fun toggleLike(id: String) {
        val index = lines.indexOfFirst { it.id == id }
        if (index < 0) return
        val line = lines[index]
        onLikeChange(line.id, !line.liked)
        lines[index] = ThreadLine(line.id, line.mine, line.text, !line.liked, line.photo)
    }

    fun send(text: String, photo: Boolean) {
        onSend(text, if (photo) ThreadPhotoName else null)
        lines += ThreadLine(lines.size.toString(), true, text, photo = photo)
        scope.launch {
            delay(500)
            typing = true
            delay(1300)
            typing = false
            val reply = ThreadReplies[replyCount++ % ThreadReplies.size]
            lines += ThreadLine(lines.size.toString(), false, reply)
        }
    }

    val rows = buildList {
        add(ThreadStamp)
        lines.forEach { add(ThreadText(it)) }
        if (typing) add(ThreadTyping)
    }

    Screen(modifier) {
        Column(Modifier.fillMaxSize()) {
            ThreadContact(onOpenContact)
            MessageScroller(
                items = rows,
                key = { it.key },
                modifier = Modifier
                    .weight(1f)
                    .fillMaxWidth(),
                contentPadding = PaddingValues(horizontal = 12.dp, vertical = 12.dp),
                verticalArrangement = Arrangement.spacedBy(4.dp, Alignment.Bottom),
            ) { row ->
                when (row) {
                    ThreadStamp -> BasicText(
                        "Today 9:30 AM",
                        modifier = Modifier
                            .fillMaxWidth()
                            .padding(top = 4.dp, bottom = 10.dp),
                        style = Typography.xs.copy(color = colors.mutedForeground, textAlign = TextAlign.Center),
                    )
                    ThreadTyping -> ThreadTypingBubble()
                    is ThreadText -> ThreadBubble(row.line) { toggleLike(row.line.id) }
                }
            }
            ThreadComposer("Message", ::send, onAttach)
        }
    }
}

@Composable
private fun ThreadContact(onPress: () -> Unit) {
    val colors = EsuissTheme.colors
    val interaction = remember { MutableInteractionSource() }
    val pressed by interaction.collectIsPressedAsState()
    Column(
        modifier = Modifier
            .fillMaxWidth()
            .background(colors.background)
            .clickable(interactionSource = interaction, indication = null, role = Role.Button, onClick = onPress),
    ) {
        Column(
            modifier = Modifier
                .fillMaxWidth()
                .graphicsLayer { alpha = if (pressed) 0.8f else 1f }
                .padding(top = 8.dp, bottom = 10.dp),
            horizontalAlignment = Alignment.CenterHorizontally,
            verticalArrangement = Arrangement.spacedBy(4.dp),
        ) {
            Avatar(
                modifier = Modifier.size(48.dp),
                size = AvatarSize.Lg,
                fallback = {
                    Box(
                        Modifier
                            .fillMaxSize()
                            .background(Palette.green500, CircleShape),
                        contentAlignment = Alignment.Center,
                    ) {
                        BasicText("RC", style = Typography.lg.copy(color = Palette.white, fontWeight = FontWeight.Medium))
                    }
                },
            )
            Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(2.dp)) {
                BasicText("Riley", style = Typography.xs.copy(color = colors.foreground, fontWeight = FontWeight.SemiBold))
                Icon(PhosphorIcons.Bold.CaretRight, contentDescription = null, size = 10.dp, tint = colors.mutedForeground)
            }
        }
        Box(
            Modifier
                .fillMaxWidth()
                .height(1.dp)
                .background(colors.border),
        )
    }
}

@Composable
private fun ThreadBubble(line: ThreadLine, onToggleLike: () -> Unit) {
    val colors = EsuissTheme.colors
    val appear = remember { Animatable(0f) }
    val currentOnToggleLike by rememberUpdatedState(onToggleLike)
    LaunchedEffect(Unit) { appear.animateTo(1f, tween(700, easing = ThreadEnterEasing)) }
    val align = if (line.mine) BubbleAlign.End else BubbleAlign.Start
    Message(
        modifier = Modifier.graphicsLayer {
            alpha = appear.value
            translationY = (1f - appear.value) * 8.dp.toPx()
        },
        align = if (line.mine) MessageAlign.End else MessageAlign.Start,
    ) {
        MessageContent {
            if (line.photo) ThreadPhoto()
            if (line.text.isNotEmpty()) Bubble(
                modifier = Modifier.widthIn(max = 260.dp),
                variant = if (line.mine) BubbleVariant.Default else BubbleVariant.Muted,
                align = align,
            ) {
                BubbleContent(
                    modifier = Modifier
                        .pointerInput(Unit) { detectTapGestures(onDoubleTap = { currentOnToggleLike() }) }
                        .semantics(mergeDescendants = true) {
                            if (line.liked) stateDescription = "Liked"
                            onClick(label = if (line.liked) "Remove the heart" else "Add a heart") {
                                onToggleLike()
                                true
                            }
                        },
                ) {
                    BasicText(line.text, style = LocalContentStyle.current)
                }
                if (line.liked) {
                    val pop = remember { Animatable(0f) }
                    LaunchedEffect(Unit) { pop.animateTo(1f, tween(300, easing = ThreadPopEasing)) }
                    BubbleReactions(
                        modifier = Modifier.graphicsLayer {
                            scaleX = pop.value
                            scaleY = pop.value
                        },
                        side = BubbleReactionsSide.Top,
                        align = if (line.mine) BubbleAlign.Start else BubbleAlign.End,
                    ) {
                        Box(
                            Modifier
                                .size(22.dp)
                                .background(colors.primary, CircleShape),
                            contentAlignment = Alignment.Center,
                        ) {
                            Icon(PhosphorIcons.Fill.Heart, contentDescription = null, size = 13.dp, tint = colors.primaryForeground)
                        }
                    }
                }
            }
        }
    }
}

@Composable
private fun ThreadTypingBubble() {
    val colors = EsuissTheme.colors
    val transition = rememberInfiniteTransition(label = "typing")
    Message {
        MessageContent {
            Bubble(variant = BubbleVariant.Muted) {
                BubbleContent(Modifier.semantics { contentDescription = "Typing" }) {
                    Row(
                        modifier = Modifier.padding(vertical = 5.dp),
                        horizontalArrangement = Arrangement.spacedBy(4.dp),
                    ) {
                        repeat(3) { dot ->
                            val alpha by transition.animateFloat(
                                initialValue = 0.35f,
                                targetValue = 1f,
                                animationSpec = infiniteRepeatable(
                                    animation = tween(550, delayMillis = dot * 180),
                                    repeatMode = RepeatMode.Reverse,
                                ),
                                label = "dot",
                            )
                            Box(
                                Modifier
                                    .size(8.dp)
                                    .graphicsLayer { this.alpha = alpha }
                                    .background(colors.mutedForeground, CircleShape),
                            )
                        }
                    }
                }
            }
        }
    }
}

@Composable
private fun ThreadComposer(placeholder: String, onSend: (String, Boolean) -> Unit, onAttach: () -> Unit) {
    val colors = EsuissTheme.colors
    var draft by rememberSaveable { mutableStateOf("") }
    var photo by rememberSaveable { mutableStateOf(false) }
    val ready = draft.isNotBlank() || photo
    val scale by animateFloatAsState(if (ready) 1f else 0f, tween(300, easing = ThreadPopEasing), label = "send")

    fun submit() {
        if (!ready) return
        onSend(draft.trim(), photo)
        draft = ""
        photo = false
    }

    BottomBar {
        AnimatedVisibility(
            visible = photo,
            enter = fadeIn(tween(300)) + slideInVertically(tween(300)) { it / 3 },
            exit = fadeOut(tween(150)),
        ) {
            Box(Modifier.padding(start = 50.dp, top = 6.dp)) {
                ThreadPhoto(onRemove = { photo = false })
            }
        }
        Row(
            modifier = Modifier
                .fillMaxWidth()
                .padding(start = 10.dp, end = 10.dp, top = 6.dp, bottom = 10.dp),
            horizontalArrangement = Arrangement.spacedBy(8.dp),
            verticalAlignment = Alignment.CenterVertically,
        ) {
            Button(
                onClick = {
                    onAttach()
                    photo = true
                },
                variant = ButtonVariant.Secondary, size = ButtonSize.IconSm) {
                Icon(PhosphorIcons.Bold.Plus, contentDescription = "Add attachment", size = 18.dp, tint = colors.mutedForeground)
            }
            InputGroup(Modifier.weight(1f)) {
                InputGroupInput(
                    value = draft,
                    onValueChange = { draft = it },
                    placeholder = placeholder,
                    keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences, imeAction = ImeAction.Send),
                    keyboardActions = KeyboardActions(onSend = { submit() }),
                )
                InputGroupAddon(align = InputGroupAddonAlign.InlineEnd) {
                    InputGroupButton(
                        onClick = ::submit,
                        modifier = Modifier.graphicsLayer {
                            scaleX = scale
                            scaleY = scale
                        },
                        size = InputGroupButtonSize.IconXs,
                        variant = ButtonVariant.Default,
                        enabled = ready,
                    ) {
                        Icon(PhosphorIcons.Bold.ArrowUp, contentDescription = "Send", size = 14.dp)
                    }
                }
            }
        }
    }
}

@Composable
private fun ThreadPhoto(onRemove: (() -> Unit)? = null) {
    val colors = EsuissTheme.colors
    Attachment(modifier = Modifier.width(224.dp), size = AttachmentSize.Sm) {
        AttachmentMedia(variant = AttachmentMediaVariant.Icon) {
            Icon(PhosphorIcons.Regular.Image, contentDescription = null, tint = colors.mutedForeground)
        }
        AttachmentContent {
            AttachmentTitle(ThreadPhotoName)
            AttachmentDescription(ThreadPhotoSize)
        }
        if (onRemove != null) {
            AttachmentActions {
                AttachmentAction(onClick = onRemove, contentDescription = "Remove attachment") {
                    Icon(PhosphorIcons.Bold.X, contentDescription = null)
                }
            }
        }
    }
}
```

Composes `Screen`, `Avatar`, `Message`, `Bubble` with `BubbleReactions`, `MessageScroller`, `Attachment`, `InputGroup`, `Button` and `BottomBar`. Double-tapping a bubble toggles a heart, the attach button adds a sample photo preview that can be removed before sending, and each sent message gets a sample reply after a typing indicator.

## Installation

**React Native**

```bash title="Terminal"
npx shadcn@latest add @esuiss-rn/chat-thread
```

See [Installation](https://ui.suiss.app/docs/installation) for the `components.json` setup. Or use the suiss/ui CLI:

```bash title="Terminal"
npx @esuiss/ui@latest add chat-thread
```

**SwiftUI**

```bash title="Terminal"
npx @esuiss/ui@latest add chat-thread --platform swift
```

**Jetpack Compose**

```bash title="Terminal"
npx @esuiss/ui@latest add chat-thread --platform kotlin
```

The CLI also adds what it builds on: [Attachment](https://ui.suiss.app/docs/components/attachment), [Avatar](https://ui.suiss.app/docs/components/avatar), [Bottom Bar](https://ui.suiss.app/docs/components/bottom-bar), [Bubble](https://ui.suiss.app/docs/components/bubble), [Button](https://ui.suiss.app/docs/components/button), [Icon](https://ui.suiss.app/docs/components/icon), [Input Group](https://ui.suiss.app/docs/components/input-group), [Message](https://ui.suiss.app/docs/components/message), [Message Scroller](https://ui.suiss.app/docs/components/message-scroller), [Screen](https://ui.suiss.app/docs/components/screen), [Text](https://ui.suiss.app/docs/components/text). On React Native it also adds the animated view `components/ui/animated-view.tsx`.

### Manual

**React Native**

**Uniwind**

1. Install the dependencies:

```bash title="Terminal"
npx expo install phosphor-react-native react-native-reanimated react-native-svg react-native-worklets
```

2. Add the pieces it builds on: [Attachment](https://ui.suiss.app/docs/components/attachment), [Avatar](https://ui.suiss.app/docs/components/avatar), [Bottom Bar](https://ui.suiss.app/docs/components/bottom-bar), [Bubble](https://ui.suiss.app/docs/components/bubble), [Button](https://ui.suiss.app/docs/components/button), [Icon](https://ui.suiss.app/docs/components/icon), [Input Group](https://ui.suiss.app/docs/components/input-group), [Message](https://ui.suiss.app/docs/components/message), [Message Scroller](https://ui.suiss.app/docs/components/message-scroller), [Screen](https://ui.suiss.app/docs/components/screen), [Text](https://ui.suiss.app/docs/components/text), and the animated view `components/ui/animated-view.tsx` from the next step.

3. Copy the animated view into `components/ui/animated-view.tsx`:

- `uniwind/components/ui/animated-view.tsx`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/uniwind/components/ui/animated-view.tsx)

4. Copy the source into `components/blocks/chat-thread.tsx`:

- `uniwind/components/blocks/chat-thread.tsx`: shown above

5. Update the import paths to match your project.

**NativeWind**

1. Install the dependencies:

```bash title="Terminal"
npx expo install phosphor-react-native react-native-reanimated react-native-svg react-native-worklets
```

2. Add the pieces it builds on: [Attachment](https://ui.suiss.app/docs/components/attachment), [Avatar](https://ui.suiss.app/docs/components/avatar), [Bottom Bar](https://ui.suiss.app/docs/components/bottom-bar), [Bubble](https://ui.suiss.app/docs/components/bubble), [Button](https://ui.suiss.app/docs/components/button), [Icon](https://ui.suiss.app/docs/components/icon), [Input Group](https://ui.suiss.app/docs/components/input-group), [Message](https://ui.suiss.app/docs/components/message), [Message Scroller](https://ui.suiss.app/docs/components/message-scroller), [Screen](https://ui.suiss.app/docs/components/screen), [Text](https://ui.suiss.app/docs/components/text), and the animated view `components/ui/animated-view.tsx` from the next step.

3. Copy the animated view into `components/ui/animated-view.tsx`:

- `nativewind/components/ui/animated-view.tsx`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/nativewind/components/ui/animated-view.tsx)

4. Copy the source into `components/blocks/chat-thread.tsx`:

```tsx title="nativewind/components/blocks/chat-thread.tsx"
import {
  ArrowUpIcon,
  CaretRightIcon,
  HeartIcon,
  PlusIcon,
  XIcon,
} from "phosphor-react-native"
import * as React from "react"
import { I18nManager, Image, Pressable, View } from "react-native"
import {
  cancelAnimation,
  Easing,
  FadeInDown,
  ReduceMotion,
  useAnimatedStyle,
  useSharedValue,
  withDelay,
  withRepeat,
  withSequence,
  withTiming,
  ZoomIn,
} from "react-native-reanimated"
import { AnimatedView } from "@/components/ui/animated-view"
import {
  Attachment,
  AttachmentAction,
  AttachmentActions,
  AttachmentContent,
  AttachmentDescription,
  AttachmentMedia,
  AttachmentTitle,
} from "@/components/ui/attachment"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { BottomBar, BottomBarContent } from "@/components/ui/bottom-bar"
import { Bubble, BubbleContent, BubbleReactions } from "@/components/ui/bubble"
import { Button } from "@/components/ui/button"
import { Icon } from "@/components/ui/icon"
import {
  InputGroup,
  InputGroupAddon,
  InputGroupButton,
  InputGroupInput,
} from "@/components/ui/input-group"
import { Message, MessageContent } from "@/components/ui/message"
import {
  MessageScroller,
  MessageScrollerItem,
  MessageScrollerProvider,
  MessageScrollerViewport,
  useMessageScroller,
} from "@/components/ui/message-scroller"
import { Screen } from "@/components/ui/screen"
import { Text } from "@/components/ui/text"

type Photo = { name: string; size: string; image: string }

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

type Row =
  | { id: string; kind: "stamp" }
  | { id: string; kind: "typing" }
  | ({ kind: "line" } & Line)

const PHOTO: Photo = {
  name: "IMG_0142.jpeg",
  size: "1.8 MB",
  image:
    "https://images.unsplash.com/photo-1714972384975-fbd4f19b1bb3?w=200&q=80&auto=format&fit=crop",
}

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",
]

const DOUBLE_TAP = 300

const SEND_EASING = Easing.bezier(0.3, 1.25, 0.5, 1)

function TypingDot({ index }: { index: number }) {
  const opacity = useSharedValue(0.35)

  React.useEffect(() => {
    opacity.value = withDelay(
      index * 180,
      withRepeat(
        withSequence(
          withTiming(1, { duration: 550 }),
          withTiming(0.35, { duration: 550 })
        ),
        -1,
        false,
        undefined,
        ReduceMotion.System
      )
    )
    return () => cancelAnimation(opacity)
  }, [index, opacity])

  const animatedStyle = useAnimatedStyle(() => ({ opacity: opacity.value }))

  return (
    <AnimatedView
      className="size-2 rounded-full bg-muted-foreground"
      style={animatedStyle}
    />
  )
}

function TypingDots() {
  return (
    <Message>
      <MessageContent>
        <Bubble variant="muted" accessible accessibilityLabel="Typing">
          <BubbleContent className="flex-row gap-1 px-3.5 py-3">
            {[0, 1, 2].map((dot) => (
              <TypingDot key={dot} index={dot} />
            ))}
          </BubbleContent>
        </Bubble>
      </MessageContent>
    </Message>
  )
}

function PhotoAttachment({
  photo,
  onRemove,
}: {
  photo: Photo
  onRemove?: () => void
}) {
  return (
    <Attachment size="sm" className="w-56">
      <AttachmentMedia variant="image">
        <Image
          source={{ uri: photo.image }}
          resizeMode="cover"
          accessibilityIgnoresInvertColors
          className="size-full"
        />
      </AttachmentMedia>
      <AttachmentContent>
        <AttachmentTitle>{photo.name}</AttachmentTitle>
        <AttachmentDescription>{photo.size}</AttachmentDescription>
      </AttachmentContent>
      {onRemove ? (
        <AttachmentActions>
          <AttachmentAction
            accessibilityLabel="Remove attachment"
            onPress={onRemove}
          >
            <Icon as={XIcon} />
          </AttachmentAction>
        </AttachmentActions>
      ) : null}
    </Attachment>
  )
}

function Composer({
  onSend,
  onAttach,
}: {
  onSend: (text: string, photo: Photo | null) => void
  onAttach?: () => void
}) {
  const [draft, setDraft] = React.useState("")
  const [photo, setPhoto] = React.useState<Photo | null>(null)
  const { scrollToEnd } = useMessageScroller()
  const ready = draft.trim().length > 0 || photo !== null
  const scale = useSharedValue(ready ? 1 : 0)

  React.useEffect(() => {
    scale.value = withTiming(ready ? 1 : 0, {
      duration: 300,
      easing: SEND_EASING,
      reduceMotion: ReduceMotion.System,
    })
  }, [ready, scale])

  const sendStyle = useAnimatedStyle(() => ({
    transform: [{ scale: scale.value }],
  }))

  const send = () => {
    if (!ready) return
    onSend(draft.trim(), photo)
    setDraft("")
    setPhoto(null)
    requestAnimationFrame(() => scrollToEnd())
  }

  return (
    <BottomBar>
      <BottomBarContent className="gap-2 px-2.5 pt-1.5 pb-3">
        {photo ? (
          <AnimatedView
            entering={FadeInDown.duration(300).reduceMotion(
              ReduceMotion.System
            )}
            className="ps-10"
          >
            <PhotoAttachment photo={photo} onRemove={() => setPhoto(null)} />
          </AnimatedView>
        ) : null}
        <View className="flex-row items-center gap-2">
          <Button
            variant="secondary"
            size="icon-sm"
            accessibilityLabel="Add attachment"
            onPress={() => {
              onAttach?.()
              setPhoto(PHOTO)
            }}
          >
            <Icon
              as={PlusIcon}
              weight="bold"
              className="size-[1.125rem] text-muted-foreground"
            />
          </Button>
          <InputGroup className="h-9 flex-1 rounded-full">
            <InputGroupInput
              value={draft}
              onChangeText={setDraft}
              placeholder="Message"
              accessibilityLabel="Message"
              returnKeyType="send"
              submitBehavior="submit"
              onSubmitEditing={send}
              className="ps-3.5"
            />
            <InputGroupAddon align="inline-end" className="pe-1">
              <AnimatedView style={sendStyle}>
                <InputGroupButton
                  variant="default"
                  size="icon-xs"
                  accessibilityLabel="Send"
                  disabled={!ready}
                  onPress={send}
                  className="size-[1.6875rem]"
                >
                  <Icon as={ArrowUpIcon} weight="bold" className="size-4" />
                </InputGroupButton>
              </AnimatedView>
            </InputGroupAddon>
          </InputGroup>
        </View>
      </BottomBarContent>
    </BottomBar>
  )
}

function LineRow({
  line,
  onTap,
  onToggle,
}: {
  line: Line
  onTap: () => void
  onToggle: () => void
}) {
  const mine = line.from === "me"
  const label = `${line.text ?? line.photo?.name ?? ""}${line.liked ? ", liked" : ""}. Double-tap to ${line.liked ? "remove the" : "add a"} heart.`
  return (
    <AnimatedView
      entering={FadeInDown.duration(700)
        .easing(Easing.bezier(0.32, 0.72, 0, 1))
        .reduceMotion(ReduceMotion.System)}
    >
      <Message align={mine ? "end" : "start"}>
        <MessageContent>
          {line.photo ? <PhotoAttachment photo={line.photo} /> : null}
          {line.text ? (
            <Bubble
              variant={mine ? "default" : "muted"}
              align={mine ? "end" : "start"}
            >
              <BubbleContent
                onPress={onTap}
                accessibilityLabel={label}
                accessibilityActions={[
                  { name: "activate" },
                  {
                    name: "like",
                    label: line.liked ? "Remove heart" : "Add heart",
                  },
                ]}
                onAccessibilityAction={(event) => {
                  const name = event.nativeEvent.actionName
                  if (name === "activate" || name === "like") onToggle()
                }}
              >
                {line.text}
              </BubbleContent>
              {line.liked ? (
                <BubbleReactions
                  side="top"
                  align={mine ? "start" : "end"}
                  className="size-7 bg-primary p-0 dark:bg-primary"
                >
                  <AnimatedView
                    entering={ZoomIn.duration(300).reduceMotion(
                      ReduceMotion.System
                    )}
                  >
                    <Icon
                      as={HeartIcon}
                      weight="fill"
                      className="size-[0.8125rem] text-primary-foreground"
                    />
                  </AnimatedView>
                </BubbleReactions>
              ) : null}
            </Bubble>
          ) : null}
        </MessageContent>
      </Message>
    </AnimatedView>
  )
}

function ChatThread({
  onSend,
  onAttach,
  onOpenContact,
  onLikeChange,
}: {
  onSend?: (text: string, attachment?: string) => void
  onAttach?: () => void
  onOpenContact?: () => void
  onLikeChange?: (id: string, liked: boolean) => void
}) {
  const [lines, setLines] = React.useState(OPENING)
  const [typing, setTyping] = React.useState(false)
  const replyCount = React.useRef(0)
  const lastTap = React.useRef<{ id: string; time: number } | null>(null)
  const timers = React.useRef<ReturnType<typeof setTimeout>[]>([])

  React.useEffect(
    () => () => {
      for (const timer of timers.current) clearTimeout(timer)
    },
    []
  )

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

  const tap = (line: Line) => {
    const now = Date.now()
    const previous = lastTap.current
    if (
      previous &&
      previous.id === line.id &&
      now - previous.time < DOUBLE_TAP
    ) {
      lastTap.current = null
      toggleLike(line)
      return
    }
    lastTap.current = { id: line.id, time: now }
  }

  const send = (text: string, photo: Photo | null) => {
    onSend?.(text, photo?.name)
    setLines((current) => [
      ...current,
      {
        id: String(current.length),
        from: "me",
        text: text || undefined,
        photo: photo ?? undefined,
      },
    ])
    timers.current.push(setTimeout(() => setTyping(true), 500))
    timers.current.push(
      setTimeout(() => {
        setTyping(false)
        setLines((current) => {
          const reply = REPLIES[replyCount.current++ % REPLIES.length]
          if (reply === undefined) return current
          return [
            ...current,
            { id: String(current.length), from: "them", text: reply },
          ]
        })
      }, 1800)
    )
  }

  const rows: Row[] = [
    { id: "stamp", kind: "stamp" },
    ...lines.map((line) => ({ kind: "line" as const, ...line })),
    ...(typing ? [{ id: "typing", kind: "typing" as const }] : []),
  ]

  return (
    <Screen edges={["top", "right", "left"]}>
      <MessageScrollerProvider>
        <Pressable
          role="button"
          accessibilityLabel="Riley"
          accessibilityHint="Shows contact details"
          onPress={onOpenContact}
          className="items-center gap-1 border-b border-border bg-background pt-2 pb-2.5 active:opacity-80"
        >
          <Avatar size="lg" className="size-12">
            <AvatarFallback className="bg-green-500">
              <Text className="text-lg">RC</Text>
            </AvatarFallback>
          </Avatar>
          <View className="flex-row items-center gap-0.5">
            <Text className="text-xs font-semibold">Riley</Text>
            <Icon
              as={CaretRightIcon}
              weight="bold"
              mirrored={I18nManager.isRTL}
              className="size-2.5 text-foreground"
            />
          </View>
        </Pressable>
        <MessageScroller>
          <MessageScrollerViewport
            data={rows}
            keyExtractor={(row) => row.id}
            accessibilityLabel="Conversation with Riley"
            contentContainerClassName="gap-1 px-0.5 py-3"
            renderItem={({ item }) => {
              if (item.kind === "stamp") {
                return (
                  <MessageScrollerItem>
                    <View className="mt-1 mb-2.5 items-center gap-1 px-6">
                      <Text className="text-center text-xs text-muted-foreground">
                        Today 9:30 AM
                      </Text>
                      <Text className="text-center text-xs text-muted-foreground">
                        Double-tap a bubble to add a heart. Send a message and
                        you'll get a reply.
                      </Text>
                    </View>
                  </MessageScrollerItem>
                )
              }
              return (
                <MessageScrollerItem>
                  {item.kind === "typing" ? (
                    <TypingDots />
                  ) : (
                    <LineRow
                      line={item}
                      onTap={() => tap(item)}
                      onToggle={() => toggleLike(item)}
                    />
                  )}
                </MessageScrollerItem>
              )
            }}
          />
        </MessageScroller>
        <Composer onSend={send} onAttach={onAttach} />
      </MessageScrollerProvider>
    </Screen>
  )
}

export { ChatThread }
```

5. Update the import paths to match your project.

**StyleSheet**

1. Install the dependencies:

```bash title="Terminal"
npx expo install phosphor-react-native react-native-reanimated react-native-svg react-native-worklets
```

2. Add the pieces it builds on: [Attachment](https://ui.suiss.app/docs/components/attachment), [Avatar](https://ui.suiss.app/docs/components/avatar), [Bottom Bar](https://ui.suiss.app/docs/components/bottom-bar), [Bubble](https://ui.suiss.app/docs/components/bubble), [Button](https://ui.suiss.app/docs/components/button), [Icon](https://ui.suiss.app/docs/components/icon), [Input Group](https://ui.suiss.app/docs/components/input-group), [Message](https://ui.suiss.app/docs/components/message), [Message Scroller](https://ui.suiss.app/docs/components/message-scroller), [Screen](https://ui.suiss.app/docs/components/screen), [Text](https://ui.suiss.app/docs/components/text), and the animated view `components/ui/animated-view.tsx` from the next step.

3. Copy the animated view into `components/ui/animated-view.tsx`:

- `stylesheet/components/ui/animated-view.tsx`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/stylesheet/components/ui/animated-view.tsx)

4. Copy the source into `components/blocks/chat-thread.tsx`:

```tsx title="stylesheet/components/blocks/chat-thread.tsx"
import {
  ArrowUpIcon,
  CaretRightIcon,
  HeartIcon,
  PlusIcon,
  XIcon,
} from "phosphor-react-native"
import * as React from "react"
import { I18nManager, Image, Pressable, StyleSheet, View } from "react-native"
import {
  cancelAnimation,
  Easing,
  FadeInDown,
  ReduceMotion,
  useAnimatedStyle,
  useSharedValue,
  withDelay,
  withRepeat,
  withSequence,
  withTiming,
  ZoomIn,
} from "react-native-reanimated"
import { AnimatedView } from "@/components/ui/animated-view"
import {
  Attachment,
  AttachmentAction,
  AttachmentActions,
  AttachmentContent,
  AttachmentDescription,
  AttachmentMedia,
  AttachmentTitle,
} from "@/components/ui/attachment"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { BottomBar, BottomBarContent } from "@/components/ui/bottom-bar"
import { Bubble, BubbleContent, BubbleReactions } from "@/components/ui/bubble"
import { Button } from "@/components/ui/button"
import { Icon } from "@/components/ui/icon"
import {
  InputGroup,
  InputGroupAddon,
  InputGroupButton,
  InputGroupInput,
} from "@/components/ui/input-group"
import { Message, MessageContent } from "@/components/ui/message"
import {
  MessageScroller,
  MessageScrollerItem,
  MessageScrollerProvider,
  MessageScrollerViewport,
  useMessageScroller,
} from "@/components/ui/message-scroller"
import { Screen } from "@/components/ui/screen"
import { Text } from "@/components/ui/text"
import { fontSize, palette, radius, useTheme } from "@/components/ui/theme"

type Photo = { name: string; size: string; image: string }

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

type Row =
  | { id: string; kind: "stamp" }
  | { id: string; kind: "typing" }
  | ({ kind: "line" } & Line)

const PHOTO: Photo = {
  name: "IMG_0142.jpeg",
  size: "1.8 MB",
  image:
    "https://images.unsplash.com/photo-1714972384975-fbd4f19b1bb3?w=200&q=80&auto=format&fit=crop",
}

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",
]

const DOUBLE_TAP = 300

const SEND_EASING = Easing.bezier(0.3, 1.25, 0.5, 1)

const styles = StyleSheet.create({
  dot: { width: 8, height: 8, borderRadius: radius.full },
  dots: {
    flexDirection: "row",
    gap: 4,
    paddingHorizontal: 14,
    paddingVertical: 12,
  },
  attachment: { width: 224 },
  image: { width: "100%", height: "100%" },
  composer: { gap: 8, paddingHorizontal: 10, paddingTop: 6, paddingBottom: 12 },
  pending: { paddingStart: 40 },
  composerRow: { flexDirection: "row", alignItems: "center", gap: 8 },
  group: { flex: 1, height: 36, borderRadius: radius.full },
  input: { paddingStart: 14 },
  addon: { paddingEnd: 4 },
  send: { width: 27, height: 27 },
  reaction: { width: 28, height: 28, padding: 0, paddingHorizontal: 0 },
  contact: {
    alignItems: "center",
    gap: 4,
    borderBottomWidth: 1,
    paddingTop: 8,
    paddingBottom: 10,
  },
  avatar: { width: 48, height: 48 },
  name: { flexDirection: "row", alignItems: "center", gap: 2 },
  list: { gap: 4, paddingHorizontal: 2, paddingVertical: 12 },
  stamp: {
    marginTop: 4,
    marginBottom: 10,
    alignItems: "center",
    gap: 4,
    paddingHorizontal: 24,
  },
  stampText: { ...fontSize.xs, textAlign: "center" },
})

function TypingDot({ index }: { index: number }) {
  const { colors } = useTheme()
  const opacity = useSharedValue(0.35)

  React.useEffect(() => {
    opacity.value = withDelay(
      index * 180,
      withRepeat(
        withSequence(
          withTiming(1, { duration: 550 }),
          withTiming(0.35, { duration: 550 })
        ),
        -1,
        false,
        undefined,
        ReduceMotion.System
      )
    )
    return () => cancelAnimation(opacity)
  }, [index, opacity])

  const animatedStyle = useAnimatedStyle(() => ({ opacity: opacity.value }))

  return (
    <AnimatedView
      style={[
        styles.dot,
        { backgroundColor: colors.mutedForeground },
        animatedStyle,
      ]}
    />
  )
}

function TypingDots() {
  return (
    <Message>
      <MessageContent>
        <Bubble variant="muted" accessible accessibilityLabel="Typing">
          <BubbleContent style={styles.dots}>
            {[0, 1, 2].map((dot) => (
              <TypingDot key={dot} index={dot} />
            ))}
          </BubbleContent>
        </Bubble>
      </MessageContent>
    </Message>
  )
}

function PhotoAttachment({
  photo,
  onRemove,
}: {
  photo: Photo
  onRemove?: () => void
}) {
  return (
    <Attachment size="sm" style={styles.attachment}>
      <AttachmentMedia variant="image">
        <Image
          source={{ uri: photo.image }}
          resizeMode="cover"
          accessibilityIgnoresInvertColors
          style={styles.image}
        />
      </AttachmentMedia>
      <AttachmentContent>
        <AttachmentTitle>{photo.name}</AttachmentTitle>
        <AttachmentDescription>{photo.size}</AttachmentDescription>
      </AttachmentContent>
      {onRemove ? (
        <AttachmentActions>
          <AttachmentAction
            accessibilityLabel="Remove attachment"
            onPress={onRemove}
          >
            <Icon as={XIcon} />
          </AttachmentAction>
        </AttachmentActions>
      ) : null}
    </Attachment>
  )
}

function Composer({
  onSend,
  onAttach,
}: {
  onSend: (text: string, photo: Photo | null) => void
  onAttach?: () => void
}) {
  const { colors } = useTheme()
  const [draft, setDraft] = React.useState("")
  const [photo, setPhoto] = React.useState<Photo | null>(null)
  const { scrollToEnd } = useMessageScroller()
  const ready = draft.trim().length > 0 || photo !== null
  const scale = useSharedValue(ready ? 1 : 0)

  React.useEffect(() => {
    scale.value = withTiming(ready ? 1 : 0, {
      duration: 300,
      easing: SEND_EASING,
      reduceMotion: ReduceMotion.System,
    })
  }, [ready, scale])

  const sendStyle = useAnimatedStyle(() => ({
    transform: [{ scale: scale.value }],
  }))

  const send = () => {
    if (!ready) return
    onSend(draft.trim(), photo)
    setDraft("")
    setPhoto(null)
    requestAnimationFrame(() => scrollToEnd())
  }

  return (
    <BottomBar>
      <BottomBarContent style={styles.composer}>
        {photo ? (
          <AnimatedView
            entering={FadeInDown.duration(300).reduceMotion(
              ReduceMotion.System
            )}
            style={styles.pending}
          >
            <PhotoAttachment photo={photo} onRemove={() => setPhoto(null)} />
          </AnimatedView>
        ) : null}
        <View style={styles.composerRow}>
          <Button
            variant="secondary"
            size="icon-sm"
            accessibilityLabel="Add attachment"
            onPress={() => {
              onAttach?.()
              setPhoto(PHOTO)
            }}
          >
            <Icon
              as={PlusIcon}
              weight="bold"
              size={18}
              color={colors.mutedForeground}
            />
          </Button>
          <InputGroup style={styles.group}>
            <InputGroupInput
              value={draft}
              onChangeText={setDraft}
              placeholder="Message"
              accessibilityLabel="Message"
              returnKeyType="send"
              submitBehavior="submit"
              onSubmitEditing={send}
              style={styles.input}
            />
            <InputGroupAddon align="inline-end" style={styles.addon}>
              <AnimatedView style={sendStyle}>
                <InputGroupButton
                  variant="default"
                  size="icon-xs"
                  accessibilityLabel="Send"
                  disabled={!ready}
                  onPress={send}
                  style={styles.send}
                >
                  <Icon as={ArrowUpIcon} weight="bold" size={16} />
                </InputGroupButton>
              </AnimatedView>
            </InputGroupAddon>
          </InputGroup>
        </View>
      </BottomBarContent>
    </BottomBar>
  )
}

function LineRow({
  line,
  onTap,
  onToggle,
}: {
  line: Line
  onTap: () => void
  onToggle: () => void
}) {
  const { colors } = useTheme()
  const mine = line.from === "me"
  const label = `${line.text ?? line.photo?.name ?? ""}${line.liked ? ", liked" : ""}. Double-tap to ${line.liked ? "remove the" : "add a"} heart.`
  return (
    <AnimatedView
      entering={FadeInDown.duration(700)
        .easing(Easing.bezier(0.32, 0.72, 0, 1))
        .reduceMotion(ReduceMotion.System)}
    >
      <Message align={mine ? "end" : "start"}>
        <MessageContent>
          {line.photo ? <PhotoAttachment photo={line.photo} /> : null}
          {line.text ? (
            <Bubble
              variant={mine ? "default" : "muted"}
              align={mine ? "end" : "start"}
            >
              <BubbleContent
                onPress={onTap}
                accessibilityLabel={label}
                accessibilityActions={[
                  { name: "activate" },
                  {
                    name: "like",
                    label: line.liked ? "Remove heart" : "Add heart",
                  },
                ]}
                onAccessibilityAction={(event) => {
                  const name = event.nativeEvent.actionName
                  if (name === "activate" || name === "like") onToggle()
                }}
              >
                {line.text}
              </BubbleContent>
              {line.liked ? (
                <BubbleReactions
                  side="top"
                  align={mine ? "start" : "end"}
                  style={[styles.reaction, { backgroundColor: colors.primary }]}
                >
                  <AnimatedView
                    entering={ZoomIn.duration(300).reduceMotion(
                      ReduceMotion.System
                    )}
                  >
                    <Icon
                      as={HeartIcon}
                      weight="fill"
                      size={13}
                      color={colors.primaryForeground}
                    />
                  </AnimatedView>
                </BubbleReactions>
              ) : null}
            </Bubble>
          ) : null}
        </MessageContent>
      </Message>
    </AnimatedView>
  )
}

function ChatThread({
  onSend,
  onAttach,
  onOpenContact,
  onLikeChange,
}: {
  onSend?: (text: string, attachment?: string) => void
  onAttach?: () => void
  onOpenContact?: () => void
  onLikeChange?: (id: string, liked: boolean) => void
}) {
  const { colors } = useTheme()
  const [lines, setLines] = React.useState(OPENING)
  const [typing, setTyping] = React.useState(false)
  const replyCount = React.useRef(0)
  const lastTap = React.useRef<{ id: string; time: number } | null>(null)
  const timers = React.useRef<ReturnType<typeof setTimeout>[]>([])

  React.useEffect(
    () => () => {
      for (const timer of timers.current) clearTimeout(timer)
    },
    []
  )

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

  const tap = (line: Line) => {
    const now = Date.now()
    const previous = lastTap.current
    if (
      previous &&
      previous.id === line.id &&
      now - previous.time < DOUBLE_TAP
    ) {
      lastTap.current = null
      toggleLike(line)
      return
    }
    lastTap.current = { id: line.id, time: now }
  }

  const send = (text: string, photo: Photo | null) => {
    onSend?.(text, photo?.name)
    setLines((current) => [
      ...current,
      {
        id: String(current.length),
        from: "me",
        text: text || undefined,
        photo: photo ?? undefined,
      },
    ])
    timers.current.push(setTimeout(() => setTyping(true), 500))
    timers.current.push(
      setTimeout(() => {
        setTyping(false)
        setLines((current) => {
          const reply = REPLIES[replyCount.current++ % REPLIES.length]
          if (reply === undefined) return current
          return [
            ...current,
            { id: String(current.length), from: "them", text: reply },
          ]
        })
      }, 1800)
    )
  }

  const rows: Row[] = [
    { id: "stamp", kind: "stamp" },
    ...lines.map((line) => ({ kind: "line" as const, ...line })),
    ...(typing ? [{ id: "typing", kind: "typing" as const }] : []),
  ]

  return (
    <Screen edges={["top", "right", "left"]}>
      <MessageScrollerProvider>
        <Pressable
          role="button"
          accessibilityLabel="Riley"
          accessibilityHint="Shows contact details"
          onPress={onOpenContact}
          style={({ pressed }) => [
            styles.contact,
            {
              borderBottomColor: colors.border,
              backgroundColor: colors.background,
            },
            pressed && { opacity: 0.8 },
          ]}
        >
          <Avatar size="lg" style={styles.avatar}>
            <AvatarFallback style={{ backgroundColor: palette.green500 }}>
              <Text style={fontSize.lg}>RC</Text>
            </AvatarFallback>
          </Avatar>
          <View style={styles.name}>
            <Text style={[fontSize.xs, { fontWeight: "600" }]}>Riley</Text>
            <Icon
              as={CaretRightIcon}
              weight="bold"
              mirrored={I18nManager.isRTL}
              size={10}
              color={colors.foreground}
            />
          </View>
        </Pressable>
        <MessageScroller>
          <MessageScrollerViewport
            data={rows}
            keyExtractor={(row) => row.id}
            accessibilityLabel="Conversation with Riley"
            contentContainerStyle={styles.list}
            renderItem={({ item }) => (
              <MessageScrollerItem>
                {item.kind === "stamp" ? (
                  <View style={styles.stamp}>
                    <Text
                      style={[
                        styles.stampText,
                        { color: colors.mutedForeground },
                      ]}
                    >
                      Today 9:30 AM
                    </Text>
                    <Text
                      style={[
                        styles.stampText,
                        { color: colors.mutedForeground },
                      ]}
                    >
                      Double-tap a bubble to add a heart. Send a message and
                      you'll get a reply.
                    </Text>
                  </View>
                ) : null}
                {item.kind === "typing" ? <TypingDots /> : null}
                {item.kind === "line" ? (
                  <LineRow
                    line={item}
                    onTap={() => tap(item)}
                    onToggle={() => toggleLike(item)}
                  />
                ) : null}
              </MessageScrollerItem>
            )}
          />
        </MessageScroller>
        <Composer onSend={send} onAttach={onAttach} />
      </MessageScrollerProvider>
    </Screen>
  )
}

export { ChatThread }
```

5. Update the import paths to match your project.

**SwiftUI**

1. Target iOS 17 or later.

2. Add the Swift package to your app:

```swift title="Package.swift"
.package(url: "https://github.com/phosphor-icons/swift", from: "2.1.0")
```

3. Copy the theme files every view uses:

- `swift/Theme/Theme.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Theme/Theme.swift)

- `swift/Theme/Tokens.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Theme/Tokens.swift)

4. Copy the source and every file it uses:

- `swift/Blocks/ChatThread.swift`: shown above

- `swift/Components/BottomBar.swift`: see [Bottom Bar](https://ui.suiss.app/docs/components/bottom-bar.md)

- `swift/Components/Bubble.swift`: see [Bubble](https://ui.suiss.app/docs/components/bubble.md)

- `swift/Components/Button.swift`: see [Button](https://ui.suiss.app/docs/components/button.md)

- `swift/Components/ButtonGroup.swift`: see [Button Group](https://ui.suiss.app/docs/components/button-group.md)

- `swift/Components/Checkbox.swift`: see [Checkbox](https://ui.suiss.app/docs/components/checkbox.md)

- `swift/Components/Field.swift`: see [Field](https://ui.suiss.app/docs/components/field.md)

- `swift/Components/Haptics.swift`: see [Haptics](https://ui.suiss.app/docs/components/haptics.md)

- `swift/Components/InputGroup.swift`: see [Input Group](https://ui.suiss.app/docs/components/input-group.md)

- `swift/Components/KeyboardScrollView.swift`: see [Keyboard Scroll View](https://ui.suiss.app/docs/components/keyboard-scroll-view.md)

- `swift/Components/Message.swift`: see [Message](https://ui.suiss.app/docs/components/message.md)

- `swift/Components/MessageScroller.swift`: see [Message Scroller](https://ui.suiss.app/docs/components/message-scroller.md)

- `swift/Components/Screen.swift`: see [Screen](https://ui.suiss.app/docs/components/screen.md)

- `swift/Components/Separator.swift`: see [Separator](https://ui.suiss.app/docs/components/separator.md)

- `swift/Components/TextVariant.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Components/TextVariant.swift)

5. Add the files to your app target.

**Jetpack Compose**

1. Set `minSdk` to 24 or higher and add the dependencies:

```kotlin title="build.gradle.kts"
implementation(platform("androidx.compose:compose-bom:2026.09.00"))
implementation("androidx.compose.foundation:foundation")
implementation("androidx.compose.ui:ui")
implementation("com.adamglin:phosphor-icon-android:1.0.0")
```

2. Copy the theme files every view uses:

- `kotlin/theme/Theme.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/theme/Theme.kt)

- `kotlin/theme/Tokens.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/theme/Tokens.kt)

3. Copy the source and every file it uses:

- `kotlin/blocks/ChatThread.kt`: shown above

- `kotlin/components/Attachment.kt`: see [Attachment](https://ui.suiss.app/docs/components/attachment.md)

- `kotlin/components/Avatar.kt`: see [Avatar](https://ui.suiss.app/docs/components/avatar.md)

- `kotlin/components/BottomBar.kt`: see [Bottom Bar](https://ui.suiss.app/docs/components/bottom-bar.md)

- `kotlin/components/Bubble.kt`: see [Bubble](https://ui.suiss.app/docs/components/bubble.md)

- `kotlin/components/Button.kt`: see [Button](https://ui.suiss.app/docs/components/button.md)

- `kotlin/components/ButtonGroup.kt`: see [Button Group](https://ui.suiss.app/docs/components/button-group.md)

- `kotlin/components/Content.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/components/Content.kt)

- `kotlin/components/Field.kt`: see [Field](https://ui.suiss.app/docs/components/field.md)

- `kotlin/components/FormSupport.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/components/FormSupport.kt)

- `kotlin/components/Haptics.kt`: see [Haptics](https://ui.suiss.app/docs/components/haptics.md)

- `kotlin/components/Icon.kt`: see [Icon](https://ui.suiss.app/docs/components/icon.md)

- `kotlin/components/InputGroup.kt`: see [Input Group](https://ui.suiss.app/docs/components/input-group.md)

- `kotlin/components/Label.kt`: see [Label](https://ui.suiss.app/docs/components/label.md)

- `kotlin/components/Message.kt`: see [Message](https://ui.suiss.app/docs/components/message.md)

- `kotlin/components/MessageScroller.kt`: see [Message Scroller](https://ui.suiss.app/docs/components/message-scroller.md)

- `kotlin/components/Screen.kt`: see [Screen](https://ui.suiss.app/docs/components/screen.md)

- `kotlin/components/Separator.kt`: see [Separator](https://ui.suiss.app/docs/components/separator.md)

- `kotlin/components/SignaturePad.kt`: see [Signature Pad](https://ui.suiss.app/docs/components/signature-pad.md)

- `kotlin/components/Spinner.kt`: see [Spinner](https://ui.suiss.app/docs/components/spinner.md)

4. Update the `com.esuiss.ui` package names to match your project.

## Usage

```tsx
import { ChatThread } from "@/components/blocks/chat-thread"
```

```tsx
<ChatThread />
```

Replace `OPENING`, `REPLIES` and `PHOTO` with your messages and attachment source. Wire `onSend`, `onAttach`, `onOpenContact` and `onLikeChange` to your messaging backend.

