Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

Chat panel

New

A small assistant panel: tabs across its head, replies that stream in under a reasoning line, and a composer at its foot.

chat-panel/chat-panel.tsx
Live · interactive

Compare mint chip to last summer

Sales HistoryFlavor Datafor 4s

Pulled 3 summers of mint chip sales for comparison.

ComparisonTrend Detectionfor 2s

Mint chip is up 12% with stronger weekend peaks.

The live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.

When to use

  • An assistant docked beside the work: a side panel or a corner of a dashboard, 380 px wide at most.
  • Short exchanges where each answer shows what the assistant did (Sales History, Flavor Data, for 4s) before what it found.
  • Answers that arrive in steps: each reply streams in on its own and sharpens once it is done.
  • A few parallel conversations, one per subject, switched by the tabs across the head.

When not to use

  • A full-page chat with a long history. Use a full-height thread with a richer composer.
  • Answers that need tables, charts or long documents: the 288 px panel has no room for them.
  • One question with a known set of answers. Use an approval card or a form.
  • Notifications or status from a background job. Use a toast or an agent progress view.

You can add this component with the shadcn CLI or by hand:

Into components/chat-panel of any React project, with no setup:

Terminal
npx shadcn@latest add https://brunodelorence.com/r/chat-panel.json

Either way it needs React 19.2 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: it follows your page’s color-scheme, light unless the page sets one, or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.

Usage

flavor-assistant.tsx
import { ChatPanel, type ChatReply } from './chat-panel';export function FlavorAssistant({ ask }: { ask: (text: string) => ChatReply[] }) {  return (    <ChatPanel      autoplay={false}      tabs={['Flavors', 'Suppliers']}      prompt="Compare mint chip to last summer"      respond={ask}      onSend={(text) => console.log('sent', text)}      placeholder="Prompt or tag a flavor with @"    />  );}

Each state below is a live instance, locked so you can study it.

The pieces it is built from, each with its measurements read live.

A compact chat panel for a side of an app. Tabs and three actions sit across its head; the conversation is the reader’s message in a bubble, then the assistant’s replies, each a reasoning line (what it did, from what, for how long) over what it found. Send a message and the replies stream in one by one, each rising into place, the latest a touch out of focus until it is done.

  • Sending replaces the conversation: the message shows at once, the composer empties and the send button greys out. The replies then arrive on a clock, the first 545 ms later and each next one 1375 ms after the one before, so they read as work coming in rather than a page loading.
  • A reply rises 8 px and fades in over 400 ms on cubic-bezier(0.23, 1, 0.32, 1). Every reply after the first arrives still being worked on, blurred by half a pixel, and sharpens over 400 ms 1200 ms later; the log is aria-busy until the last one has settled.
  • Each conversation has a key that every send bumps, and the replies are keyed by it, so a new conversation plays its replies in again while a settled one never re-animates.
  • The tabs are a real tablist with roving focus: the picked one sits on a filled chip, the others at half strength (three quarters under the pointer), crossfading in 100 ms. The conversation stays; a host swaps it on onTabChange.
  • The composer is a form, so Enter sends. Its edge darkens while it has focus (150 ms); the send button fills with the ink colour as soon as there is something to send (200 ms) and gives to 0.96 under the press. A press on the composer’s padding focuses the prompt.
  • The panel is 288 px tall at most 380 px wide; head and foot are fixed and the conversation scrolls between them, following each new reply into view.
  • While on screen and untouched, the demo types a question into the composer, sends it, lets the replies stream in, holds, and asks the next one. Any input hands it to the reader, with what the demo had typed cleared.
  • Every colour is a light-dark() token on the root, so the panel follows the page’s color-scheme or the colorScheme prop.
