Conversation List React component for Tailwind CSS v4
v1.0.0Added in 1.0.0
A React chat history list for AI apps: New chat, search, pinned chats and day groups, with rename, pin, archive and delete.
Click or tap a chat to open it: the open one is tinted and keeps its ⋯ button, rows with an href navigate through your router link. Chats sit under sticky day labels (Pinned, Today, Yesterday, Previous 7 days, Previous 30 days, then one per month), newest first; a reply still streaming shows a small spinner and an unread chat a dot. With a mouse the ⋯ appears on hover or focus while the title fades out beneath it; on touch it is always there and the rows are 44px. The ⋯ menu (a bottom sheet on phones) renames, pins or unpins, archives and deletes, below any entries of your own; a long press on a touch screen opens the same menu (the sheet on a phone) with a haptic tick. Rename edits the title in place on a desktop (Enter or a click away saves, Escape cancels, an empty title keeps the old one) and on a phone in a small sheet with Save whose field has the keyboard up at once. Delete asks first, then the chat leaves at once and focus moves to the next one; pin moves a chat into Pinned while the rows around it slide (in one ungrouped list it glides up, lifted over the others). Every change shows right away, stays busy with a spinner while your handler's promise runs, and rolls back with an announcement if it rejects. Typing in the search turns the groups into one ranked list with the matched letters in bold (a match in the last message shows that part of it); Down steps from the search into the results. The rows are one Tab stop: Up and Down move, Home and End jump, Page Up and Page Down skip ten, Enter opens, F2 renames, Delete or Backspace deletes, Shift+F10 or the menu key opens the actions, and Tab reaches the focused row's ⋯. A title cut off or faded under the ⋯ shows in full as the row's tooltip after a moment with a mouse. Load more at the end also runs when it scrolls into view. Reduced motion turns every slide into a short fade.
Conversation List is a Pro React component in the React AI components category, written in TypeScript for React 19 and Tailwind CSS v4. It installs as source with npx wingo-ui@latest add conversation-list, together with Alert Dialog, Button, Drawer, Dropdown Menu, Empty State and 18 more and the npm packages motion and lucide-react.
Add to a project
Requirements: React 19 and Tailwind CSS v4, in Next.js (App Router or Pages), Vite or React Router. Installation guide · Next.js setup · Vite setup
npx wingo-ui@latest add conversation-listConversation List is part of Pro, with 298 more Pro items: $8 a month or $150 once. 14-day refund before you download any Pro item. See plans
Usage
Import ConversationList from @/components/ui/conversation-list.
import { useState } from "react"
import { Share2 } from "lucide-react"
import { ConversationList, type ConversationItem } from "@/components/ui/conversation-list"
// your own wording over the English defaults (DEFAULT_CONVERSATION_LIST_LABELS)
const LABELS = {
nav: "Assistant chats",
pinned: "Starred",
deleteTitle: (title: string) => `Delete “${title}” for good?`,
}
export function ChatHistory({ initial }: { initial: ConversationItem[] }) {
const [chats, setChats] = useState(initial)
const [open, setOpen] = useState(initial[0]?.id ?? "")
const patch = (id: string, change: Partial<ConversationItem>) =>
setChats((list) => list.map((chat) => (chat.id === id ? { ...chat, ...change } : chat)))
return (
<aside className="h-dvh w-72 bg-card">
<ConversationList
conversations={chats}
value={open}
onValueChange={setOpen}
onNewChat={() => {
const id = crypto.randomUUID()
setChats((list) => [{ id, title: "", updatedAt: Date.now() }, ...list])
setOpen(id)
}}
onRename={async (id, title) => {
await fetch(`/api/chats/${id}`, { method: "PATCH", body: JSON.stringify({ title }) })
patch(id, { title })
}}
onPinChange={(id, pinned) => patch(id, { pinned })}
onArchive={(id) => patch(id, { archived: true })}
onDelete={(id) => setChats((list) => list.filter((chat) => chat.id !== id))}
getActions={(chat) => [{ id: "share", label: "Share", icon: <Share2 />, onSelect: () => navigator.clipboard.writeText(`/chat/${chat.id}`) }]}
newChatShortcut="shift+mod+o"
showTime
locale="en-US"
labels={LABELS}
/>
</aside>
)
}
// server search: control the text, show the server's results as they come, and keep them unfiltered
// <ConversationList conversations={results} searchValue={query} onSearchChange={setQuery} searching={pending} filter={false} />
// long histories: hasMore + onLoadMore (a promise keeps Load more spinning; it also runs when it scrolls into view)Props
conversations*ConversationItem[]The history: { id, title, updatedAt, preview?, pinned?, unread?, streaming?, archived?, href?, icon?, model? }. updatedAt (a Date, an ISO string or ms) sorts and groups them; a missing or invalid one goes to Earlier at the end. An empty title shows labels.untitled. Keep the objects immutable: a new object only for the chat that changed.
valuestringThe open conversation's id (controlled). Without it, rows with an href follow the current page of a LinkProvider (currentPath), and otherwise the last clicked row stays open. An id that is not in the list highlights nothing.
defaultValuedefault""stringThe conversation open on the first render while uncontrolled. Empty in the playground opens Overdue invoices, September.
onValueChange(id: string, item: ConversationItem) => voidA row was chosen (a click, a tap or Enter). Rows with an href also navigate.
onNewChat() => voidShows the New chat button at the top (and makes newChatShortcut work).
onRename(id: string, title: string) => (void | Promise<unknown>)Enables Rename in the menu, F2 and a double click with a mouse. The new title (trimmed, never empty) shows at once; a returned promise keeps the row busy, and a rejection brings the old title back and announces the error.
onPinChange(id: string, pinned: boolean) => (void | Promise<unknown>)Enables Pin / Unpin. The chat slides into (or out of) Pinned at once and rolls back if the promise rejects.
onArchive(id: string) => (void | Promise<unknown>)Enables Archive. The chat leaves the list at once (unless showArchived) and comes back if the promise rejects.
onDelete(id: string) => (void | Promise<unknown>)Enables Delete (the menu, Delete or Backspace on a focused row). After the confirmation the chat leaves at once and focus moves to the next chat, or New chat; a rejection brings it back.
getActions(item: ConversationItem) => MenuEntry[]Extra dropdown-menu entries above the built-in ones (Share, Move to project, a submenu). Called each time a menu opens, so they can depend on the app's state.
searchdefaulttruebooleanThe search field under New chat. Its text still filters the list when you hide it and pass searchValue yourself.
searchValuestringThe search text (controlled). Pair it with onSearchChange for a server search, then pass the results as conversations and filter={false}.
defaultSearchValuedefault""stringThe search text on the first render while uncontrolled.
onSearchChange(value: string) => voidEvery keystroke in the search, and the clear.
searchingdefaultfalsebooleanThe search icon turns into a spinner while your server looks for results.
searchShortcutdefault""stringA key combo in mod+k notation that focuses the search from anywhere, shown as a hint in the empty field. "" binds nothing.
filterdefaulttruebooleanFilters titles, previews and model names locally while searching (case and diacritics ignored, best matches first). false keeps conversations as given, for results that come from a server.
groupBydefault"date""date" | "none"date: Pinned, Today, Yesterday, Previous 7 days, Previous 30 days, then one group per month, each under a sticky label. none: one list, pinned chats first with a pin glyph.
showPreviewdefaultfalsebooleanA second line with the last message; rows grow to 52 / 56px.
showTimedefaultfalsebooleanA trailing time: "4:35 PM" today, "Yesterday" yesterday, "Sep 22" this year, the full date before. With a mouse the ⋯ takes its place on hover.
showArchiveddefaultfalsebooleanLists archived chats too, muted and with an archive glyph (the demo has one from August).
sizedefault"md""sm" | "md"Rows 36 / 40px (44px on touch), with New chat and the search at the same height.
radiusdefault"md""none" | "sm" | "md" | "lg" | "full"The rows' corners, the button's radius at the row size (8 / 10px at md); full makes pills.
tonedefault"neutral""neutral" | "brand" | "success" | "warning" | "danger" | "info"Colors the open row (a gray tint for neutral, the tone's soft fill otherwise), the unread dot and the streaming spinner.
colordefault""stringAny CSS color. Replaces the tone for the open row, the dot and the spinner. Leave empty to use the tone.
actionsVisibilitydefault"hover""hover" | "always"hover: with a mouse the ⋯ shows on hover and keyboard focus (it is always there on touch and on the open chat). always: on every row, which keeps room for it.
confirmDeletedefaulttruebooleanAsks in an alert dialog before onDelete. false deletes at once.
newChatShortcutdefault""stringA key combo in mod+k notation that calls onNewChat from anywhere, shown on the button (⇧⌘O on a Mac, Ctrl+Shift+O elsewhere) and hidden on touch. "" binds nothing; the demo uses shift+mod+o.
loadingdefaultfalsebooleanSkeleton rows with the real row shape (two lines with showPreview) and aria-busy, while the history loads.
skeletonCountdefault8numberHow many skeleton rows loading shows.
hasMoredefaultfalsebooleanLoad more at the end of the list; it also runs onLoadMore when it scrolls into view (once per new page, never while searching).
onLoadMore() => (void | Promise<unknown>)Loads the next page of older chats; a returned promise keeps Load more spinning until it settles.
loadingMoredefaultfalsebooleanKeeps Load more spinning, for apps that track the request themselves.
emptyStateReactNodeReplaces the empty state (a chat icon, labels.empty and labels.emptyDescription). A search with no match shows its own, with Clear search.
linkComponent(props: LinkComponentProps) => ReactNodeRenders rows that have an href: next/link's Link, react-router's Link. Defaults to the LinkProvider's, then a plain <a>.
nowDate | numberThe reference time for the day groups and times; default useNow(), which ticks each minute. Without it the list shows skeleton rows on the server and during hydration, so pass a server time to render the real list there.
localedefault""stringFormats times, month groups and the result count ("en-US"). Empty uses the UIProvider's locale, then the browser's.
labelsPartial<ConversationListLabels>Every visible and spoken string, English by default (DEFAULT_CONVERSATION_LIST_LABELS): group names, the menu, the delete confirmation, the rename sheet, the empty states and the announcements. Counted and titled ones are functions: results(count, formatted), actions(title), deleteTitle(title), renamed(title). Pass a stable object.
classNamesPartial<Record<"header" | "newChat" | "search" | "scroller" | "group" | "groupLabel" | "list" | "item" | "row" | "icon" | "title" | "preview" | "indicator" | "time" | "actions" | "rename" | "more" | "empty" | "skeleton", string>>Extra classes for the inner parts.
classNamestringExtra classes for the root <nav>. It inherits its parent's background so the sticky day labels cover the rows; give it one (bg-card) when the parent is transparent.
Parts
ConversationListItem
componentOne row on its own (a recent-chats card, a search result): item, active, onSelect, size, radius, tone, color, showPreview, showTime, now, locale, linkComponent, labels, classNames. Same look and link behavior, no menu; render it inside a <ul>. Props: ConversationListItemProps.
groupConversations
functionThe grouping the list uses, pure: [{ key: "pinned" | "today" | "yesterday" | "week" | "month" | "2026-08" | "earlier", label, items }], newest first inside each group, month labels like "August 2026". Signature: (items: ConversationItem[], options: { now: Date | number; locale?: string; labels?: Partial<ConversationListLabels> }) => ConversationGroup[].
DEFAULT_CONVERSATION_LIST_LABELS
constantThe English strings, to spread a translation over. Type: ConversationListLabels.
ConversationItem
typeOne chat: id, title, updatedAt (sorts and groups the list), preview, pinned, unread, streaming, archived, href (renders the row as a link), icon and model.
ConversationGroup
typeOne group from groupConversations: key ("pinned", "today", a month such as "2026-08"...), label and items.
Examples
In the app sidebar
Inside the sidebar under the workspace switcher; rows are router links and the open page is highlighted.
With previews and times
showPreview and showTime: the last message under each title and a trailing 4:35 PM / Yesterday / Sep 22.
Server search
A controlled searchValue, filter={false} and a spinner in the field while the request runs.
Infinite
100 generated chats in pages of 20 with hasMore and onLoadMore; older ones group by month.
Empty and loading
Skeleton rows while the history loads, and an empty state that the first chat replaces.
Phone
A screen of its own at 350px: 44px rows, the ⋯ always visible, a long press opens the action sheet.
Tones and colors
Brand for the open chat and the unread dot; a custom violet with small pill rows and actions always shown.
Recent chats, one row at a time
ConversationListItem in a card of its own: default labels, no menu.
Dependencies
Files
- components/ui/conversation-list.tsx
FAQ
Is the React Conversation List component free?
The preview, props and docs are free; copying the source needs a Pro plan. See plans.
How do I install Conversation List?
Run npx wingo-ui@latest add conversation-list. It also adds the Wingo UI items it builds on (Alert Dialog, Button, Drawer, Dropdown Menu, Empty State and 18 more) and the npm packages motion and lucide-react. With shadcn, add the @wingo-ui registry with your API key, then run npx shadcn@latest add @wingo-ui/conversation-list.
How does Conversation List behave on phones?
The ⋯ menu (a bottom sheet on phones) renames, pins or unpins, archives and deletes, below any entries of your own; a long press on a touch screen opens the same menu (the sheet on a phone) with a haptic tick.