Installation
Two commands and two paste-ins: init makes the project ready, add copies components into it,
and the two things a CLI should not do to your files — importing the theme and marking your client
components — you do yourself.
Before you start
- React 18 or later, with TypeScript. Components ship as
.tsx. - Next.js 16, if you use Next.js. The React build that Next.js 15.5 bundles sometimes gives tabs, menus and other parts different ids on the server and in the browser, which breaks the links screen readers use between a tab and its panel. Next.js 16 fixes it. Vite apps aren't affected.
- Node 20 or later, for the CLI.
- A bundler that imports CSS from components. Each component imports its own stylesheet. Next.js and Vite both handle this out of the box.
Nothing else to configure: no CSS framework, no config file, no path alias.
Browser support
Chrome/Edge 111+, Safari 16.2+, Firefox 113+. Declared as a browserslist key so a build tool
can read it, and checked on two engines — the test suite runs against Chromium and WebKit.
The floor is oklch() and color-mix(). Echo's whole colour system derives every surface, every
border and every state from one seed colour, and that derivation happens in CSS at paint time
rather than being precomputed — which is what lets a theme relight from a single value. Those two
functions are how.
Below the floor the failure is silent: an unsupported color-mix() makes the whole declaration
invalid rather than falling back, so a surface loses its colour instead of degrading. createEchoTheme
therefore emits one @supports not (color: color-mix(…)) guard that paints plain seed colours on
the page root. An out-of-baseline visitor gets something legible and obviously unstyled rather than
invisible text on an invisible background. There are no per-property fallbacks beyond that, and
there will not be: a flat-colour twin for every color-mix() in the library is a second, worse
design system running in parallel forever.
What Echo brings with it
Two runtime dependencies arrive with the components that need them. You do not install either by hand — an item declares what it needs and the CLI installs it — but you should know they are there before you find them in your lockfile.
Base UI is the behaviour under most Echo controls: the keyboard handling, the focus management, the ARIA wiring. Echo skins it rather than reimplementing it, which is why a Select behaves like a Select.
GSAP is a required peer, not an optional enhancement. The overlays,
hardware-button and the controls built on it pull it in, and so does everything that uses them,
and echo-ui-core declares it as a peer dependency, so installing any of them without it is a
broken install rather than a degraded one.
Why it cannot simply fall back to CSS
Because the thing it does is not a CSS transition. Base UI unmounts a popup when it closes, and it
can wait for a CSS transition to finish before doing so — but it cannot detect a JavaScript
one. useGsapPresence exists to bridge exactly that: it runs the exit animation and only then
calls unmount(), so the element leaves the DOM after the animation rather than during it.
A CSS fallback would have to reimplement that handshake in CSS for every overlay, which is the feature Base UI does not offer for JS animations and the reason the hook exists. Echo would rather say GSAP is required than ship an overlay that vanishes mid-exit on some installs and not others.
If you cannot take the dependency, the components that do not use it — every control, every surface, the templates that avoid overlays — install and work without it.
Reduced motion is a different question, and it is already handled. Every motion token has a
reduced-motion form, and under prefers-reduced-motion: reduce the exit resolves immediately
instead of waiting out a tween. GSAP being required does not mean animation is forced on anyone.
What it costs
You only ship what you install. These are real builds, not estimates: a Vite + React 19 app built five times with different amounts of Echo in it, each row measured as every asset the production build emits, gzipped, minus the same app with no Echo at all (67.8 kB of React and the bundler).
| What you installed | Gzipped, over the same app without it |
|---|---|
| A badge — no GSAP, no Base UI | 2.7 kB |
A button (hardware-button) | 34.8 kB |
A form — field, textarea, switch, select, a button | 89.3 kB |
A template — chat, and the 7 items it pulls in | 49.5 kB |
| The whole library — all 115 items | 309.2 kB |
Read the first row before the others. 2.7 kB is what Echo itself costs for a component: the
theme, echo-ui-core and the component's own code and CSS. Almost everything above it is the two
libraries on this page. The button's 34.8 kB is mostly GSAP; the form's 89.3 kB is mostly Base UI,
and it is the same Base UI whether you install one control or twelve. The template is eight items
for 49.5 kB for the same reason: it shares the GSAP its button already brought. And all 115 items
together are 309.2 kB, about three and a half forms. (Measured 2026-10-12, with the
install matrix in Echo's repository run for sizes on Vite.)
So the useful question is not how many components you install — it is which of the two libraries you have taken on. Once you have taken both, each further component is small.
What you can count on
- Updates keep your edits. Echo records a hash of every file it installs.
npx echo-ui-cli diffsays what changed since, andnpx echo-ui-cli updatemerges Echo's change into files you've edited; where the two meet in the same lines, it shows them and changes nothing. initdoesn't touch your CSS. It writes the theme into its own file in the Echo folder and prints the one import line for you to paste. Your global CSS and your layout stay yours.- Few dependencies.
echo-ui-corehas none of its own: it asks for React and GSAP, which your app installs once. Components add Base UI where they need it, and nothing else. - First-party code only. Every file you install is Echo's own source, in your repository, where you can read it. The release workflow publishes each package with npm provenance, so a version can be traced to the commit it was built from.
- Accessible, and kept that way. Every component is tested against WCAG 2.2 AA: axe, keyboard, focus-visible, forced-colors, target-size, reflow.
npx echo-ui-cli checkkeeps an agent from undoing it in the screens it builds: unnamed controls, clicks with no keyboard, images without alt text, removed focus rings. What a check can't judge still needs a person: the reading order, and whether a name makes sense. Still open: screen-reader, not yet verified by a person.
Set the project up
npx echo-ui-cli initinit looks at what is already there — the framework, the lockfile, whether you keep source in
src/ — installs echo-ui-core, and writes three files:
{
"$schema": "https://echo-ui.com/e/schema/config.json",
"dir": "src/components/echo",
"framework": "next",
"packageManager": "pnpm",
"registry": "https://echo-ui.com/e"
}echo.json records where components go and which registry they come from. echo-lock.json starts
empty and records what you install, file by file, so a later add can tell an untouched file from
one you have edited. echo-theme.css is the theme, rendered once as plain CSS.
It also writes what your agent reads: a short Echo block in CLAUDE.md or AGENTS.md, DESIGN.md,
a hook that runs check after each edit, the Stop gate that runs
Echo's tests on the pages the agent changed before it finishes, and three skills (building with
Echo, Design Eye and Style Fit). What Echo puts in your project lists each one, and
The workflow shows them at work.
If the project is outside what Echo supports, init says which part and what to do about it,
rather than writing half an install into a project it doesn't understand. Nothing you already have
is edited — that is true of every Echo command.
Flags: --dir puts components somewhere other than the default, --seed generates the theme from
your own accent colour, --style wears one of your styles from the start, --registry points at a
different registry, --yes writes over an existing echo.json, --skip-agent leaves out what it
writes for agents, --ci also writes a GitHub Actions job that runs Echo's tests on every pull
request, and --dry-run says what it would do without doing it. echo.json has
every field it writes.
Add a component
Everything lands under the one folder echo.json names:
hardware-button/ hardware-button.css hardware-button.tsx index.ts treatment.ts size/ index.ts size.tsx
Items import each other by relative path inside that folder, so they resolve the same way here as
they do in Echo's own repo — there is no @/ alias to set up. Adding a component adds the Echo
items it builds on, and installs the npm packages it needs.
Before anything is written, add checks the whole plan: every item resolves, every file hashes to
what the registry published, and your echo-ui-core is in range for each item. A file you have
edited stops the install and is named, rather than being replaced; --overwrite is how you say to
replace it anyway.
Import the theme
Components are styled only through --echo-* custom properties, and nothing defines them until the
theme is on the page. init wrote it — import it once, wherever your global CSS is imported:
// app/layout.tsx
import "@/components/echo/echo-theme.css";
import "./globals.css";Give the page the device's own ground, dark or light as your chassis makes it:
/* globals.css */
body {
background: var(--echo-well);
color: var(--echo-text);
font-family: var(--echo-font-sans);
}Skip this step and every component renders with no colour at all.
The static file is one seed frozen into CSS. To change the theme at runtime — a seed a person picks,
or one that follows their brand — call createEchoTheme instead and put its CSS in a <style> at
the root:
import { createEchoTheme } from "echo-ui-core/theme";
const { css } = createEchoTheme({ accent: "#3D8EF0" });
<head>
<style>{css}</style>
</head>;Theming covers the seed in full.
Turn on sound
Components play sound through a SoundProvider. Without one they work, silently. Wrap the app:
import { SoundProvider } from "echo-ui-core/sound";
<body>
<SoundProvider>{children}</SoundProvider>
</body>;Sound starts off. Someone who hasn't chosen yet hears nothing until they turn it on, so give
them a switch (Patterns), or pass defaultMuted={false} where
sound is the point from the first press. Browsers only allow audio after a user gesture, so the
provider starts audio on the first pointer press, never on page load, and a person's choice is
remembered. Motion & sound has the rest.
Client components
Echo components run in the browser. In the Next.js App Router, pages are server components, and a
server component cannot pass a function to a client one — so a page that hands a component its
onSave, onSubmit or onClick has to be a client component too:
// app/page.tsx
"use client";
import { HardwareButton } from "@/components/echo/hardware-button";
export default function Page() {
return <HardwareButton onClick={() => save()}>Save</HardwareButton>;
}Without the directive the build stops with "Event handlers cannot be passed to Client Component props".
Watch the running page
check reads your source. What only exists once the page runs, it can't see: a colour as the
browser computes it, a name a control gets at runtime, text against whatever ends up behind it, a
screen put together from several files. EchoCheck looks at the page itself, in development. Put
it once in the root layout:
import { SoundProvider } from "echo-ui-core/sound";
import { EchoCheck } from "echo-ui-core/check";
<body>
<SoundProvider>{children}</SoundProvider>
<EchoCheck />
</body>;It looks when the page settles, and again whenever it changes, and says what it finds in the browser's console, one line each. The Next.js 16 dev server prints the same lines in its terminal, so an agent that started it reads them there.
Echo check · contrast: p.hint "Saved a minute ago": 3.2:1 (#6f6a7d on #16131e). Text this size needs 4.5:1. …
| Rule | What it finds |
|---|---|
setup | The theme, the page's ground or SoundProvider missing from the page. |
colour | A colour written by hand, or painted on the page, that the seed doesn't make. The theme's colours at any opacity, and check.colours, are allowed. |
name | A control with no accessible name, which a screen reader can only call "button" or "textbox". |
contrast | Text under WCAG AA against what's actually behind it, gradients included; small text that passes AA but reads under APCA Lc 45. |
alignment | Text cut off with "…", cards side by side whose actions sit at different heights, a lone action off its column's edge, a grid that leaves columns empty. |
depth | A control that glows at rest, or a raised surface on a raised one with no well between. |
corners | Parts of one kind with different corners, or a corner inside a corner that doesn't follow it. |
icons | One group's icons at different sizes or stroke weights, or one filled among outlined ones. |
screen | Echo's screen rules: another library's parts on an Echo screen, two primary caps in one decision, something destructive on one press, a confirmation that says "OK"; on the page, "you are here" lit twice. |
overlay adds a small panel in the corner that lists them and points at each one.
ignore={["colour"]} turns a rule off, and colours takes the brand colours the seed doesn't make
that you use on purpose, as hex values: the same list as check.colours in echo.json.
A production build gets a version that renders nothing. echo-ui-core/check points the
development condition bundlers set in development at the page check, and every other build at an
empty component, so none of its code ships and it can stay in the layout.
It doesn't measure text over an image, a video or blurred glass, since styles can't say what's
behind it, and it doesn't run axe. test does: every page, headless, with
everything here and axe's WCAG A and AA rules, and in Claude Code it runs when the agent stops.
Right-to-left
Echo mirrors for right-to-left languages. It needs two things from your app: dir="rtl" on an
ancestor, which flips the CSS, and Base UI's DirectionProvider, which makes arrow keys and slider
drags follow the reading direction.
import { DirectionProvider } from "@base-ui/react/direction-provider";
<html lang="ar" dir="rtl">
<body>
<DirectionProvider direction="rtl">{children}</DirectionProvider>
</body>
</html>;The light doesn't mirror: it comes from the top left in every language, because it's part of the device. See every component both ways in the right-to-left gallery.
Under a Content Security Policy
Echo adds no scripts of its own, evaluates no strings, starts no workers and loads nothing from another origin, and its CSS is files your bundler serves. So a strict policy works, with one allowance for everything and two more for one component each:
| Allow | For | Why |
|---|---|---|
style-src-attr 'unsafe-inline' | Every component | A part that moves or fills carries its value in a style attribute: a slider's position, a meter's level, a chart's bars. Base UI, Echo's behaviour layer, places its parts the same way, and React writes style props as attributes when it renders on the server. Stylesheets stay strict; only attributes are allowed. |
img-src data: | Textures | Textures draws its tiles as small SVGs in data: URLs. |
media-src blob: | VoiceNote | VoiceNote plays back what it just recorded from a blob: URL. |
The theme needs nothing when it's the echo-theme.css file init wrote. If you put it in a
<style> at runtime instead, give that element your nonce. Next.js puts the nonce on its own
scripts when your middleware sets the header; its
Content Security Policy guide shows
how. A policy for an app that uses Echo, with the two component lines in:
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-{nonce}' 'strict-dynamic';
style-src 'self' 'nonce-{nonce}';
style-src-attr 'unsafe-inline';
img-src 'self' data:;
media-src 'self' blob:;
object-src 'none';
base-uri 'self';Leave out data: if you don't use Textures, and the media-src line if you don't use VoiceNote.
Vite
The same commands. init detects Vite on its own; the theme is imported from your entry file
instead of a layout:
// src/main.tsx
import { createRoot } from "react-dom/client";
import { SoundProvider } from "echo-ui-core/sound";
import "./components/echo/echo-theme.css";
import App from "./App";
createRoot(document.getElementById("root")!).render(
<SoundProvider>
<App />
</SoundProvider>,
);Troubleshooting
| Symptom | Cause |
|---|---|
No echo.json here | add before init (set the project up). |
echo.json isn't valid JSON | A hand edit left a trailing comma or a comment. Fix the place the message names; nothing runs, and nothing is written over the file, until it reads. |
An import from @base-ui-components/react fails | That is Base UI's old package name; the current one is @base-ui/react. AI assistants trained on older code often suggest it. |
| Components render with no colour | echo-theme.css is not imported (import the theme). |
add stops naming a file you changed | That file differs from what was installed. Keep your version, or pass --overwrite to take the registry's. |
needs echo-ui-core >=… | The item is newer than the core you have. Update echo-ui-core. |
| "Event handlers cannot be passed to Client Component props" | The page passing a callback needs "use client" (client components). |
| No sound | No SoundProvider; or nobody has turned sound on yet (it starts muted; defaultMuted={false} starts with it); or muted (the choice is saved in localStorage as echo-sound-muted); or the OS asks for reduced motion, which mutes by default; or the tab is not focused. |
| Everything looks washed out on a white page | The page isn't on the device's ground. Use var(--echo-well) or var(--echo-surface) as the page ground; for a light device, give the seed a pale chassis rather than a white page under a dark one. |