{"slug": "build-a-streaming-ai-chat-ui-in-react-with-shadcn-ui-2026-guide", "title": "Build a Streaming AI Chat UI in React with shadcn/ui (2026 Guide)", "summary": "A developer published a guide for building a streaming AI chat interface in React using shadcn/ui, the Vercel AI SDK, and four open-source components from Cuelark UI (Conversation, Message, StreamingText, and PromptInput). The guide frames the work around four UX problems that streaming introduces—scroll ownership, interruptibility, visual stability, and screen-reader announcements—and maps each component to one of them, with components installed as editable source via the shadcn registry.", "body_md": "Getting tokens from a model to the browser is the easy part. Every modern SDK does it in a few lines. The hard part is everything the user actually experiences while those tokens arrive: the view that yanks them back to the bottom while they are trying to read, the Send button that does nothing while a reply runs, the paragraph that reflows every 40 milliseconds, the screen reader that either announces nothing or announces every single token.\n\nThis guide builds a streaming chat interface in React that gets those details right. We will use shadcn/ui as the foundation, the Vercel AI SDK for the transport, and four open-source components from Cuelark UI for the parts shadcn does not ship: `Conversation`, `Message`, `StreamingText` and `PromptInput`. Every component is installed as source, so if you disagree with a decision we made, you can change the file.\n\nBy the end you will have:\n\nBefore writing code, it helps to name the problems. A static list of messages is a solved problem. Streaming adds four new ones.\n\n**Scroll ownership.** While a reply streams, the view should stick to the bottom. The instant the reader scrolls up, the interface must stop chasing new tokens, because the reader has told you they want to read something else. When they want to come back, they need a fast way to do it.\n\n**Interruptibility.** A reply in progress is a running process. If the model has misunderstood the question, the user should be able to stop it immediately, from the keyboard as well as the mouse. A text box with no Stop button during streaming feels broken.\n\n**Visual stability.** Each chunk that arrives can cause the paragraph to reflow. Words that were already read should not move, flash or re-render with a different weight.\n\n**Announcements.** Assistive technology should hear that a reply is arriving without being flooded token by token.\n\nThe four components below map one to one onto those problems.\n\nYou need a React project configured for shadcn/ui with Tailwind CSS v4. Next.js, Vite, TanStack Start, React Router and Astro all work. If you are starting fresh:\n\n```\nnpx shadcn@latest init\n```\n\nThen add the Cuelark registry to `components.json` once, so you can install components by name:\n\n```\n{\n  \"registries\": {\n    \"@cuelark\": \"https://ui.cuelark.com/r/{name}.json\"\n  }\n}\n```\n\nNow install the four chat components:\n\n```\nnpx shadcn@latest add @cuelark/conversation @cuelark/message @cuelark/streaming-text @cuelark/prompt-input\n```\n\nIf you would rather not touch `components.json`, each component also installs by URL, for example `npx shadcn@latest add https://ui.cuelark.com/r/conversation.json`. The CLI brings the theme tokens and the motion layer along with whatever you add. The full set of options is on the [installation page](https://ui.cuelark.com/docs/installation).\n\nFinally, install the AI SDK packages:\n\n```\nnpm install ai @ai-sdk/react @ai-sdk/openai\n```\n\nThe examples use OpenAI, but any AI SDK provider works the same way.\n\nThe server side is short. This is a Next.js App Router route; the shape is similar for other frameworks.\n\n``` js\n// app/api/chat/route.ts\nimport { openai } from \"@ai-sdk/openai\";\nimport { streamText, convertToModelMessages, type UIMessage } from \"ai\";\n\nexport async function POST(req: Request) {\n  const { messages }: { messages: UIMessage[] } = await req.json();\n\n  const result = streamText({\n    model: openai(\"gpt-4o-mini\"),\n    messages: convertToModelMessages(messages),\n  });\n\n  return result.toUIMessageStreamResponse();\n}\n```\n\nThe snippets here are written against AI SDK 5's `useChat` and UI message stream. If you are on a different major version, the hook names may differ slightly; the UI layer does not change.\n\nHere is the skeleton of the chat page.\n\n``` js\n\"use client\";\n\nimport { useChat } from \"@ai-sdk/react\";\nimport { Conversation, ConversationContent } from \"@/components/ui/conversation\";\nimport { Message, MessageContent } from \"@/components/ui/message\";\nimport { StreamingText } from \"@/components/ui/streaming-text\";\nimport { PromptInput } from \"@/components/ui/prompt-input\";\n\nexport default function Chat() {\n  const { messages, sendMessage, status, stop } = useChat();\n  const isStreaming = status === \"submitted\" || status === \"streaming\";\n\n  return (\n    <div className=\"mx-auto flex h-dvh max-w-3xl flex-col gap-4 p-4\">\n      <Conversation className=\"flex-1\">\n        <ConversationContent>\n          {/* messages go here */}\n        </ConversationContent>\n      </Conversation>\n\n      <PromptInput\n        status={isStreaming ? \"streaming\" : \"idle\"}\n        onSubmit={({ text }) => sendMessage({ text })}\n        onStop={stop}\n      />\n    </div>\n  );\n}\n```\n\n`Conversation` is the piece that handles scroll ownership. It follows the newest message while a reply streams and stops following as soon as the reader scrolls up. When they are away from the bottom, a \"Latest\" pill appears to jump back. You do not wire any of this yourself: no `IntersectionObserver`, no `scrollIntoView` in an effect, no \"is the user near the bottom\" threshold to tune.\n\nIt also renders with `role=\"log\"`, which tells screen readers this is a region where new content arrives in order, and announcements are polite, so they do not interrupt whatever the user is doing. The \"Latest\" pill is skipped by Tab while it is hidden.\n\nA common mistake is to put the scroll container's height on a parent and let the container grow. Give `Conversation` the flexible space (here `flex-1` inside a full-height column) so it is the element that scrolls.\n\nEach turn is a `Message`. User turns render as a compact bubble; assistant turns read as a full-width document with an avatar and quiet actions (copy, regenerate, rate) that appear on hover and stay visible on touch devices.\n\n``` js\n<ConversationContent>\n  {messages.map((m, i) => {\n    const text = m.parts\n      .filter((p) => p.type === \"text\")\n      .map((p) => p.text)\n      .join(\"\");\n    const isLast = i === messages.length - 1;\n\n    return (\n      <Message key={m.id} from={m.role === \"user\" ? \"user\" : \"assistant\"}>\n        <MessageContent>\n          {m.role === \"assistant\" ? (\n            <StreamingText text={text} streaming={isStreaming && isLast} />\n          ) : (\n            text\n          )}\n        </MessageContent>\n      </Message>\n    );\n  })}\n</ConversationContent>\n```\n\n`StreamingText` takes two props: the `text` so far and whether it is still `streaming`. Each new chunk fades up out of a slight blur rather than popping in, a small caret marks the writing edge while streaming is true, and text already on screen never moves. That last property is the one that matters for long answers: people read streamed text as it arrives, and a paragraph that keeps shifting is tiring to follow.\n\nOnly the last assistant message should be marked as streaming. Earlier turns are history and should render as finished text with no caret.\n\n`PromptInput` is the composer, and it does more than a textarea:\n\n`attachments={false}` to turn that off);`status` is `\"streaming\"`, Send becomes Stop, and Esc stops the run.\nThat swap is the interruptibility problem solved. Pass the SDK's `stop` function to `onStop` and you are done. The component also accepts a `toolbar` prop for extra controls, which is where a model picker belongs if your product lets people trade speed for quality. The `onSubmit` callback receives `{ text, files }`, so file uploads use the same handler.\n\nThree small details separate a demo from a product.\n\n**The gap before the first token.** Between pressing Send and the first chunk, the AI SDK reports `status === \"submitted\"`. Treating that as streaming (as the code above does) means the Stop button appears immediately, which is exactly when users most often realise they asked the wrong question.\n\n**Errors.** When `status` becomes `\"error\"`, show it inside the conversation, next to the turn that failed, not as a toast that disappears. The user needs to see which message did not get an answer.\n\n**Empty state.** An empty `Conversation` is a blank rectangle. A few suggested prompts tell people what the assistant is good at. Cuelark UI has a `Suggestions` component for this; three suggestions work better than eight.\n\nBecause everything installs as source styled with Tailwind CSS v4 tokens, the chat picks up your existing shadcn theme. If you want a starting point, the registry includes ready-made themes you can install with one command; browse them on the [theme gallery](https://ui.cuelark.com/themes).\n\nMotion is controlled globally by one CSS variable, `--cl-motion`. Set it to `0` for a dense internal tool where you want nothing to move, or raise it on a marketing demo. A visitor's reduced-motion setting always wins over whatever you choose.\n\n```\n:root {\n  --cl-motion: 0.5;\n}\n```\n\nBefore you ship, test these with a keyboard and a screen reader:\n\nThe components cover these by default, but your layout can break them: a parent with `overflow: hidden`, a custom focus style that removes outlines, or a wrapper that steals Enter.\n\nA plain chat is the start. Once the model can think, call tools and ask permission, the interface needs more places to put things: reasoning that folds away, tool calls shown as facts, approvals that are hard to click through by accident. We cover those in the companion article on tool calls, reasoning and approvals, and you can see all of it working together in the [live research agent example](https://ui.cuelark.com/agents/research-agent), where every part of the interface can be inspected and copied.\n\n**Is Cuelark UI free?** Yes. It is open source under the MIT licence, and the code is on GitHub at github.com/cuelark/ui.\n\n**Do I have to use the Vercel AI SDK?** No. The components take plain props (`text`, `streaming`, `status`, `onSubmit`, `onStop`), so any streaming source works.\n\n**Can I change how the components look?** Yes. They are copied into your project as source, so you edit them like any other file in your codebase.", "url": "https://wpnews.pro/news/build-a-streaming-ai-chat-ui-in-react-with-shadcn-ui-2026-guide", "canonical_source": "https://dev.to/cuelark/build-a-streaming-ai-chat-ui-in-react-with-shadcnui-2026-guide-2p42", "published_at": "2026-10-31 12:09:37+00:00", "updated_at": "2026-10-31 12:19:41.818698+00:00", "lang": "en", "topics": ["ai-tools", "developer-tools", "generative-ai", "ai-products"], "entities": ["shadcn/ui", "Cuelark UI", "Vercel AI SDK", "React", "OpenAI", "Next.js", "Tailwind CSS"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/build-a-streaming-ai-chat-ui-in-react-with-shadcn-ui-2026-guide", "markdown": "https://wpnews.pro/news/build-a-streaming-ai-chat-ui-in-react-with-shadcn-ui-2026-guide.md", "text": "https://wpnews.pro/news/build-a-streaming-ai-chat-ui-in-react-with-shadcn-ui-2026-guide.txt", "jsonld": "https://wpnews.pro/news/build-a-streaming-ai-chat-ui-in-react-with-shadcn-ui-2026-guide.jsonld"}}