Skip to content
oks-ui

App screens

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

How it's built

1

MessageList is an `aria-live` log, so new messages are announced without moving focus.

2

The 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

A reaction is just `footer` content on a `Message` — any node works, not a dedicated reactions API.

Source

One file · copy & paste
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 } from "../../showcase/icons";

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

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

function 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",
};

export function 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)]">
                  👍
                </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>
    </>
  );
}

More patterns

App screens
Analytics dashboardA full fintech app screen — collapsible sidebar, header search, wallet cards, an earnings chart, savings goals, and a transactions table.
App screens
Settings panelA real settings screen — top tabs, two-column sections, and a sortable, selectable billing history table.
App screens
Data table viewA full documents list screen — status Tabs, a filter toolbar, and a sortable, selectable Table with real recipient avatars.