# iOS Messages

> Conversation list and thread with bubbles, typing indicator and canned replies.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/templates/ios-messages
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/ios-messages.json
```

Adds the page at `app/ios-messages/page.tsx` together with the components below. Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Components

- [iOS Kit](https://chromotion.dev/r/ios-kit.md): `@/components/ios-kit`

## Dependencies

```bash
npm install lucide-react
```

## Source

```tsx title="app/ios-messages/page.tsx"
"use client"

import * as React from "react"
import { ArrowUp, Plus, SquarePen } from "lucide-react"

import { cn } from "@/lib/utils"
import { IOSBackButton, IOSDevice, IOSNavButton, IOSPage, IOSSearchField } from "@/components/ios-kit"

type Bubble = { id: number; from: "me" | "them"; text: string }
type Thread = { id: string; name: string; time: string; unread: boolean; gradient: string; messages: Bubble[] }

const initialThreads: Thread[] = [
  {
    id: "mia", name: "Mia Chen", time: "9:41 AM", unread: true, gradient: "from-pink-400 to-rose-500",
    messages: [
      { id: 1, from: "them", text: "Are we still on for dinner tonight?" },
      { id: 2, from: "me", text: "Definitely! Where should we go?" },
      { id: 3, from: "them", text: "I booked the 7:30 table at Nopa. They have that squash dish you liked." },
    ],
  },
  {
    id: "design", name: "Design Team", time: "9:12 AM", unread: true, gradient: "from-indigo-400 to-violet-600",
    messages: [
      { id: 1, from: "them", text: "New icon set is in Figma." },
      { id: 2, from: "me", text: "Looks great — shipping it today." },
    ],
  },
  {
    id: "dad", name: "Dad", time: "Yesterday", unread: false, gradient: "from-amber-400 to-orange-500",
    messages: [{ id: 1, from: "them", text: "Call me when you land." }],
  },
  {
    id: "sam", name: "Sam Rivera", time: "Tuesday", unread: false, gradient: "from-emerald-400 to-teal-600",
    messages: [{ id: 1, from: "me", text: "Thanks for the book recommendation!" }],
  },
]

const replies = ["Sounds good!", "Haha, perfect.", "On my way.", "Let me check and get back to you."]

function Avatar({ name, gradient, size = 50 }: { name: string; gradient: string; size?: number }) {
  const initials = name.split(" ").map((part) => part[0]).join("").slice(0, 2)
  return (
    <span
      className={cn("flex shrink-0 items-center justify-center rounded-full bg-gradient-to-b font-medium text-white", gradient)}
      style={{ width: size, height: size, fontSize: size * 0.4 }}
    >
      {initials}
    </span>
  )
}

