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 & pasteimport { 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'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's on our roadmap for next quarter — I'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.