Quick start
Install the package, set it up, and assemble your first chat.
@intentface/ui ships the behavior of a chat interface and none of its appearance. This page takes you from an empty React 19 app to a working chat.
Install the library
npm install @intentface/uireact and react-dom (v19+) are the only peer dependencies. Nothing else
ships with it beyond one small runtime dep — @floating-ui/dom for anchored
positioning. No styling, no editor framework, no animation library.
Upgrading from @intentface/chat? The package was renamed in 0.7.0. Swap the
dependency for @intentface/ui, then replace @intentface/chat/ with
@intentface/ui/ in your imports. The rename changes nothing else.
Each primitive is a separate entry point:
import { Composer } from "@intentface/ui/composer";
import { Message } from "@intentface/ui/message";
import { Thread } from "@intentface/ui/thread";
import { groupTurns } from "@intentface/ui/message-utils";Set up
Portals
The composer's command popover, its panel, and the attachment preview all render
through portals into document.body, so they escape any overflow or transform on
your layout. To keep them above the rest of the page regardless of your own
stacking, give your app root its own stacking context.
In your root layout:
<body>
<div className="root">{children}</div>
</body>And in your global stylesheet:
.root {
isolation: isolate;
}Without this, a z-index anywhere in your layout can paint over the command
popover.
Assemble a component
Three primitives make a chat: Thread owns the scroll area and measures its
floating composer, Message renders each turn, and Composer takes input.
Every part renders semantic DOM with data-* state attributes and no classes of
its own — you pass className to each one, so the look is yours from the first
render.
"use client";
import { Composer, type ComposerSubmitData } from "@intentface/ui/composer";
import { Message } from "@intentface/ui/message";
import { groupTurns } from "@intentface/ui/message-utils";
import { Thread } from "@intentface/ui/thread";
import { ArrowUp } from "@keyline-icons/react";
import { useState } from "react";
type ChatMessage = { id: string; role: "user" | "assistant"; text: string };
const INITIAL: ChatMessage[] = [
{ id: "1", role: "user", text: "Can you summarise this thread?" },
{
id: "2",
role: "assistant",
text: "Sure — it covers the composer's segment model, how chips serialise, and why the editor owns its own DOM.",
},
];
// Three primitives assembled: Thread owns the scroll area and reserves space
// for its floating composer, Message renders each turn, Composer takes input.
export const Hero = () => {
const [messages, setMessages] = useState<ChatMessage[]>(INITIAL);
const handleSubmit = (data: ComposerSubmitData) => {
if (data.kind !== "message" || !data.text.trim()) return;
setMessages((current) => [
...current,
{ id: `${current.length}`, role: "user", text: data.text },
]);
};
return (
<div className="h-[360px] w-full max-w-xl overflow-hidden rounded-xl bg-white shadow-[0_0_0_1px_rgb(0_0_0/0.075),0_1px_2px_rgb(0_0_0/0.06),0_4px_8px_-2px_rgb(0_0_0/0.05)] dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.05),inset_0_0_0_1px_rgb(255_255_255/0.06),0_0_0_1px_rgb(0_0_0/0.16),0_1px_2px_rgb(0_0_0/0.1)] dark:bg-zinc-900">
<Thread.Root className="relative flex h-full w-full overflow-hidden [--thread-inset-top:1rem] [--thread-composer-gap:2rem]">
{/* A fade over the pt-4 below, which is the gap a landed turn leaves at the top. */}
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 top-0 z-1 h-4 bg-linear-to-b from-white to-transparent dark:from-zinc-900"
/>
<Thread.Viewport className="h-full w-full overflow-x-hidden overflow-y-auto outline-none [overflow-anchor:auto]">
<div className="relative flex min-h-full w-full flex-col items-center">
<Thread.Content className="mx-auto flex min-h-full w-full flex-col gap-4 px-4 pt-(--thread-inset-top) pb-(--thread-inset-bottom) [&>*:last-child]:min-h-(--thread-turn-min-height)">
{groupTurns(messages).map((turn) => (
<Message.Turn key={turn.key} className="flex flex-col gap-4">
{turn.messages.map((message) => (
<Message.Root
key={message.id}
role={message.role}
isLast={message.id === messages.at(-1)?.id}
className="group flex w-full flex-col gap-1.5 data-[role=user]:items-end"
>
<Message.Text className="text-sm text-zinc-700 leading-6 group-data-[role=user]:max-w-[80%] group-data-[role=user]:rounded-[20px] group-data-[role=user]:bg-white group-data-[role=user]:px-3.5 group-data-[role=user]:py-1.5 group-data-[role=user]:text-zinc-900 group-data-[role=user]:shadow-[0_0_0_1px_rgb(0_0_0/0.08),0_1px_2px_rgb(0_0_0/0.04)] dark:text-zinc-300 dark:group-data-[role=user]:bg-zinc-800 dark:group-data-[role=user]:text-zinc-100 dark:group-data-[role=user]:shadow-[inset_0_1px_0_rgb(255_255_255/0.05),inset_0_0_0_1px_rgb(255_255_255/0.06),0_0_0_1px_rgb(0_0_0/0.16)]">
{message.text}
</Message.Text>
</Message.Root>
))}
</Message.Turn>
))}
</Thread.Content>
</div>
</Thread.Viewport>
<Thread.Composer className="absolute inset-x-0 bottom-0 z-2 w-full">
<div className="flex w-full flex-col items-center px-4 pb-4">
<Composer.Root onSubmit={handleSubmit} className="flex w-full flex-col">
<Composer.Container className="cursor-text rounded-xl bg-white p-1 shadow-[0_0_0_1px_rgb(0_0_0/0.08),0_1px_2px_-1px_rgb(0_0_0/0.08),0_6px_16px_-6px_rgb(0_0_0/0.1)] dark:bg-zinc-800 dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.06),inset_0_0_0_1px_rgb(255_255_255/0.07),0_0_0_1px_rgb(0_0_0/0.2),0_1px_2px_rgb(0_0_0/0.12),0_6px_16px_-6px_rgb(0_0_0/0.22)]">
{/* The editable element is engine-owned and out of JSX reach, so
it is styled through the data-composer-editor variants. */}
<Composer.Textarea className="max-h-32 min-h-12 overflow-y-auto px-2.5 pt-2.5 text-sm text-zinc-900 dark:text-zinc-100 **:data-composer-editor:w-full **:data-composer-editor:max-w-none **:data-composer-editor:leading-6 [&_[data-composer-editor]:focus]:outline-none">
<Composer.Placeholder
placeholder="Send a message…"
className="text-zinc-400 leading-6 dark:text-zinc-500"
/>
</Composer.Textarea>
<Composer.Actions className="flex h-12 items-center justify-end px-2.5">
<Composer.Submit className="flex size-7 cursor-pointer items-center justify-center rounded-full bg-[#0169cc] bg-linear-to-b from-[oklch(57.2%_0.166_253.2)] to-[oklch(52.9%_0.173_255)] text-white shadow-[inset_0_1px_0_rgb(255_255_255/0.28),0_0_0_1px_oklch(46.5%_0.146_254.8),0_1px_2px_rgb(1_105_204/0.35)] transition-opacity focus-visible:outline-2 focus-visible:outline-[#0169cc]/60 focus-visible:outline-offset-2 disabled:cursor-default disabled:opacity-40">
<ArrowUp className="size-[15px]" />
</Composer.Submit>
</Composer.Actions>
</Composer.Container>
</Composer.Root>
</div>
</Thread.Composer>
</Thread.Root>
</div>
);
};Composer.Submit disables itself while the field is empty. Thread applies no
layout of its own. It publishes CSS variables, such as the room the composer takes
as --thread-inset-bottom, and the demo's classes position the composer and pad
the content with them so the scroll area never hides behind it. See
Composer, Thread, and
Message for the full part lists, and the Thread page's
Anatomy for the layout classes a thread needs.
Pre-styled components
There is no pre-styled @intentface/ui package, and no CSS to install. The
demos on each component page are the styled reference: they use stock Tailwind,
depend on nothing but this package, and are meant to be copied and edited.
This site's own chat is built from components that live in the app, not the package. They use design tokens, Motion, and local icon files, and they are not published — read them as a reference implementation if you like, but they are not a starting point.
Working with LLMs
Append .md to any docs URL to get that page as markdown — for example
/primitives/composer.md. Every demo's
source is inlined into it as a code block, so an agent reading the page gets the
code rather than a component tag it can't resolve. The View as Markdown link
in the page header does the same thing.
/llms.txt indexes every page with its description and markdown
URL. Feed it to an assistant to let it navigate the docs without crawling HTML.