Skip to content

Chat thread

A support-inbox screen: a folder and connected-channel rail (real Nav badges), a searchable/sortable message list with a channel-tinted Avatar per row, and a conversation pane where a live MessageList renders aligned bubbles with delivery status, a reaction, and a composer.

chat.tsx

Built with

How it’s built

  1. 01MessageList is an aria-live log, so new messages are announced without moving focus.
  2. 02The support agent's replies align end with a delivery status; the customer's align start with an avatar — the same real align/status props regardless of which conversation is selected.
  3. 03A reaction is just footer content on a Message — any node works, not a dedicated reactions API.

Source

Two files · copy & paste

Styling: this source uses Tailwind utility classes and this site’s colour tokens (for example text-ink, bg-paper and border-line). The oks-ui components render without them; swap those classes for your own.

chat.tsx
import { useState } from "react";
import { Avatar } from "oks-ui/avatar";
import { Button } from "oks-ui/button";
import { Chip } from "oks-ui/chip";
import { Dropdown, DropdownItem, DropdownMenu, DropdownTrigger } from "oks-ui/dropdown";
import { Form, FormFieldSet } from "oks-ui/form-field-set";
import { Message, MessageList } from "oks-ui/message";
import { ChevronDownIcon, MailIcon, MoreIcon, PhoneIcon, SearchIcon, SendIcon, SparkleIcon, ThumbsUpIcon } from "./icons";

type ChannelKey = "gmail" | "telegram" | "whatsapp" | "messenger";

const CHANNEL_TONE: Record<ChannelKey, string> = {
  gmail: "#e0623a",
  telegram: "#2f9fe0",
  whatsapp: "#22a35a",
  messenger: "#6d5fe0",
};

const ChannelDot = ({ channel, size = 16 }: { channel: ChannelKey; size?: number }) => {
  return (
    <span className="flex shrink-0 items-center justify-center rounded-full text-white" style={{ background: CHANNEL_TONE[channel], width: size, height: size }} aria-hidden="true">
      <svg width={size * 0.55} height={size * 0.55} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
        <path d="M2 4h12v8H2Z" />
        <path d="m2 4 6 5 6-5" />
      </svg>
    </span>
  );
};

type Preview = { id: number; name: string; channel: ChannelKey; snippet: string; time: string; starred: boolean; hasAttachment: boolean };

const PREVIEWS: Preview[] = [
  { id: 1, name: "Matthew Anderson", channel: "whatsapp", snippet: "Hey there! I'm new here and had a question about...", time: "5m ago", starred: true, hasAttachment: true },
  { id: 2, name: "Ethan Johnson", channel: "messenger", snippet: "Hi, John! I hope this message finds you well.", time: "15m ago", starred: true, hasAttachment: false },
  // …more rows
];

// A handful of rows get a real photo — two are the same stock-free portrait
// assets used elsewhere on this site, three are AI-synthesized
// (non-real-person) faces — the rest fall back to initials.
const PHOTOS: Record<string, string> = {
  "Matthew Anderson": "/hero/person-man.jpg",
  "Ethan Johnson": "/avatars/synthetic-1.jpg",
};

