AI Chat Input
A React chat input that morphs between two states — one for the AI's response, one for your reply — in a single, continuous GSAP-driven motion. Two visual variants, configurable thinking, sparkle, and morph presets, theme it with a few CSS variables. Open source, MIT.
Live preview
Pick a preset. The code block below updates live — copy it into your project.
renderToggle.--aurora-ai-bg / --aurora-ai-blob-*.#111111<AiChatInputvariant="aurora"defaultMode="ai"onSend={(text) => console.log(text)}/>
Install
Requires React 19. gsap and @gsap/react are peer deps so you control the version.
pnpm add ai-chat-input gsap @gsap/react
Quick start
import { AiChatInput } from 'ai-chat-input';import 'ai-chat-input/styles.css';export default function Page() {return (<AiChatInputvariant="aurora"thinkingPreset="words"sparklePreset="matrix"onSend={(text) => console.log(text)}/>);}
<AiChatInput> props
| Prop | Type | Default | Description |
|---|---|---|---|
variant | 'aurora' | 'halo' | 'aurora' | Top-level visual preset. |
thinkingPreset | 'words' | 'dots' | 'typewriter' | 'words' | Middle thinking indicator animation. |
sparklePreset | 'matrix' | 'pulse' | 'wave' | 'matrix' | Top-left icon animation. Aurora only — halo's orb is CSS-driven and ignores this. |
morphPreset | 'fade' | 'scale' | 'blur' | 'slide' | 'blur' | Transition style between AI and human states. |
thinkingWords | readonly string[] | built-in 56-word list | Words to cycle through for words / typewriter. |
mode | 'ai' | 'human' | — | Controlled mode. Pair with onModeChange. |
defaultMode | 'ai' | 'human' | 'ai' | Initial mode for uncontrolled usage. |
onModeChange | (next: Mode) => void | — | Fires on every toggle (controlled or uncontrolled). |
onSend | (text: string) => void | — | Fires when user hits Send in human mode. |
autoSwitchOnBlur | boolean | true | When the user enters human mode by activating the input and later moves focus outside the component, return to AI automatically. Manual top-right toggles are never affected. |
renderToggle | (ctx: ToggleRenderContext) => ReactNode | — | Fully replace the top-right button. Receives mode, toggle, label, and isAutoEntered. Useful for swapping the button with an unrelated CTA (e.g. "Upgrade plan"). Flip position animation no longer applies. |
labels | Partial<AciLabels> | English defaults | Override any user-facing string (i18n). |
inputValue | string | — | Controlled input value. Pair with onInputValueChange. When omitted, internal state is used (see defaultInputValue). |
defaultInputValue | string | '' | Uncontrolled initial value. Ignored when inputValue is set. |
onInputValueChange | (next: string) => void | — | Fires on every keystroke (controlled or uncontrolled). |
maxLength | number | — | Hard character limit passed through to the textarea. |
renderToolbar | (ctx: ToolbarRenderContext) => ReactNode | — | Replace the default 8-icon toolbar. Context gives you labels, value, setValue, focusInput — useful for slash commands, attach buttons, @mentions. |
renderSendButton | (ctx: SendButtonRenderContext) => ReactNode | — | Replace the default Send button. Wire ctx.onClick to submit; ctx.disabled is true when the trimmed value is empty. |
hint | ReactNode | null | labels.hint | Custom hint content in the human footer. Pass null to hide entirely. |
textareaProps | TextAreaSlotProps | — | Props spread onto the underlying <textarea>. The component owns value/onChange/onKeyDown/onBlur/ref — everything else (rows, autoFocus, aria-*, etc.) is yours. |
width | number | string | 100% | Outer width. number becomes px. Caps at variant's natural max unless you set max-width via style. |
height | number | string | variant default | Outer height. Single height shared by both AI and human states. |
className | string | — | Appended to the root element. |
style | CSSProperties | — | Inline style on root — good place to override CSS variables. |
Variants
Top-level visual preset.
| Variant | Feel | Default max-width | Default height |
|---|---|---|---|
aurora | Glass card with blue gradient wash, white particles, frosted pill. | 357px | 240px |
halo | Apple Intelligence–style rotating colored rim + morphing gradient orb. | 357px | 240px |
The card is fluid by default — width: 100% up to the variant's natural max. Override with the width / height props or your own CSS.
Thinking presets
Middle content of the AI state.
| Preset | Description |
|---|---|
words | Two overlapping text slots crossfade + slide + blur through your word list. |
dots | Three pulsing dots with staggered opacity + scale. |
typewriter | Single word typed char-by-char, held, erased, next. |
Sparkle presets
Top-left icon animation. Aurora-only — halo's orb is purely CSS-driven and ignores this prop.
| Preset | Description |
|---|---|
matrix | 16 hand-authored frames forming 4 directional sub-animations (aurora). |
pulse | Radial breathing — dots stagger from center outward, then fade (aurora). |
wave | Concentric rings expand outward — sonar / water-drop effect (aurora). |
Morph presets
How the component transitions between AI and human states.
| Preset | Description |
|---|---|
fade | Soft crossfade + slight vertical drift (default). |
scale | Outgoing scales down (0.92), incoming scales in from 1.08 — a push-pull zoom. |
blur | Heavy 12px blur during transition — out-of-focus / refocus feel. |
slide | Horizontal slide — outgoing drifts left, incoming enters from right. |
Labels
All user-facing strings. Pass overrides via the labels prop for i18n.
| Key | Default |
|---|---|
aiReceiving | AI is replying... |
aiPlaceholder | Spark an idea... |
humanHeader | Live agent online |
humanHeaderAuto | — (falls back to humanHeader) |
inputPlaceholder | Type here |
hint | Type / + any field to search quick replies |
sendLabel | Send |
aiOn | Turn AI off |
aiOff | Turn AI on |
hoverTip | Focus to type — AI pauses automatically |
<AiChatInput labels={{ sendLabel: 'Reply', aiOn: 'Pause AI', aiOff: 'Resume AI' }} />
CSS variables
Override via inline style or a wrapping class.
| Variable | Default | Purpose |
|---|---|---|
--aci-accent | #111111 | Send button background. |
--aci-radius-ai | 20px | Outer border radius in AI mode. |
--aci-radius-human | 14px | Outer border radius in human mode. |
--aci-fg | #1b1b1b | Primary text color. |
--aci-fg-muted | #9b978c | Muted/secondary text. |
--aci-font | inherit | Font stack override. |
--aci-duration | 0.55s | Base morph duration. |
<AiChatInput style={{ ['--aci-accent' as string]: '#FF5722' }} />
Halo-specific tokens
Only meaningful when variant="halo".
| Variable | Default | Purpose |
|---|---|---|
--halo-color | #ff3b9e | Primary hue — orb base + first stop of the border gradient. |
--halo-hot | #ff7a3d | Warm accent — orb highlight + second stop of the border gradient. |
--halo-c3 | #a855f7 | Third hue for the border rotation sweep. |
--halo-c4 | #60a5fa | Fourth hue for the border rotation sweep. |
--halo-spin-duration | 5s | Drives both the border rotation and the orb drift. Orb's shape-morph derives automatically as calc(value × 0.74) to keep the two rhythms desynced. |
--halo-border-width | 3px | Width of the colored rim that bleeds past the frosted inset. |
--halo-border-blur | 7px | Softness of the bloom between frost and rim. |
--halo-frost | rgba(250,250,252,0.78) | Center frosted fill. Lower alpha lets more gradient bleed through. |
<AiChatInputvariant="halo"style={{['--halo-color' as string]: '#06b6d4',['--halo-hot' as string]: '#f472b6',['--halo-spin-duration' as string]: '8s',}}/>
Controlled mode
Pass mode and onModeChange to drive the component externally (e.g., to sync across multiple instances).
const [mode, setMode] = useState<Mode>('ai');<AiChatInputmode={mode}onModeChange={setMode}/>
Customizing the input
The human side (toolbar + textarea + Send + hint) exposes a layered customization surface. Reach for the cheapest one that solves your problem — every prop below is independent and optional.
Layer 1 — Controlled input value
Standard React controlled pattern. Useful for draft recovery, external clears, multi-step flows, character counts. Pair inputValue with onInputValueChange, or just pass defaultInputValue for an uncontrolled initial state.
const [draft, setDraft] = useState('');<AiChatInputinputValue={draft}onInputValueChange={setDraft}maxLength={500}onSend={(text) => {post(text);setDraft('');}}/>
Layer 2 — Replace the Send button
Use renderSendButton when you need a loading state, a different icon, or custom styling. Wire ctx.onClick to keep the submit pipeline intact; ctx.disabled is true when the trimmed value is empty.
const [sending, setSending] = useState(false);<AiChatInputonSend={async (text) => {setSending(true);try { await post(text); } finally { setSending(false); }}}renderSendButton={({ onClick, disabled, label }) => (<buttontype="button"onClick={onClick}disabled={disabled || sending}className="rounded-md bg-black px-3 py-1.5 text-sm text-white disabled:opacity-50">{sending ? <Spinner /> : label}</button>)}/>
Layer 2 — Replace the toolbar
renderToolbar receives live input context — value, setValue, focusInput — so you can build slash commands, attach buttons, @-mentions, emoji pickers, etc. without leaving the layout.
// Insert a slash command and re-focus the textarea.<AiChatInputrenderToolbar={({ value, setValue, focusInput }) => (<div className="flex gap-1.5">{['/summarize', '/translate', '/fix-grammar'].map((cmd) => (<buttonkey={cmd}type="button"onClick={() => {setValue(value + cmd + ' ');focusInput();}}className="rounded-full border px-2 py-0.5 text-xs">{cmd}</button>))}</div>)}/>
Layer 2 — Hint and textarea passthrough
Pass hint={null} to hide the footer hint, or any ReactNode to replace it. Use textareaProps for anything that lives on the native <textarea>: rows, autoFocus, spellCheck, aria-*, etc.
// Hide the hint entirely<AiChatInput hint={null} />// Or replace it with a custom node (character counter)<AiChatInputinputValue={draft}onInputValueChange={setDraft}maxLength={500}hint={<span>{draft.length} / 500</span>}/>
<AiChatInputtextareaProps={{autoFocus: true,rows: 5,spellCheck: false,'aria-label': 'Compose your message',}}/>// value / onChange / onKeyDown / onBlur / ref are reserved by the component// — TypeScript will reject them here.
Auto-switch & toggle
Two orthogonal knobs for how the component flips between AI and human.
autoSwitchOnBlur
When the user activates the textarea, the component auto-switches into human mode and marks the session as "auto-entered". OnautoSwitchOnBlur=true(default), moving focus outside the component reverts to AI. Manual clicks on the top-right toggle never auto-revert — that path is always "user intent".
// Default: focusing the textarea auto-switches into human mode; focus// leaving the component auto-reverts to AI. Manual clicks on the toggle// are never auto-reverted.<AiChatInput autoSwitchOnBlur />// Disable if you want human mode to persist until the user manually toggles.<AiChatInput autoSwitchOnBlur={false} />
renderToggle
Fully replace the top-right button. The returned node is rendered in place of the default <button> — the Flip position animation no longer applies, so use this for swapping to a CTA that lives outside the AI/human axis (e.g. an Upgrade button when the user is out of free quota).
// Swap the top-right toggle for an unrelated CTA (e.g. an Upgrade button// when the user has run out of free AI quota).<AiChatInputlabels={{ humanHeader: 'AI quota reached — upgrade to continue' }}defaultMode="human"renderToggle={() => (<buttontype="button"onClick={() => router.push('/billing')}className="aci-toggle aci-toggle-shared">Upgrade</button>)}/>
Accessibility
- Shared toggle button has
aria-pressedand a descriptivearia-label. - AI glass card is
role="button", activatable via Enter / Space. - The hover tip is
aria-hidden; screen readers receive the same text via the card'saria-label. - When
prefers-reduced-motion: reduce, idle loops and morph collapse to instant state changes.