PropTypeDefaultDescription
tabsstring[]['Flavors', 'Suppliers']The tabs across the head.
tabnumber—The picked tab’s index; with it, the tabs are controlled (pair it with onTabChange).
defaultTabnumber0The tab picked first, when uncontrolled.
onTabChange(index: number) => void—Called with the index of the tab picked.
promptstring'Compare mint chip to last summer'The conversation’s opening message.
repliesChatReply[]—The replies shown under it, complete: each a tool, a source, a time in seconds and the text it found.
respond(text: string) => ChatReply[]—The replies that stream in after a message is sent; the opening replies by default.
defaultStateChatPanelState—Opens in a given state, with no demo: draft (words in the composer), arrived and settled (how many opening replies are in, and in focus), focused (the composer drawn focused) and pressed (the send button held).
onSend(text: string) => void—Called with each message sent.
onAction(action: 'new' | 'history' | 'more') => void—Called when one of the head’s actions is pressed.
placeholderstring'Prompt or tag a flavor with @'The composer’s placeholder.
autoplaybooleantrueWhile on screen and untouched, the panel types a question, sends it and streams the replies in by itself.
colorScheme'light' | 'dark' | 'system'—Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one.
classNamestring—Extra class for the root.

The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.

chat-panel.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import {  useCallback,  useEffect,  useId,  useRef,  useState,  type FormEvent,  type KeyboardEvent,} from 'react';import { useAutoplay } from './use-autoplay';import styles from './chat-panel.module.css';/* * Chat panel: a small assistant panel. Tabs and three actions across its head, the conversation * in the middle (the reader's message in a bubble on the right, then the assistant's replies, * each a reasoning line, "Sales History  Flavor Data  for 4s", over what it found) and a composer * at its foot. * * How it moves * - Sending replaces the conversation: the message shows at once, the composer empties and its
chat-panel.module.css
/* Chat panel (chat-panel.tsx): a quiet stage with a small assistant panel in its middle, tabs and   actions across its head, the conversation, and the composer at its foot. At night the stage is   near-black and the panel a shade lighter; by day the stage is a pale grey and the panel white.   Colours are the tokens below, light-dark(light, dark) where the two differ: the component takes   the color-scheme it inherits from the page (light unless the page says otherwise), or the one   its colorScheme prop sets (data-color-scheme).   Memory. What moves is opacity, a transform and a half-pixel blur on a reply as it comes in, and   a few colours; nothing runs while the conversation is still. */.root {  /* The words: messages and replies, the reasoning line's muted words, the icons and placeholder. */  --ink: oklch(24.7% 0.006 258.361);  --ink-2: oklch(50.6% 0.01 264.477);  --ink-3: oklch(69.5% 0.009 264.505);  /* The stage, the panel, and the fills: the picked tab, the message, the composer. */  --canvas: oklch(96.1% 0.002 247.84);  --surface: oklch(100% 0 0);  --field: oklch(96.1% 0.001 286.375);  --hover: oklch(97% 0.002 247.839);  /* Edges: the stage's and the head's rule, the composer's (darker with focus), the idle send. */  --line: oklch(94.6% 0.003 264.542);  --line-strong: oklch(91.2% 0.005 258.326);
use-autoplay.ts
'use client';import { useCallback, useEffect, useRef, useState } from 'react';/* * When a demo may play on its own: while part of it is on screen and the tab is visible, never * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it * takes back over `idle` ms after their last input. */export function useAutoplay<T extends HTMLElement>(idle = 6000) {  const ref = useRef<T>(null);  const [visible, setVisible] = useState(false);  const [reduced, setReduced] = useState(true);  const [taken, setTaken] = useState(false);  const timer = useRef(0);  useEffect(() => {    const element = ref.current;    if (!element) return;    let onScreen = false;    const update = () => setVisible(onScreen && !document.hidden);    const observer = new IntersectionObserver(      ([entry]) => {        onScreen = entry.isIntersecting;
  • The tabs are a tablist with one tab stop: arrow keys, Home and End move between them, and the conversation is their tabpanel.
  • The conversation is a polite log: new replies are announced as they arrive, and it is aria-busy while one is still being worked on. It can be focused and scrolled from the keyboard.
  • The head’s actions are buttons named New chat, History and More; the icons are decorative.
  • The composer is a form with a labelled prompt, so Enter sends; the send button is disabled while there is nothing to send. The prompt shows its focus by the composer’s darker edge and its caret; every other control has a visible focus ring.
  • Under reduced motion the demo never plays, and replies, tabs and buttons change without animation.