Headless React components with the AI wiring already done. You bring an LLM client and your own styles; the library handles the fiddly parts โ debouncing, aborting stale requests, ghost-text positioning, keyboard handling.
pnpm add @extedcoud/smart-components react react-domNo runtime dependencies beyond React. Every adapter is built on plain fetch.
import { SmartProvider, SmartTextbox, SmartSuggestion } from '@extedcoud/smart-components';
import { createProxyClient } from '@extedcoud/smart-components/adapters/proxy';
import '@extedcoud/smart-components/style.css';
// In production, point this at your own backend so the API key stays server-side.
const client = createProxyClient({ url: '/api/smart' });
export function App() {
const [v, setV] = useState('');
return (
<SmartProvider client={client} model="gpt-4o-mini">
<SmartTextbox value={v} onChange={setV} context="user is writing a support reply" />
<SmartSuggestion
value={v}
onChange={setV}
onSelect={(s) => console.log('picked', s)}
context="naming a new project"
/>
</SmartProvider>
);
}| Component | What it does |
|---|---|
<SmartTextbox> |
Single-line input with Copilot-style ghost completion. ArrowRight accepts (configurable), Esc dismisses. |
<SmartTextarea> |
Multiline ghost completion via a mirror div, with multiline stops and optional auto-resize. |
<SmartSuggestion> |
Combobox with an AI-generated dropdown. Arrow keys to move, Enter to pick. |
<SmartRewrite> |
Render-prop rewrite primitive. Ships with Shorter / Formal / Casual / Fix grammar presets. |
Everything is headless: minimal default DOM, render slots (renderItem, renderGhost, render-props), and native input attributes pass straight through.
The ghost defaults to opacity: 0.4 and inherits the input's color. To recolor it, pass ghostClassName or ghostStyle:
<SmartTextbox value={v} onChange={setV} ghostStyle={{ color: '#0066cc', fontStyle: 'italic' }} />For richer markup (icons, badges) use the renderGhost render-prop. To style globally, target [data-testid="smart-textbox-ghost"] / [data-testid="smart-textarea-ghost"].
Touch is a first-class target, not an afterthought. Things to know:
- The accept key is configurable. It defaults to
ArrowRight, which most soft keyboards lack โ on mobile, remapacceptKeyor trigger accept imperatively via the field ref. - Wrappers use
touch-action: manipulationto drop the 300ms tap delay. SmartSuggestionselection runs on pointer events (mouse, touch, pen). Give itemsmin-height: 44pxto hit the WCAG touch target.- Inputs below
16pxfont size make iOS Safari zoom on focus โ keepSmartTextbox/SmartTextareaat 16px or larger. - Known limit: the
SmartSuggestiondropdown can sit under the soft keyboard on short viewports. Portal the list or use a fullscreen picker there.
SmartClient is a capability-based interface. Use an adapter or implement your own:
| Adapter | Import | When |
|---|---|---|
createProxyClient |
/adapters/proxy |
Production. POSTs to your backend; the key stays on the server. |
createOpenAIClient |
/adapters/openai |
Dev and demos only โ never ship a key to the browser. |
createAnthropicClient |
/adapters/anthropic |
Anthropic Messages API (complete + stream). |
createMockClient |
/adapters/mock |
Tests and Storybook. |
Rolling your own is a few lines:
import { SMART_CLIENT_PROTOCOL_VERSION, type SmartClient } from '@extedcoud/smart-components';
const myClient: SmartClient = {
protocolVersion: SMART_CLIENT_PROTOCOL_VERSION,
id: 'my-backend',
capabilities: new Set(['complete', 'stream']),
async complete(req) { /* ... */ return text; },
async *stream(req) { /* yield chunks */ },
};The components are thin wrappers over hooks. Reach for these to build your own on the same plumbing:
import {
useGhostCompletion,
useSuggestionList,
useRewrite,
useSmartState,
} from '@extedcoud/smart-components';A drop-in useState with one extra: ai.generate(context?). The shape is read from your initial value, so the model is constrained to matching JSON โ no schema, no parsing.
const [user, setUser, ai] = useSmartState(
{ name: '', age: 0, bio: '' },
'a fictitious cyberpunk character',
);
ai.generate(); // โ { name: 'Kael-9', age: 28, bio: 'โฆ' }
setUser({ ... }); // still plain useStateWhen the seed is empty (null, []), pass options.shape so it knows what to ask for:
const [tags, , ai] = useSmartState<string[]>([], 'tags for a sci-fi blog post', {
shape: { type: 'array', item: 'string' },
});A few things worth knowing:
- Calling
setValuemid-generate cancels the in-flight request โ the user's edit wins. - An empty inner array (
{ tags: [] }) throws at mount; seed it or passshape. Same forDate,Map,Set, functions โ JSON-serializable values only. - Results are cached LRU(16) by
(shape, context). Opt out with{ cache: false }. - Generate is atomic (no streaming) and needs a client with the
completecapability.
Full walkthrough โ form autofill, text extraction, palettes โ is in the docs.
A pnpm workspace: the root is the library, docs/ is the public site (Next.js + Fumadocs on GitHub Pages).
pnpm install
pnpm storybook # :6006 โ internal dev surface
pnpm test # vitest watch
pnpm lint
pnpm typecheck
pnpm build # lib โ dist/
# Docs site (build the lib first โ it consumes dist/)
pnpm --filter @extedcoud/smart-components build
pnpm --filter docs dev # :3000Docs deploy to GitHub Pages via .github/workflows/deploy-docs.yml on push to main.
Library: Vite (lib mode), TypeScript, CSS Modules, ESLint 9, Prettier, Vitest + RTL, Storybook. Docs: Next.js 15 (static export), Fumadocs UI, Tailwind v4, MDX.