v0.1Open source · MIT

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.

Variant
Thinking
Sparkle
Morph
Control
Auto-switch
Return to AI after input loses focus (auto-activated path only).
Toggle
Swap the top-right button via renderToggle.
Aurora
Tweak the animated gradient via --aurora-ai-bg / --aurora-ai-blob-*.
Accent
#111111
AI is replying
Live agent online
Type here
tsx
<AiChatInput
variant="aurora"
defaultMode="ai"
onSend={(text) => console.log(text)}
/>

Install

Requires React 19. gsap and @gsap/react are peer deps so you control the version.

bash
pnpm add ai-chat-input gsap @gsap/react

Quick start

tsx
import { AiChatInput } from 'ai-chat-input';
import 'ai-chat-input/styles.css';
export default function Page() {
return (
<AiChatInput
variant="aurora"
thinkingPreset="words"
sparklePreset="matrix"
onSend={(text) => console.log(text)}
/>
);
}

<AiChatInput> props

PropTypeDefaultDescription
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.
thinkingWordsreadonly string[]built-in 56-word listWords 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) => voidFires on every toggle (controlled or uncontrolled).
onSend(text: string) => voidFires when user hits Send in human mode.
autoSwitchOnBlurbooleantrueWhen 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) => ReactNodeFully 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.
labelsPartial<AciLabels>English defaultsOverride any user-facing string (i18n).
inputValuestringControlled input value. Pair with onInputValueChange. When omitted, internal state is used (see defaultInputValue).
defaultInputValuestring''Uncontrolled initial value. Ignored when inputValue is set.
onInputValueChange(next: string) => voidFires on every keystroke (controlled or uncontrolled).
maxLengthnumberHard character limit passed through to the textarea.
renderToolbar(ctx: ToolbarRenderContext) => ReactNodeReplace the default 8-icon toolbar. Context gives you labels, value, setValue, focusInput — useful for slash commands, attach buttons, @mentions.
renderSendButton(ctx: SendButtonRenderContext) => ReactNodeReplace the default Send button. Wire ctx.onClick to submit; ctx.disabled is true when the trimmed value is empty.
hintReactNode | nulllabels.hintCustom hint content in the human footer. Pass null to hide entirely.
textareaPropsTextAreaSlotPropsProps spread onto the underlying <textarea>. The component owns value/onChange/onKeyDown/onBlur/ref — everything else (rows, autoFocus, aria-*, etc.) is yours.
widthnumber | string100%Outer width. number becomes px. Caps at variant's natural max unless you set max-width via style.
heightnumber | stringvariant defaultOuter height. Single height shared by both AI and human states.
classNamestringAppended to the root element.
styleCSSPropertiesInline style on root — good place to override CSS variables.

Variants

Top-level visual preset.

VariantFeelDefault max-widthDefault height
auroraGlass card with blue gradient wash, white particles, frosted pill.357px240px
haloApple Intelligence–style rotating colored rim + morphing gradient orb.357px240px

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.

PresetDescription
wordsTwo overlapping text slots crossfade + slide + blur through your word list.
dotsThree pulsing dots with staggered opacity + scale.
typewriterSingle 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.

PresetDescription
matrix16 hand-authored frames forming 4 directional sub-animations (aurora).
pulseRadial breathing — dots stagger from center outward, then fade (aurora).
waveConcentric rings expand outward — sonar / water-drop effect (aurora).

Morph presets

How the component transitions between AI and human states.

PresetDescription
fadeSoft crossfade + slight vertical drift (default).
scaleOutgoing scales down (0.92), incoming scales in from 1.08 — a push-pull zoom.
blurHeavy 12px blur during transition — out-of-focus / refocus feel.
slideHorizontal slide — outgoing drifts left, incoming enters from right.

Labels

All user-facing strings. Pass overrides via the labels prop for i18n.

KeyDefault
aiReceivingAI is replying...
aiPlaceholderSpark an idea...
humanHeaderLive agent online
humanHeaderAuto— (falls back to humanHeader)
inputPlaceholderType here
hintType / + any field to search quick replies
sendLabelSend
aiOnTurn AI off
aiOffTurn AI on
hoverTipFocus to type — AI pauses automatically
tsx
<AiChatInput labels={{ sendLabel: 'Reply', aiOn: 'Pause AI', aiOff: 'Resume AI' }} />

CSS variables

Override via inline style or a wrapping class.

VariableDefaultPurpose
--aci-accent#111111Send button background.
--aci-radius-ai20pxOuter border radius in AI mode.
--aci-radius-human14pxOuter border radius in human mode.
--aci-fg#1b1b1bPrimary text color.
--aci-fg-muted#9b978cMuted/secondary text.
--aci-fontinheritFont stack override.
--aci-duration0.55sBase morph duration.
tsx
<AiChatInput style={{ ['--aci-accent' as string]: '#FF5722' }} />

Halo-specific tokens

Only meaningful when variant="halo".

VariableDefaultPurpose
--halo-color#ff3b9ePrimary hue — orb base + first stop of the border gradient.
--halo-hot#ff7a3dWarm accent — orb highlight + second stop of the border gradient.
--halo-c3#a855f7Third hue for the border rotation sweep.
--halo-c4#60a5faFourth hue for the border rotation sweep.
--halo-spin-duration5sDrives 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-width3pxWidth of the colored rim that bleeds past the frosted inset.
--halo-border-blur7pxSoftness of the bloom between frost and rim.
--halo-frostrgba(250,250,252,0.78)Center frosted fill. Lower alpha lets more gradient bleed through.
tsx
<AiChatInput
variant="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).

tsx
const [mode, setMode] = useState<Mode>('ai');
<AiChatInput
mode={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.

tsx
const [draft, setDraft] = useState('');
<AiChatInput
inputValue={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.

tsx
const [sending, setSending] = useState(false);
<AiChatInput
onSend={async (text) => {
setSending(true);
try { await post(text); } finally { setSending(false); }
}}
renderSendButton={({ onClick, disabled, label }) => (
<button
type="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.

tsx
// Insert a slash command and re-focus the textarea.
<AiChatInput
renderToolbar={({ value, setValue, focusInput }) => (
<div className="flex gap-1.5">
{['/summarize', '/translate', '/fix-grammar'].map((cmd) => (
<button
key={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.

tsx
// Hide the hint entirely
<AiChatInput hint={null} />
// Or replace it with a custom node (character counter)
<AiChatInput
inputValue={draft}
onInputValueChange={setDraft}
maxLength={500}
hint={<span>{draft.length} / 500</span>}
/>
tsx
<AiChatInput
textareaProps={{
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".

tsx
// 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).

tsx
// Swap the top-right toggle for an unrelated CTA (e.g. an Upgrade button
// when the user has run out of free AI quota).
<AiChatInput
labels={{ humanHeader: 'AI quota reached — upgrade to continue' }}
defaultMode="human"
renderToggle={() => (
<button
type="button"
onClick={() => router.push('/billing')}
className="aci-toggle aci-toggle-shared"
>
Upgrade
</button>
)}
/>

Accessibility

  • Shared toggle button has aria-pressed and 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's aria-label.
  • When prefers-reduced-motion: reduce, idle loops and morph collapse to instant state changes.
MIT © leefanv · Built with GSAP 3.13+GitHub