Skip to main content

Client/server boundaries

Directive preservation

Each client entry ships with 'use client' at the top of its bundle: So cn(), If, Spinner, and the other directive-free entries can be used from Server Components:
Because client entries carry the directive themselves, a Server Component can render them directly. Your own code needs its own 'use client' only when it uses hooks or event handlers — for example, a form built with useForm():

Trans (i18n with next-intl)

Trans is a client component that reads next-intl’s context, so it must render under a NextIntlClientProvider. The Ekanos host mounts one at its root, and @ekanos/harness’s RootLayout mounts one for you:

Fallback chain

A key (ns:key shape) resolves to the host’s message when it exists. Otherwise Trans falls back in this order:
  1. defaults (interpolated with values, with components)
  2. children
  3. The key itself
With no provider at all, Trans throws. Anywhere else (a Storybook, a bare unit test), wrap the tree in a NextIntlClientProvider; empty messages are enough, because a missing key renders defaults:

Interpolation

defaults strings use {{variable}} interpolation:

Components

A components map renders tags in the string as elements:

Spinner

Spinner draws an inline SVG (a faint full ring under a quarter-turn arc), so unlike Icon it needs no icon font — only the Tailwind utilities it emits (animate-spin, size-*, text-*):
It renders as <span role="status" aria-label="Loading">; other props land on that span. It honours prefers-reduced-motion via motion-reduce:animate-none, freezing the arc into a static busy glyph. It is hook-free, so it renders from a Server Component or a Client Component.

Base UI (not Radix)

Popup-shaped components (Select, DropdownMenu, Dialog, Tooltip) are built on @base-ui/react, not Radix. The main difference you’ll notice:

No asChild prop

Instead, pass a render prop to the trigger:
If you don’t provide render, SelectTrigger renders a <button> by default.

Polymorphic components

Some components (triggers, items) accept a render prop to change the element:

Form with react-hook-form

Forms use react-hook-form and @hookform/resolvers for validation:
FormMessage displays the field’s validation error. zod here is your own dependency; @ekanos/ui does not ship it.

Type imports

Some entries export named prop types (ButtonProps from button, BadgeProps from badge, the PromptInput*Props types from ai-prompt-input). For the rest, use React.ComponentProps<typeof Component>:
Props extend the underlying element’s props, so standard attributes work:

Peer dependencies

You provide these (react-hook-form and @hookform/resolvers for form usage, next-intl for Trans):
The package declares:
  • @base-ui/react — the primitives (Base UI, not Radix)
  • ai — used for types only (ChatStatus, FileUIPart in PromptInput’s props); no ai code is imported at runtime
  • class-variance-authority — variant management
  • clsx + tailwind-merge — class merging
  • cmdk — command palette/combobox
  • nanoid — unique IDs
Everything else the components need at runtime is inlined into the package’s dist/.

'use client' in Next.js App Router

Client entries keep their directive in the published bundle (the build re-applies it per entry), so Next.js treats them as client components:

Per-component entries

Each component is its own subpath with its own bundle, and the package declares "sideEffects": false, so importing @ekanos/ui/button does not pull in @ekanos/ui/select. There is no root barrel export. Entries that render icons (for example select and dropdown-menu) share the one icon bundle, so a single IconStyleOverrideProvider reaches every icon.

Base UI patch divergence (minor)

The Ekanos monorepo patches @base-ui/react to widen a trigger drag-boundary offset in menu, select, and combobox triggers. The published package resolves the unpatched package from npm, so partner-side menus keep upstream’s slightly tighter press-and-drag selection boundary. Cosmetic only.

CSS-in-JS vs. Tailwind

All components emit static Tailwind class strings, not CSS-in-JS: no styles are generated or injected at render time. The tradeoff is that you must load either styles.css (pre-compiled) or theme.css in your Tailwind build so the utilities exist. See Styling.