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-elementsPeer 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:
| Peer | Why |
|---|---|
tailwindcss ^4 | Load-bearing. Every component is Tailwind utility markup; without the framework it renders unstyled. |
react ^19 | Same React the rest of the system needs. |
react-dom ^19 | Several components portal or measure; it is a peer in its own right, not implied by react. |
ai ^6 | The AI SDK's types. v6, not v7 — see below. |
@ai-sdk/react ^3 | useChat 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';tailwindcssis the framework itself. Elements needs v4; the@importform above is the v4 spelling, and a v3@tailwindtriple will not work.tokens.cssdeclares the custom properties. It has to be loaded — the next file aliases its variables, it does not define them.tailwind.cssis 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 tovar(--token). It is what makes Elements render on-brand with no edits, and it is also wheredark: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
@layerdeclaration has to come first.@import 'tailwindcss'carries its own layer order with it; importing the pieces by subpath does not, so without this lineutilitiessorts by where it first appears in the file and stops outrankingbase— the opposite of what makes Tailwind utilities win. ./elements-preflight.cssis your own copy ofnode_modules/tailwindcss/preflight.css, with every selector rewritten to reach only inside one wrapper element — the element that contains everything@elirobinson/ai-elementsrenders, 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 letstokens.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/reactis 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.