Open Source ChatGPT Clone UI, Assembled with aparté
The whole application, assembled from what ships: the shell grid and the header are
recipes (classes), the sidebar and the conversation list are elements (they have
behaviour — the drawer, the search, the selection), and the chat is <aparte-chat>. It
talks to a scripted model here — no key, no network — so everything you see run is
real except the model: the loop, the transcript, the tool call, the attachments.
<!-- The whole application, assembled from what ships: the shell grid and the header are recipes, the sidebar and the conversation list are elements, the chat is the chat. This frame talks to a SCRIPTED model (no key, no network) - type "haiku", "table", "code" or "weather" and watch the loop, the transcript and the tool call run for real. Everything but the model is what your users would get. The sidebar keeps the shell's own 48rem breakpoint: below 768px it is a drawer behind the toggle - the frame is a window of its own, so narrow the page (or press 375) and watch it fold, exactly as it would on a phone. --><div class="aparte-app-shell" style="height: 42rem"> <aparte-sidebar> <div class="aparte-sidebar__header"> <span class="aparte-sidebar__brand">(aparté)</span> <button class="aparte-btn aparte-btn--icon aparte-btn--sm" type="button" aria-label="New conversation"> <aparte-icon name="edit"></aparte-icon> </button> </div> <div class="aparte-sidebar__search aparte-field-group"> <input class="aparte-field aparte-field--sm" type="search" placeholder="Search conversations" aria-label="Search conversations" data-aparte-sidebar-search> </div> <div class="aparte-sidebar__body"> <aparte-conversation-list active-id="c1"></aparte-conversation-list> </div> <div class="aparte-sidebar__footer"> <span class="aparte-avatar aparte-avatar--sm">P</span> Paul </div> </aparte-sidebar> <header class="aparte-app-header"> <button class="aparte-btn aparte-btn--icon aparte-app-header__toggle" type="button" aria-label="Toggle the sidebar" data-aparte-sidebar-toggle><aparte-icon name="menu"></aparte-icon></button> <span class="aparte-app-header__title">What is aparté?</span> <div class="aparte-app-header__actions"><span class="aparte-tag">scripted model</span></div> </header> <main class="aparte-app-shell__main"> <!-- overlay-composer: the transcript's scroll surface spans the whole column and the composer floats over it, so the scrollbar runs to the bottom edge - the anatomy ChatGPT has, one attribute here. --> <aparte-chat attachments overlay-composer> <aparte-chat-viewport> <aparte-chat-bubble message-id="u1" data-role="user" content="What is aparté, in one sentence?"></aparte-chat-bubble> <aparte-chat-bubble message-id="a1" data-role="assistant" name="Assistant" content="A chat UI in Web Components with the agent loop inside - streaming markdown, tool calls a person approves, typed questions and branching, with zero dependencies. This whole screen is it: ask me for a haiku, a table, some code, or the weather."></aparte-chat-bubble> </aparte-chat-viewport> <aparte-composer> <div class="aparte-composer-shell"> <aparte-composer-attachments></aparte-composer-attachments> <div class="aparte-composer-row"> <aparte-composer-add-attachment></aparte-composer-add-attachment> <aparte-composer-input></aparte-composer-input> <aparte-composer-send></aparte-composer-send> </div> </div> </aparte-composer> </aparte-chat> </main></div>
<script> const day = 864e5; const list = document.querySelector('aparte-conversation-list'); list.conversations = [ { id: 'c1', title: 'What is aparté?', updatedAt: Date.now() }, { id: 'c2', title: 'Approve a tool call', updatedAt: Date.now() - day }, { id: 'c3', title: 'Branch a reply', updatedAt: Date.now() - 3 * day }, { id: 'c4', title: 'Theme it in three variables', updatedAt: Date.now() - 12 * day }, ]; // The header title follows the selected conversation - one listener, no framework. list.addEventListener('aparte-conversation-select', (e) => { const picked = list.conversations.find((c) => c.id === e.detail.id); if (picked) document.querySelector('.aparte-app-header__title').textContent = picked.title; });</script>What you are looking at
Section titled “What you are looking at”.aparte-app-shell— a two-column grid: the sidebar spans both rows, the header and the main area take the second column. Give it a height and it fills it.<aparte-sidebar>— collapsible, a drawer under itsbreakpoint, and it listens fordata-aparte-sidebar-toggleanddata-aparte-sidebar-searchon its own, so the header’s button and the search field need no script.<aparte-conversation-list>— takes aconversationsarray, marksactive-id, and dispatchesaparte-conversation-select. The header title above follows it in one listener.<aparte-chat attachments>— the chat with the file picker; the host consumes the files (anAparteClientdoes).
Make it yours
Section titled “Make it yours”Three moves turn this into your application:
- The model. Replace the scripted provider with yours — an OpenAI-compatible endpoint
(
@aparte/provider-openai-compat, presets for OpenAI, Mistral, OpenRouter, Ollama, LM Studio), the Vercel AI SDK, or a model in the browser (@aparte/provider-transformers) — or keep the key on your server with the backend transport. - The conversations. Feed
conversationsfrom your store and persist with the conversation manager. - The look. It is all CSS variables and light DOM — see theming.
The recipe behind the shell, and its mobile behaviour, are on the app shell guide; the layouts a chat can take — split, feed, side panel — on the layout guide.