Skip to content
kitn AI/UI

How it's built

You don’t need to know any of this to use <kai-chat>. Read it when you want to go deeper: import a building block directly, compose your own layout, or change the kit itself.

Every kai-* element is a thin Shadow-DOM wrapper around a SolidJS component. The interesting parts — the streaming, the auto-resize, the stick-to-bottom scroll, the accessible toggles — live in plain Solid code underneath. The web component is just the last mile that turns that code into a custom element any framework can use.

The source (src/) stacks in four layers, each built on the one below.

1. Headless primitives (src/primitives/) — behavior, no markup. These are Solid hooks and small utilities: useTextStream (token-by-token streaming), useStickToBottom (auto-scroll that yields when the reader scrolls up), useAutoResize (textarea growth), useVoiceRecorder, plus the generative-UI card contract. They hold logic you’d otherwise rewrite in every chat app.

2. UI primitives (src/ui/) — small, accessible building blocks styled entirely through the design tokens: Button, Avatar, Switch, Tooltip, HoverCard, Dropdown, Resizable, ScrollArea, Separator. They’re keyboard-operable, theme-aware, and know nothing about chat.

3. Feature components (src/components/) — the AI surface, assembled from the layers below: ChatContainer, Message, PromptInput, Markdown, Reasoning, Tool, Artifact, ConversationList, the generative-UI cards, and the rest. This is where “a chat message with copy actions” or “a streaming reasoning panel” actually takes shape.

4. The element facade (src/elements/) — each feature component wrapped as a kai-* custom element. This layer adds Shadow DOM, the design-token stylesheet, prop/attribute handling, and kai-* CustomEvents. It’s deliberately thin: most files are a few lines mapping props and events onto the component underneath.

src/primitives/ → behavior (useTextStream, useStickToBottom, card contract)
src/ui/ → accessible building blocks (Button, Switch, Resizable)
src/components/ → AI feature components (ChatContainer, Message, PromptInput)
src/elements/ → kai-* custom elements (the Shadow-DOM facade)

The package exposes two of these layers directly. @kitn.ai/ui (the . export) gives you layers 1–3 as Solid source; @kitn.ai/ui/elements gives you layer 4, the registered kai-* elements. Working with the components directly covers the first; the rest of the docs cover the second.

Solid compiles to small, fast DOM updates with no virtual DOM — a good fit for streaming text and frequently-updating chat state. Wrapping each component in a custom element then buys framework independence: the same compiled element drops into React, Vue, Svelte, Angular, or plain HTML, with its styles sealed inside Shadow DOM so neither side can leak into the other.

So the architecture gives you both. Inside a Solid app you can skip the wrapper and compose the components directly. Anywhere else, you get a self-contained element with no Solid runtime to think about.

<kai-switch> is a good small example — the whole element is the wrapper around the Switch UI primitive:

import { defineWebComponent } from './define';
import { Switch } from '../ui/switch';
defineWebComponent('kai-switch', {
checked: undefined,
disabled: undefined,
label: undefined,
}, (props, { dispatch, flag }) => (
<Switch
defaultChecked={flag('checked')}
disabled={flag('disabled')}
label={props.label}
onChange={(checked) => dispatch('kai-change', { checked })}
/>
));

defineWebComponent (src/elements/define.tsx) does the heavy lifting for every element:

  • Registers the tag and renders the component into its Shadow DOM.
  • Adopts the compiled kit stylesheet into each shadow root (one shared CSSStyleSheet, not a copy per instance), so Tailwind classes resolve inside.
  • Adds a theme prop to every element (light / dark / auto, default auto follows the OS) that toggles the dark token scope.
  • Hands the facade two helpers: dispatch(type, detail) fires a kai-* CustomEvent off the host, and flag(name) resolves a boolean the way HTML authors expect — <el checked>, <el checked="true">, and el.checked = true all read as on.

That last point matters: a bare boolean attribute parses to undefined, not true, so facades use flag() rather than the raw prop for any on/off prop.