A list of conversations to choose from: each row shows the avatar, the title, when the latest message arrived and a line of it, and marks unread conversations with a bold title and a dot. The open conversation's row is highlighted, under the pointer too, and marked aria-current. The list is a single stop for the Tab key, which lands on the open conversation; Up and Down arrows move between rows, Home and End jump to the first and last, and Enter, Space or a click calls onSelect. Rows can group under sections, whose headers open and close them. A list of more than 60 conversations renders only the rows near the view, and the Tab stop and the open row, each placed where it would be in the whole list and measured as it renders, so a long list costs about what a screenful does. The arrow keys, Home and End still reach every row, and the open conversation's row scrolls into view. A long list's other rows are not in the page, so code that reads the rows from the page, and the browser's find in page, see only the rows rendered: read items instead.
On this site
This site's own pages use ConversationList too. Open one with X-ray pointing it out: The Nautilus's story
Install
Once per app, with Tailwind CSS v4 and an @/* path alias (Ouro HX is built on Base UI, so --base base; skip -t in an existing app):
npx shadcn@latest init --base base --preset nova --yes --no-monoreponpx shadcn@latest add ourostack/ouro-ui/ouro-base#v1.3.10Then the component. From v0.8.0 it installs only the helpers it imports and never touches your stylesheet (Installing):
npx shadcn@latest add ourostack/ouro-ui/conversation-list#v1.3.10Or, with the @ouro namespace set up:
npx shadcn@latest add @ouro/ouro-base @ouro/conversation-listIts tests, stories and Playwright spec are optional. They need Vitest 4.1.11 or later, and from v0.8.0 never change a version your app has (Testing):
npx shadcn@latest add ourostack/ouro-ui/conversation-list#v1.3.10 ourostack/ouro-ui/conversation-list-tests#v1.3.10import { ConversationList } from "@/components/ouro/conversation-list/conversation-list";Paths use shadcn's default @/ alias; the CLI rewrites them to yours.
Example
<ConversationList label="Conversations" items={conversations} selectedId={openId} onSelect={setOpenId} />
<ConversationList label="Chats" sections={[{ id: "pinned", title: "Pinned" }]} items={[{ id: "ana", title: "Ana Lind", section: "pinned" }]} onSelect={open} />Use instead when
- Use
ItemGroupinstead for a static list with nothing open, andTabsfor a handful of views that switch in place.
Props
Type: ConversationListProps.
className- Type:
string - Layout only: width, margins, grid placement (
w-80). Appearance classes warn in development. density- Type:
"comfortable" | "compact" - Default:
"comfortable" compactdrops the preview line and uses a smaller avatar.draftLabel- Type:
string - Default:
"Draft:" - The words before a row's
draft, for other languages. empty- Type:
ReactNode - Default:
"Nothing here yet" - What shows when
itemsis empty. items(required)- Type:
readonly ConversationListItem[] - The conversations, in the order they show.
label(required)- Type:
string - What the list is, for screen readers ("Conversations"). Required, so the list has a name.
onSectionOpenChange- Type:
((id: string, open: boolean) => void) - Called with a section's
idand whether it is now open, when its header is pressed. onSelect(required)- Type:
(id: string, event: MouseEvent<HTMLButtonElement>) => void - Called with a row's
idwhen it is chosen by a click, a tap, Enter or Space. A handler that takes a second parameter also gets the click, so the app can checkevent.isTrustedorevent.metaKey; a one-parameter handler or a server action gets theidalone. ref- Type:
Ref<HTMLDivElement> - Reaches the rendered element.
sections- Type:
readonly ConversationListSection[] - The headers the rows group under, in order. A row names its header in
section, and a section without rows is left out. selectedId- Type:
string | null - The
idof the open conversation. Its row is highlighted and markedaria-current. unreadLabel- Type:
string - Default:
"Unread" - What screen readers hear for an unread row's dot, and a closed section's, for other languages.
Native attributes
Also accepts the attributes in React's HTMLAttributes (id, aria-*, event handlers), passed to the rendered element, except those named on this page.
Don't use
These are not props. Passing one fails the type check with the fix in the error, and in development a console message names the fix.
conversations: Not a prop. The rows areitems:<ConversationList items={conversations}>.groups: Not a prop. Headers aresections, and a row names its own insection:<ConversationList sections={sections} items={rows}>.onValueChange: Not a prop. A chosen row callsonSelect:<ConversationList onSelect={(id) => open(id)}>.selected: Not a prop. The open conversation isselectedId:<ConversationList selectedId={openId}>.value: Not a prop. The open conversation isselectedId:<ConversationList selectedId={openId}>.
className is for layout only
Pass margins, width and grid placement (mt-4, w-full, col-span-2). Your classes always win over Ouro UI's: ConversationList merges className last with cn (tailwind-merge), so a class that conflicts with its own replaces it.layout-classes
Appearance comes from props, not classes. In development, a class that sets appearance logs one warning per component instance, naming the prop to use instead. A class sets appearance when, after its variants (hover:, sm:) and any ! are removed, it starts with bg-, text-, border-, rounded-, shadow-, font-, ring-, from-, via-, to-, fill- or stroke-, or is border, rounded, shadow or ring, or has a dark: variant. Text wrapping, overflow and table layout classes (text-wrap, text-ellipsis, border-collapse) are layout and do not warn.
Alignment classes (text-center) warn too: set alignment on a parent element.
Types
ConversationListContentTitle
A conversation whose title is content, such as its name with emoji drawn as pictures (<EmojiText text={chat.name} catalogue={catalogue} />), with its name as text beside it.
interface ConversationListContentTitle {
/** What the row shows as the conversation's name. */
title: ReactNode;
/**
* The conversation's name as text: what screen readers hear in place of the title, and the
* avatar's initials come from it.
*/
name: string;
}ConversationListItem
One conversation in a ConversationList: its title as text, or as content with a name.
type ConversationListItem = ConversationListItemFields & (ConversationListTextTitle | ConversationListContentTitle);ConversationListItemFields
What a ConversationList row shows of a conversation, whichever way its title comes.
interface ConversationListItemFields {
/** Identifies the conversation; `onSelect` receives it. */
id: string;
/**
* A quiet tag right after the title, such as "AI agent" for an agent or "Guest": small and muted, not a badge, and
* read after the title ("Halden, AI agent"). In a narrow row the title shortens and the tag stays whole.
*/
nameTag?: string | undefined;
/**
* A muted name after the title, on its line, such as the team a channel belongs to: "Design
* reviews · Launch team". Text, or content such as `<EmojiText>` to draw its emoji as pictures.
*/
where?: ReactNode;
/** A second line under the title, such as the group a topic belongs to. */
subtitle?: string | undefined;
/**
* A line between the title and the preview that says what happened and where, as an activity
* feed shows it: the title names the person, this line "mentioned you in Design reviews", and
* the preview the message. Text, or content such as `<EmojiText>`. It keeps to `density="compact"`.
*/
context?: ReactNode;
/** The latest message, cut to one line. `density="compact"` hides it. */
preview?: ReactNode;
/**
* The first words of a message the person started here and has not sent. They show in place
* of the preview after `draftLabel` ("Draft:"), in the destructive tone as chat apps mark a
* draft, so a half-written reply is visible from the list. `density="compact"` hides it with
* the preview.
*/
draft?: ReactNode;
/** When the latest message arrived, already formatted ("9:41", `<RelativeTime>`). */
time?: ReactNode;
/** There are messages the person has not read: the title is bold and a dot shows. */
unread?: boolean | undefined;
/** A small icon before the title, in the muted color, such as a pin or a muted bell. Give it a name (`role="img"` and `aria-label`) when it means something. */
icon?: ReactNode;
/** Small marks after the title, such as a `<Badge>`. */
badges?: ReactNode;
/**
* The row's mark, at the end of its last line under the time: a mention `<Badge>`, "in 10 min" before a meeting.
* It takes the unread dot's place; screen readers still hear `unreadLabel`. Words show small and muted, like the
* time. It keeps to the line's height, so the row stays the height of the others.
*/
mark?: ReactNode;
/**
* The avatar: a picture's URL, or an element to show instead, such as an `<Avatar>` with the person's presence or an
* `<AvatarGroup>`. Without it the avatar shows the title's initials; `false` hides the avatar.
*/
avatar?: string | ReactElement | false | undefined;
/** The `id` of the section in `sections` the row shows under. Rows without a section come first, before the headers. */
section?: string | undefined;
}ConversationListSection
A group of rows in a ConversationList, under a header that opens and closes it.
interface ConversationListSection {
/** Identifies the section; rows name it in their `section`, and `onSectionOpenChange` receives it. */
id: string;
/** The header's words: "Pinned", "Recent". They also name the section's list for screen readers. */
title: string;
/** Muted words after the title on the header, such as "your folder". */
note?: ReactNode;
/** Whether the section starts open, when the list keeps its state. @default true */
defaultOpen?: boolean | undefined;
/** Whether the section is open, when the app keeps the state with `onSectionOpenChange`. */
open?: boolean | undefined;
}ConversationListTextTitle
A conversation whose title is text.
interface ConversationListTextTitle {
/** The conversation's name: a person, a group or a topic. The avatar's initials come from it. */
title: string;
/** The name screen readers hear in place of `title`, and the avatar's initials come from. @default the title */
name?: string | undefined;
}Claim sources
- layout-classes: test:registry/ouro/components/ouro/conversation-list/conversation-list.test.tsx::ConversationList > forwards a ref and lets app layout classes win
verify:testExists(registry/ouro/components/ouro/conversation-list/conversation-list.test.tsx::ConversationList > forwards a ref and lets app layout classes win)