const Chat = () => {
  const [selected, setSelected] = useState(1);
  const active = PREVIEWS.find((p) => p.id === selected) ?? PREVIEWS[0];
  const firstName = active.name.split(" ")[0];

  return (
    <>
      {/* message list */}
      <div className="flex h-full w-80 shrink-0 flex-col overflow-hidden border-r border-line">
        <div className="flex items-center gap-2 px-4 py-3">
          <FormFieldSet
            type="search"
            name="search"
            label="Search messages"
            placeholder="Search message"
            startIcon={<SearchIcon size={15} />}
          />
          <Dropdown placement="bottom-start">
            <DropdownTrigger>
              <Button variant="ghost" size="sm" endContent={<ChevronDownIcon size={13} />}>
                Newest
              </Button>
            </DropdownTrigger>
            <DropdownMenu aria-label="Sort messages" selectionMode="single" disallowEmptySelection selectedKeys={new Set(["newest"])}>
              <DropdownItem key="newest">Newest</DropdownItem>
              <DropdownItem key="oldest">Oldest</DropdownItem>
            </DropdownMenu>
          </Dropdown>
        </div>

        {PREVIEWS.map((p) => (
          <Button
            key={p.id}
            variant="ghost"
            fullWidth
            onClick={() => setSelected(p.id)}
            className={`justify-start gap-3 rounded-none border-b border-line px-4 py-3 text-left ${p.id === selected ? "bg-[color:var(--oks-color-primary-50)]" : ""}`}
          >
            <span className="relative shrink-0">
              <Avatar src={PHOTOS[p.name]} name={p.name} size="md" radius="full" />
              <span className="absolute -bottom-0.5 -right-0.5 rounded-full ring-2 ring-paper">
                <ChannelDot channel={p.channel} size={14} />
              </span>
            </span>
            <span className="min-w-0 flex-1">
              <span className="flex items-center justify-between gap-2">
                <span className="truncate text-[0.88rem] font-semibold text-ink">{p.name}</span>
                <span className={`shrink-0 text-[0.72rem] ${p.id === selected ? "text-ink-soft" : "text-ink-faint"}`}>{p.time}</span>
              </span>
              <span className={`truncate text-[0.8rem] ${p.id === selected ? "text-ink-soft" : "text-ink-muted"}`}>{p.snippet}</span>
            </span>
          </Button>
        ))}
      </div>

      {/* conversation */}
      <div className="flex min-w-0 flex-1 flex-col overflow-hidden">
        <header className="flex shrink-0 items-center justify-between gap-3 border-b border-line px-5 py-3">
          <div className="flex min-w-0 items-center gap-2.5">
            <Avatar src={PHOTOS[active.name]} name={active.name} size="sm" radius="full" />
            <div className="min-w-0">
              <p className="truncate text-[0.92rem] font-semibold text-ink">{active.name}</p>
              <p className="truncate text-[0.75rem] text-ink-faint">Last seen recently</p>
            </div>
          </div>
          <div className="flex items-center gap-1">
            <Button isIconOnly aria-label="AI reply suggestions" variant="ghost" radius="full" size="sm">
              <SparkleIcon size={16} />
            </Button>
            <Button isIconOnly aria-label="Call" variant="ghost" radius="full" size="sm">
              <PhoneIcon size={16} />
            </Button>
          </div>
        </header>

        <div className="flex flex-1 flex-col gap-1 overflow-y-auto px-5 py-4">
          <Chip size="sm" variant="soft" classNames={{ base: "mx-auto" }}>
            Today
          </Chip>

          <MessageList classNames={{ base: "flex flex-col gap-3" }}>
            <Message author={active.name} avatar={<Avatar src={PHOTOS[active.name]} name={active.name} size="sm" radius="full" />} timestamp="10:02 AM">
              Hi! Quick question — does the analytics dashboard support exporting a custom date range, or only the preset ones?
            </Message>
            <Message
              align="end"
              color="primary"
              status="read"
              timestamp="10:20 AM"
              footer={
                <span className="inline-flex items-center gap-1 rounded-full bg-paper px-1.5 py-0.5 text-[0.75rem] shadow-[var(--shadow-sm)]" aria-label="Reacted with thumbs up">
                  <ThumbsUpIcon />
                </span>
              }
            >
              Hi {firstName}, good question — yes, the export panel has a custom range picker next to the presets, so you can pick any start and end date.
            </Message>
            <Message author={active.name} avatar={<Avatar src={PHOTOS[active.name]} name={active.name} size="sm" radius="full" />} timestamp="10:24 AM" isContinuation>
              That&apos;s great! One more thing — can I schedule that export to email itself weekly?
            </Message>
            <Message align="end" color="primary" status="delivered" timestamp="10:31 AM">
              Not yet, but it&apos;s on our roadmap for next quarter — I&apos;ll follow up here as soon as it ships.
            </Message>
          </MessageList>
        </div>

        <Form onSubmit={(d) => console.log(d)} className="flex shrink-0 flex-col gap-2 border-t border-line p-3">
          <FormFieldSet type="text" name="message" label="Message" placeholder="Write your message…" />
          <div className="flex items-center justify-between gap-2">
            <Button isIconOnly aria-label="Insert email template" variant="ghost" size="sm">
              <MailIcon size={15} />
            </Button>
            <Button type="submit" isIconOnly aria-label="Send message" color="primary" radius="full" size="sm">
              <SendIcon size={15} />
            </Button>
          </div>
        </Form>
      </div>
    </>
  );
};

export default Chat;

It imports ./icons, so save this next to it:

icons.tsx
export const ChevronDownIcon = ({ size = 18 }: { size?: number }) => {
  return (
    <svg
      width={size}
      height={size}
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <path d="m6 9 6 6 6-6" />
    </svg>
  );
};

export const MailIcon = ({ size = 18 }: { size?: number }) => {
  return (
    <svg
      width={size}
      height={size}
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <rect x="2" y="4" width="20" height="16" rx="2" />
      <path d="m2 7 10 6 10-6" />
    </svg>
  );
};

export const MoreIcon = ({ size = 18 }: { size?: number }) => {
  return (
    <svg
      width={size}
      height={size}
      viewBox="0 0 24 24"
      fill="currentColor"
      aria-hidden="true"
    >
      <circle cx="5" cy="12" r="1.6" />
      <circle cx="12" cy="12" r="1.6" />
      <circle cx="19" cy="12" r="1.6" />
    </svg>
  );
};

export const PhoneIcon = ({ size = 18 }: { size?: number }) => {
  return (
    <svg
      width={size}
      height={size}
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.127.96.362 1.903.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.907.338 1.85.573 2.81.7A2 2 0 0 1 22 16.92Z" />
    </svg>
  );
};

export const SearchIcon = ({ size = 18 }: { size?: number }) => {
  return (
    <svg
      width={size}
      height={size}
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <circle cx="11" cy="11" r="7" />
      <path d="M21 21l-4.3-4.3" />
    </svg>
  );
};

export const SendIcon = ({ size = 18 }: { size?: number }) => {
  return (
    <svg
      width={size}
      height={size}
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <path d="m22 2-7 20-4-9-9-4Z" />
      <path d="M22 2 11 13" />
    </svg>
  );
};

// Icons below this line were added for Slate's rebuild into a personal
// finance & invoicing app -- same "no icon library dependency" convention
// above.

export const SparkleIcon = ({ size = 18 }: { size?: number }) => {
  return (
    <svg
      width={size}
      height={size}
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <path d="M12 3c.6 3.2 1.9 4.9 5.5 5.5-3.6.6-4.9 2.3-5.5 5.5-.6-3.2-1.9-4.9-5.5-5.5C10.1 7.9 11.4 6.2 12 3Z" />
      <path d="M19 15c.3 1.4.9 2.1 2.3 2.3-1.4.3-2.1.9-2.3 2.3-.3-1.4-.9-2.1-2.3-2.3 1.4-.3 2.1-.9 2.3-2.3Z" />
    </svg>
  );
};

export const ThumbsUpIcon = ({ size = 14 }: { size?: number }) => {
  return (
    <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
      <path d="M7 10v11" />
      <path d="M15 5.9 14 10h5.8a2 2 0 0 1 2 2.3l-1.4 7a2 2 0 0 1-2 1.7H7V10l4-8a3 3 0 0 1 4 3.9Z" />
    </svg>
  );
};