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.constclient=createProxyClient({url: '/api/smart'});exportfunctionApp(){const[v,setV]=useState('');return(<SmartProviderclient={client}model="gpt-4o-mini"><SmartTextboxvalue={v}onChange={setV}context="user is writing a support reply"/><SmartSuggestionvalue={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:
<SmartTextboxvalue={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,typeSmartClient}from'@extedcoud/smart-components';constmyClient: SmartClient={protocolVersion: SMART_CLIENT_PROTOCOL_VERSION,id: 'my-backend',capabilities: newSet(['complete','stream']),asynccomplete(req){/* ... */returntext;},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.