export default function IOSMessages() {
  const [threads, setThreads] = React.useState(initialThreads)
  const [openId, setOpenId] = React.useState<string | null>(null)
  const [query, setQuery] = React.useState("")
  const [draft, setDraft] = React.useState("")
  const [typing, setTyping] = React.useState(false)
  const timer = React.useRef<ReturnType<typeof setTimeout> | null>(null)

  React.useEffect(() => () => { if (timer.current) clearTimeout(timer.current) }, [])

  const open = threads.find((thread) => thread.id === openId)
  const unreadCount = threads.filter((thread) => thread.unread && thread.id !== openId).length

  const append = (id: string, bubble: Omit<Bubble, "id">) =>
    setThreads((list) =>
      list.map((thread) =>
        thread.id === id ? { ...thread, messages: [...thread.messages, { ...bubble, id: thread.messages.length + 1 }] } : thread
      )
    )

  const openThread = (id: string) => {
    setThreads((list) => list.map((thread) => (thread.id === id ? { ...thread, unread: false } : thread)))
    setOpenId(id)
  }

  const send = (event: React.FormEvent) => {
    event.preventDefault()
    const text = draft.trim()
    if (!text || !open) return
    const id = open.id
    append(id, { from: "me", text })
    setDraft("")
    setTyping(true)
    if (timer.current) clearTimeout(timer.current)
    timer.current = setTimeout(() => {
      setTyping(false)
      append(id, { from: "them", text: replies[text.length % replies.length] })
    }, 1400)
  }

  if (open) {
    const lastMine = [...open.messages].reverse().find((bubble) => bubble.from === "me")
    return (
      <IOSDevice>
        <div className="flex min-h-0 flex-1 flex-col">
          <header className="z-10 flex h-[98px] shrink-0 items-end border-b border-(color:--ios-sep) bg-(color:--ios-bg)/80 px-4 backdrop-blur-xl">
            <div className="relative flex h-[60px] w-full items-center justify-between">
              <IOSBackButton
                label={unreadCount ? String(unreadCount) : "Messages"}
                onClick={() => {
                  setOpenId(null)
                  setTyping(false)
                }}
              />
              <div className="absolute inset-x-0 flex flex-col items-center gap-0.5">
                <Avatar name={open.name} gradient={open.gradient} size={36} />
                <span className="text-[11px]">{open.name}</span>
              </div>
            </div>
          </header>

          <ol className="flex min-h-0 flex-1 flex-col gap-1 overflow-y-auto px-4 py-3 [scrollbar-width:none]">
            {open.messages.map((bubble) => (
              <li
                key={bubble.id}
                className={cn(
                  "relative max-w-[75%] rounded-[19px] px-3.5 py-2 text-[17px] leading-[22px]",
                  bubble.from === "me"
                    ? "self-end bg-(color:--ios-blue) text-white"
                    : "self-start bg-[#e9e9eb] text-black dark:bg-[#26262a] dark:text-white"
                )}
              >
                {bubble.text}
              </li>
            ))}
            {lastMine && !typing && <p className="self-end pr-1 text-[11px] text-(color:--ios-label2)">Delivered</p>}
            {typing && (
              <li aria-label="Typing" className="flex w-14 items-center justify-center gap-1 self-start rounded-[19px] bg-[#e9e9eb] py-3.5 dark:bg-[#26262a]">
                {[0, 1, 2].map((dot) => (
                  <span key={dot} className="size-2 animate-bounce rounded-full bg-(color:--ios-label3)" style={{ animationDelay: `${dot * 150}ms` }} />
                ))}
              </li>
            )}
          </ol>

          <form onSubmit={send} className="flex shrink-0 items-center gap-2 px-3 pt-2 pb-[34px]">
            <button type="button" aria-label="Add attachment" className="flex size-8 shrink-0 items-center justify-center rounded-full bg-(color:--ios-fill) text-(color:--ios-label2)">
              <Plus className="size-5" />
            </button>
            <div className="flex h-9 min-w-0 flex-1 items-center rounded-full border border-(color:--ios-sep) pr-1 pl-3.5">
              <input
                value={draft}
                onChange={(event) => setDraft(event.target.value)}
                placeholder="iMessage"
                aria-label="Message"
                className="min-w-0 flex-1 bg-transparent text-[17px] outline-none placeholder:text-(color:--ios-label3)"
              />
              <button
                type="submit"
                aria-label="Send"
                disabled={!draft.trim()}
                className="flex size-7 items-center justify-center rounded-full bg-(color:--ios-blue) text-white transition-opacity disabled:opacity-0"
              >
                <ArrowUp className="size-4" strokeWidth={3} />
              </button>
            </div>
          </form>
        </div>
      </IOSDevice>
    )
  }

  const filtered = threads.filter((thread) => thread.name.toLowerCase().includes(query.trim().toLowerCase()))

  return (
    <IOSDevice>
      <IOSPage
        title="Messages"
        left={<IOSNavButton>Edit</IOSNavButton>}
        right={<IOSNavButton aria-label="Compose"><SquarePen className="size-[22px]" /></IOSNavButton>}
      >
        <div className="px-4 pb-2">
          <IOSSearchField value={query} onChange={(event) => setQuery(event.target.value)} />
        </div>
        <ul>
          {filtered.map((thread) => {
            const last = thread.messages[thread.messages.length - 1]
            return (
              <li key={thread.id} className="relative">
                <button
                  type="button"
                  onClick={() => openThread(thread.id)}
                  className="flex w-full items-center gap-2 py-2.5 pr-4 pl-1.5 text-left transition-colors active:bg-(color:--ios-fill)"
                >
                  <span className={cn("size-[10px] shrink-0 rounded-full", thread.unread ? "bg-(color:--ios-blue)" : "bg-transparent")} />
                  <Avatar name={thread.name} gradient={thread.gradient} />
                  <span className="ml-1 min-w-0 flex-1 border-b border-(color:--ios-sep) pb-2.5">
                    <span className="flex items-baseline justify-between gap-2">
                      <span className="truncate text-[17px] font-semibold">{thread.name}</span>
                      <span className="shrink-0 text-[15px] text-(color:--ios-label2)">{thread.time}</span>
                    </span>
                    <span className="line-clamp-2 text-[15px] leading-5 text-(color:--ios-label2)">{last.text}</span>
                  </span>
                </button>
              </li>
            )
          })}
        </ul>
      </IOSPage>
    </IOSDevice>
  )
}
```
