Installing AI Elements

@elirobinson/ai-elements publishes to the same registry as the rest of the system and is authenticated the same way, so if you already have the tokens and components installed the first two steps are done. If you do not, do the GitHub Packages step on the Installation page first — the .npmrc, the scope, and where the token goes are all there, and none of it differs here.

Install the package

pnpm add @elirobinson/ai-elements

Peer dependencies

Five, and your package manager will tell you about them, so the point of this section is which ones you have to act on:

PeerWhy
tailwindcss ^4Load-bearing. Every component is Tailwind utility markup; without the framework it renders unstyled.
react ^19Same React the rest of the system needs.
react-dom ^19Several components portal or measure; it is a peer in its own right, not implied by react.
ai ^6The AI SDK's types. v6, not v7 — see below.
@ai-sdk/react ^3useChat and the client bindings the components are shaped around.

The ai pin is the one that surprises people. ai@7 restructured LanguageModelUsage, and the vendored components/context reads the v6 shape; the pinned upstream release declares ai: ^6.0.105 itself. pnpm sync:elements diffs upstream's ranges on every run, so the day upstream moves, this moves with it.

Wire up the styles

Three imports in your app's entry stylesheet, in this order:

@import 'tailwindcss';
@import '@elirobinson/tokens/tokens.css';
@import '@elirobinson/tokens/tailwind.css';
  • tailwindcss is the framework itself. Elements needs v4; the @import form above is the v4 spelling, and a v3 @tailwind triple will not work.
  • tokens.css declares the custom properties. It has to be loaded — the next file aliases its variables, it does not define them.
  • tailwind.css is the bridge. It maps Tailwind's colour, radius, shadow and font namespaces onto the tokens with @theme inline, so the utilities the vendored components already use compile to var(--token). It is what makes Elements render on-brand with no edits, and it is also where dark: gets re-pointed at [data-theme="dark"].

The first line is also the one to slow down on if your app is not already built on Tailwind — read the next section before you paste it in. If your app is already Tailwind v4 throughout, the block above is already the right answer, and the simplest one: stop here.

If you are also using @elirobinson/react on the same page, its stylesheet goes in as usual — the two do not overlap. Elements carries no stylesheet of its own.

import '@elirobinson/react/styles.css';

If your app is not already Tailwind

@import 'tailwindcss' is one specifier for three things: Tailwind's theme, its utilities, and Preflight — Tailwind's global CSS reset. In a greenfield Tailwind app that reset is exactly what you want, and nothing below applies to you. In an app with its own pre-existing CSS, the reset lands everywhere the stylesheet reaches, which for a normal entry stylesheet is the whole app: headings lose their size and weight (h1, h2, h3, h4, h5, h6 { font-size: inherit; font-weight: inherit }), lists lose their markers (ol, ul, menu { list-style: none }), links lose their color and underline, and every button, form control, hr, and table is reset the same way. None of that is a Preflight bug — it is the blank slate Tailwind utilities are written to assume — but a site that was not expecting it does not announce that this import is why it changed.

We hit this ourselves: this docs site added @import 'tailwindcss' for the Elements demo surfaces on this site, and 176 of 692 visual baselines changed — most of them on pages the change never touched. The build gave no warning; it surfaced only once the visual suite ran in CI.

The way out is to import Tailwind's theme and utilities by subpath instead of the umbrella specifier, and to import a scoped copy of Preflight — its rules rewritten to reach only a wrapper element around your Elements markup — in the umbrella's place:

@layer theme, base, components, utilities;
@import 'tailwindcss/theme.css' layer(theme);
@import './elements-preflight.css' layer(base);
@import 'tailwindcss/utilities.css' layer(utilities);
@import '@elirobinson/tokens/tokens.css';
@import '@elirobinson/tokens/tailwind.css';
  • The @layer declaration has to come first. @import 'tailwindcss' carries its own layer order with it; importing the pieces by subpath does not, so without this line utilities sorts by where it first appears in the file and stops outranking base — the opposite of what makes Tailwind utilities win.
  • ./elements-preflight.css is your own copy of node_modules/tailwindcss/preflight.css, with every selector rewritten to reach only inside one wrapper element — the element that contains everything @elirobinson/ai-elements renders, and nothing else.
  • It has to sit where the umbrella's Preflight would have: after the theme, before tokens.css. Within @layer base, later wins at equal specificity, and that order is what lets tokens.css's own rules still beat the reset — exactly as they do against @import 'tailwindcss' itself.

Write the scope as :where(.your-wrapper), never a bare .your-wrapper. A bare class adds one class of specificity to every rule it prefixes, and that is enough to change who wins: tokens.css styles an unstyled anchor with a { color: var(--link); text-decoration: underline } at specificity (0,0,1), inside @layer base. .your-wrapper a is (0,1,1) — one class higher — so a bare-class scope beats that rule instead of losing to it, silently, because both are still valid CSS. That is not a hypothetical: it is exactly how this went wrong here once, and every link inside the wrapper rendered with no underline and no error until a visual diff caught it. :where() contributes zero specificity of its own, so scoping with it is what keeps each rule at exactly the specificity Tailwind gave it.

What scoping costs you: the reset only reaches markup inside the wrapper. An @elirobinson/ai-elements component mounted outside it — a portal, a toast, anywhere not physically nested inside that one element — renders unreset: native button chrome, a browser-default heading size if the component itself sets none, and so on. Nothing will fail to tell you when that happens; it will just look wrong. Keep every mount point for @elirobinson/ai-elements markup inside the one wrapper, or scope a copy of Preflight for each mount point that needs one.

Tell Tailwind to scan the package

Tailwind v4 only emits the utilities it can see, and it does not scan node_modules. Name the package's source tree next to the imports above — the path is relative to the stylesheet the directive is written in, so adjust the ../ to wherever yours lives:

@source '../node_modules/@elirobinson/ai-elements/src';
@source '../node_modules/@elirobinson/ai-elements/dist/fixtures';

The second line is the published audit fixtures. They are ordinary render inputs a consumer can mount, and their class strings live in dist/ rather than src/, so a fixture using a utility no vendored component uses would otherwise mount unstyled with no error. Drop it only if you never import @elirobinson/ai-elements/fixtures.

The package ships src alongside dist for exactly this. Without the directive the components mount and produce no styles — the same symptom as a missing Tailwind, from a different cause. This is also the configuration the accessibility sweep measures against: its harness stylesheet is these three imports and this directive, and nothing else.

Import a component

There is no barrel, exactly as in @elirobinson/react. Every import names a subpath, and the three namespaces mean different things:

import { Message, MessageContent } from '@elirobinson/ai-elements/components/message';
import { Button } from '@elirobinson/ai-elements/ui/button';
import { cn } from '@elirobinson/ai-elements/lib/utils';
  • components/* — AI Elements proper.
  • ui/* — the shadcn/ui primitives those components are built on. Reach for one when you are extending an Element. For the rest of a page, @elirobinson/react is the layer with the keyboard contracts and the props tables.
  • lib/* — the helpers the tree shares.

What exists and what each subpath exports is data, not documentation: read the component index, or the manifest it is generated from.

node -p "require('@elirobinson/ai-elements/manifest').entries.map(e => e.subpath).join('\n')"

Next step

Examples wires the client components to @elirobinson/ai-patterns/server and shows the round trip.