Skip to content

Conversation list

<aparte-conversation-list>

Conversation-history sidebar — a framework-agnostic web component. The host sets the conversations JS property and the active-id attribute; this renders the rows and fires the user’s intent, never acting on it itself.

A row is two real buttons: the title, which selects, and a that opens the row’s menu — rename, pin or unpin, archive or unarchive, delete. Every item fires an event and stops; the one exception is delete, which asks first, inline in the menu, because it is the one action the host cannot undo. Rename swaps the title for an input: Enter or leaving the field commits, Escape cancels, and an unchanged or emptied title fires nothing. The rows are grouped by date — Pinned, Today, Yesterday, Previous 7 days, Previous 30 days, then one heading per month — as soon as any item carries updatedAt; set no-groups to render them flat.

Children are not a composition point: _render() assigns innerHTML from the conversations array, so any light-DOM child a host writes inside the element is discarded the next time the list renders — and switching this element’s locale is enough to trigger one. Compose around the element, not inside it: it renders rows and nothing else, with no header, no new-conversation button and no search field.

What it is not: a store. Selecting, renaming, pinning, archiving and deleting all leave the array untouched — the events carry an id (and, for rename, the title) and stop. A row’s text comes from the array, so it changes when the host assigns conversations again; the exception is an empty title, which falls back to the locale’s new-chat label and therefore follows a locale switch. An archived item is still rendered (it gains aparte-conv-item--archived); filtering archived conversations out of the list is the host’s decision, not this element’s. The asymmetry between the two inputs is deliberate: active-id is an attribute because moving the selection patches the rendered rows in place, while conversations is a JS property because it is structured data an attribute cannot carry, and setting it re-renders the whole list.

Width Open in a tab
The real element, in its own document so this site's CSS cannot reach it. It renders the same example printed below — every framework mounts this same element, so the rendering is what you get in all five; the code is what changes.
<!-- It stores nothing and fetches nothing: an empty tag renders the empty state, and
the list appears when the host assigns `conversations`. -->
<aparte-conversation-list active-id="c1" style="max-width: 20rem"></aparte-conversation-list>
<script>
const day = 864e5;
document.querySelector('aparte-conversation-list').conversations = [
{ id: 'c0', title: 'Release checklist', updatedAt: Date.now() - 3 * day, pinnedAt: Date.now() },
{ id: 'c1', title: 'Deploy checklist', updatedAt: Date.now() },
{ id: 'c2', title: 'Rename the segment types', updatedAt: Date.now() - day },
{ id: 'c3', title: 'Tokens, not selectors', updatedAt: Date.now() - 4 * day },
];
</script>
// The host owns the data: set the `conversations` property, listen for the intent.
const list = document.querySelector('aparte-conversation-list')!;
list.conversations = [
{ id: 'c1', title: 'Deploy checklist', updatedAt: Date.now() },
{ id: 'c2', title: 'Old thread', updatedAt: 0, archivedAt: Date.now() },
];
list.setAttribute('active-id', 'c1');
list.addEventListener('aparte-select-conversation', (e) => load(e.detail.id));
list.addEventListener('aparte-rename-conversation', (e) => manager.updateTitle(e.detail.id, e.detail.title));
list.addEventListener('aparte-pin-conversation', (e) => manager.pin(e.detail.id));
list.addEventListener('aparte-delete-conversation', (e) => manager.delete(e.detail.id));
AttributeDescription
active-idThe id of the conversation to render as selected.
no-groupsRender the rows flat, in host order, with no date headings.
PropertyTypeDescription
conversationsAparteConversationListItem[]Set the list of conversations to display. Triggers a re-render.
EventTypeDescription
aparte-unpin-conversationCustomEvent&lt;AparteConversationPinDetail>The same item on a pinned row; same detail shape, opposite intent.
aparte-pin-conversationCustomEvent&lt;AparteConversationPinDetail>The pin item was chosen on an unpinned row.
aparte-unarchive-conversationCustomEvent&lt;AparteConversationArchiveDetail>The same item on an already-archived one; same detail shape, opposite intent.
aparte-archive-conversationCustomEvent&lt;AparteConversationArchiveDetail>The archive item was chosen on a live conversation.
aparte-delete-conversationCustomEvent&lt;AparteConversationDeleteDetail>The delete was confirmed. Nothing is removed here.
aparte-rename-conversationCustomEvent&lt;AparteConversationRenameDetail>A rename was committed with a new, non-empty title. Nothing is renamed here.
aparte-select-conversationCustomEvent&lt;AparteConversationSelectDetail>A row’s title was activated; the host loads that conversation.

Override any of these on :root, on a subtree, or on one instance — custom properties inherit downward. Some are this element’s own; others are site-wide tokens that also style it, and overriding one of those at :root moves everything that reads it. The full set is in the CSS variables reference.

VariableDefaultDescription
--aparte-conv-list-gapvar(--aparte-space-1)Vertical gap between rows, and between a group’s heading and its rows.
--aparte-conv-item-paddingvar(--aparte-space-4) var(--aparte-space-5)Padding of a row’s title button.
--aparte-conv-item-gapvar(--aparte-space-3)Gap between a row’s title and its button.
--aparte-conv-item-radiusvar(--aparte-radius-md)Corner radius of a row.
--aparte-conv-item-font-sizevar(--aparte-font-size-md)Font size of a row’s title.
--aparte-conv-item-colorvar(--aparte-text-muted)Title colour of an inactive row.
--aparte-conv-item-bg-hovervar(--aparte-surface-3)Row background on hover.
--aparte-conv-item-bg-activevar(--aparte-surface-3)Background of the row matching active-id.
--aparte-conv-item-color-activevar(--aparte-text)Title colour of the active row.
--aparte-conv-item-font-weight-activevar(--aparte-font-weight-medium)Title weight of the active row.
--aparte-conv-action-btn-sizevar(--aparte-btn-size-sm)Square size of the button (the recipe’s small step). Under (pointer: coarse) the stylesheet redeclares it as the touch target size on the button itself, so a value set on the element does not reach it there; the button also stays visible instead of appearing on hover.

The element is the same object everywhere — the tag does not change. What changes is how an attribute is written and how an event reaches you.

<aparte-conversation-list no-groups=""></aparte-conversation-list>
el.addEventListener('aparte-unpin-conversation', (e) => use(e.detail));

Installation and the framework-specific traps: React · Vue · Svelte · Angular.