Forms are the gatekeepers of web applications. Whether users are signing up, checking out, or submitting support requests, forms stand between intent and action. Yet according to Baymard Institute research, the average cart abandonment rate hovers around 70%, with complex forms being a primary culprit. AI-powered smart forms can improve completion rates by 30% or more through intelligent auto-completion, predictive validation, and conversational interfaces.
In this comprehensive guide, we will explore how to build intelligent form experiences that anticipate user needs, reduce friction, and maintain accessibility. From predictive text input and address auto-completion with the Google Places API to conversational forms and dynamic field generation, you will learn practical techniques that transform tedious data entry into seamless interactions.
Predictive Text Input with AI
Predictive text reduces cognitive load by suggesting completions as users type. While browser autocomplete handles basic scenarios, AI-powered prediction can understand context, learn from user behavior, and suggest semantically relevant options. According to UX research, effective autocomplete can reduce input time by up to 50%.
Building a Smart Autocomplete Component
Here is a complete React implementation of an AI-powered autocomplete component with debouncing, keyboard navigation, and accessibility support:
// smart-autocomplete.tsx
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { useQuery } from '@tanstack/react-query';
interface AutocompleteProps {
id: string;
label: string;
placeholder?: string;
fetchSuggestions: (query: string) => Promise<Suggestion[]>;
onSelect: (value: Suggestion) => void;
minChars?: number;
debounceMs?: number;
maxSuggestions?: number;
}
interface Suggestion {
id: string;
value: string;
label: string;
metadata?: Record<string, any>;
}
export function SmartAutocomplete({
id,
label,
placeholder,
fetchSuggestions,
onSelect,
minChars = 2,
debounceMs = 300,
maxSuggestions = 8
}: AutocompleteProps) {
const [query, setQuery] = useState('');
const [isOpen, setIsOpen] = useState(false);
const [highlightedIndex, setHighlightedIndex] = useState(-1);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLUListElement>(null);
const debounceRef = useRef<NodeJS.Timeout>();
// Debounced query for API calls
const [debouncedQuery, setDebouncedQuery] = useState('');
useEffect(() => {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
debounceRef.current = setTimeout(() => {
setDebouncedQuery(query);
}, debounceMs);
return () => {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
};
}, [query, debounceMs]);
// Fetch suggestions with React Query
const { data: suggestions = [], isLoading } = useQuery({
queryKey: ['autocomplete', debouncedQuery],
queryFn: () => fetchSuggestions(debouncedQuery),
enabled: debouncedQuery.length >= minChars,
staleTime: 60000, // Cache for 1 minute
});
const displaySuggestions = suggestions.slice(0, maxSuggestions);
// Keyboard navigation
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (!isOpen || displaySuggestions.length === 0) {
if (e.key === 'ArrowDown' && displaySuggestions.length > 0) {
setIsOpen(true);
setHighlightedIndex(0);
e.preventDefault();
}
return;
}
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setHighlightedIndex(prev =>
prev < displaySuggestions.length - 1 ? prev + 1 : 0
);
break;
case 'ArrowUp':
e.preventDefault();
setHighlightedIndex(prev =>
prev > 0 ? prev - 1 : displaySuggestions.length - 1
);
break;
case 'Enter':
e.preventDefault();
if (highlightedIndex >= 0) {
handleSelect(displaySuggestions[highlightedIndex]);
}
break;
case 'Escape':
setIsOpen(false);
setHighlightedIndex(-1);
break;
case 'Tab':
setIsOpen(false);
break;
}
}, [isOpen, displaySuggestions, highlightedIndex]);
const handleSelect = (suggestion: Suggestion) => {
setQuery(suggestion.label);
setIsOpen(false);
setHighlightedIndex(-1);
onSelect(suggestion);
inputRef.current?.blur();
};
// Scroll highlighted item into view
useEffect(() => {
if (highlightedIndex >= 0 && listRef.current) {
const highlightedElement = listRef.current.children[highlightedIndex] as HTMLElement;
highlightedElement?.scrollIntoView({ block: 'nearest' });
}
}, [highlightedIndex]);
// Close on outside click
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (!inputRef.current?.parentElement?.contains(e.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const listboxId = `${id}-listbox`;
return (
<div className="autocomplete-container" role="combobox" aria-expanded={isOpen}>
<label htmlFor={id} className="autocomplete-label">
{label}
</label>
<div className="autocomplete-input-wrapper">
<input
ref={inputRef}
id={id}
type="text"
value={query}
onChange={(e) => {
setQuery(e.target.value);
setIsOpen(true);
setHighlightedIndex(-1);
}}
onFocus={() => query.length >= minChars && setIsOpen(true)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
autoComplete="off"
aria-autocomplete="list"
aria-controls={listboxId}
aria-activedescendant={
highlightedIndex >= 0 ? `${id}-option-${highlightedIndex}` : undefined
}
className="autocomplete-input"
/>
{isLoading && (
<span className="autocomplete-spinner" aria-hidden="true">
<svg viewBox="0 0 24 24" className="spinner-icon">
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2" fill="none" />
</svg>
</span>
)}
</div>
{isOpen && displaySuggestions.length > 0 && (
<ul
ref={listRef}
id={listboxId}
role="listbox"
aria-label={`${label} suggestions`}
className="autocomplete-list"
>
{displaySuggestions.map((suggestion, index) => (
<li
key={suggestion.id}
id={`${id}-option-${index}`}
role="option"
aria-selected={highlightedIndex === index}
className={`autocomplete-option ${
highlightedIndex === index ? 'highlighted' : ''
}`}
onClick={() => handleSelect(suggestion)}
onMouseEnter={() => setHighlightedIndex(index)}
>
<HighlightedText text={suggestion.label} query={query} />
</li>
))}
</ul>
)}
{isOpen && query.length >= minChars && displaySuggestions.length === 0 && !isLoading && (
<div className="autocomplete-no-results" role="status">
No suggestions found
</div>
)}
</div>
);
}
// Highlight matching text
function HighlightedText({ text, query }: { text: string; query: string }) {
if (!query) return <>{text}</>;
const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
const parts = text.split(regex);
return (
<>
{parts.map((part, i) =>
regex.test(part) ? (
<mark key={i} className="autocomplete-highlight">{part}</mark>
) : (
<span key={i}>{part}</span>
)
)}
</>
);
}
AI-Powered Suggestion Engine
To make suggestions truly intelligent, we need a backend that combines multiple data sources and ML models:
// suggestion-engine.ts
import { OpenAI } from 'openai';
interface SuggestionContext {
fieldType: 'name' | 'email' | 'company' | 'job_title' | 'product' | 'custom';
userHistory: string[];
popularChoices: string[];
domain?: string;
}
class AISuggestionEngine {
private openai: OpenAI;
private cache: Map<string, { suggestions: string[]; timestamp: number }> = new Map();
private cacheTimeout = 300000; // 5 minutes
constructor() {
this.openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
}
async getSuggestions(
query: string,
context: SuggestionContext,
limit: number = 8
): Promise<string[]> {
const cacheKey = `${context.fieldType}:${query.toLowerCase()}`;
const cached = this.cache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < this.cacheTimeout) {
return cached.suggestions;
}
// Combine multiple suggestion sources
const [historySuggestions, popularSuggestions, aiSuggestions] = await Promise.all([
this.getHistorySuggestions(query, context.userHistory),
this.getPopularSuggestions(query, context.popularChoices),
this.getAISuggestions(query, context)
]);
// Rank and deduplicate suggestions
const ranked = this.rankSuggestions(
[...historySuggestions, ...popularSuggestions, ...aiSuggestions],
query
);
const unique = [...new Set(ranked)].slice(0, limit);
this.cache.set(cacheKey, { suggestions: unique, timestamp: Date.now() });
return unique;
}
private getHistorySuggestions(query: string, history: string[]): string[] {
const lowerQuery = query.toLowerCase();
return history
.filter(item => item.toLowerCase().includes(lowerQuery))
.sort((a, b) => {
// Prioritize prefix matches
const aStarts = a.toLowerCase().startsWith(lowerQuery);
const bStarts = b.toLowerCase().startsWith(lowerQuery);
if (aStarts && !bStarts) return -1;
if (!aStarts && bStarts) return 1;
return a.length - b.length;
});
}
private getPopularSuggestions(query: string, popular: string[]): string[] {
const lowerQuery = query.toLowerCase();
return popular.filter(item =>
item.toLowerCase().includes(lowerQuery)
);
}
private async getAISuggestions(
query: string,
context: SuggestionContext
): Promise<string[]> {
if (query.length < 2) return [];
try {
const prompt = this.buildPrompt(query, context);
const response = await this.openai.chat.completions.create({
model: 'gpt-4o-mini',
messages: [{ role: 'user', content: prompt }],
max_tokens: 200,
temperature: 0.3
});
const content = response.choices[0]?.message?.content || '';
return this.parseSuggestions(content);
} catch (error) {
console.error('AI suggestion error:', error);
return [];
}
}
private buildPrompt(query: string, context: SuggestionContext): string {
const typePrompts: Record<string, string> = {
name: `Suggest 5 common names that start with or contain "${query}"`,
company: `Suggest 5 well-known company names that start with or contain "${query}"`,
job_title: `Suggest 5 professional job titles that start with or contain "${query}"`,
product: `Suggest 5 product names that match "${query}"`,
custom: `Suggest 5 completions for the partial text "${query}"`
};
return `${typePrompts[context.fieldType] || typePrompts.custom}.
Return only the suggestions as a JSON array of strings, nothing else.
${context.domain ? `Context: ${context.domain} industry` : ''}`;
}
private parseSuggestions(content: string): string[] {
try {
const jsonMatch = content.match(/\[[\s\S]*\]/);
if (jsonMatch) {
return JSON.parse(jsonMatch[0]);
}
} catch {
// Fallback: split by newlines
return content
.split('\n')
.map(line => line.replace(/^[\d\.\-\*]\s*/, '').trim())
.filter(line => line.length > 0);
}
return [];
}
private rankSuggestions(suggestions: string[], query: string): string[] {
const lowerQuery = query.toLowerCase();
return suggestions.sort((a, b) => {
const aLower = a.toLowerCase();
const bLower = b.toLowerCase();
// Exact match first
if (aLower === lowerQuery) return -1;
if (bLower === lowerQuery) return 1;
// Prefix match second
const aStarts = aLower.startsWith(lowerQuery);
const bStarts = bLower.startsWith(lowerQuery);
if (aStarts && !bStarts) return -1;
if (!aStarts && bStarts) return 1;
// Word boundary match third
const aWordMatch = aLower.includes(` ${lowerQuery}`);
const bWordMatch = bLower.includes(` ${lowerQuery}`);
if (aWordMatch && !bWordMatch) return -1;
if (!aWordMatch && bWordMatch) return 1;
// Shorter strings preferred
return a.length - b.length;
});
}
}
Address Auto-completion with Google Places API
Address forms are notorious for abandonment. Users struggle with format requirements, make typos, and face validation errors. The Google Places Autocomplete API solves this by predicting addresses as users type and returning structured, validated address components.
Implementing Google Places Address Input
// address-autocomplete.tsx
import React, { useEffect, useRef, useState, useCallback } from 'react';
interface AddressComponents {
streetNumber: string;
route: string;
locality: string;
administrativeArea: string;
country: string;
postalCode: string;
formattedAddress: string;
latitude: number;
longitude: number;
}
interface AddressInputProps {
id: string;
label: string;
onAddressSelect: (address: AddressComponents) => void;
countries?: string[]; // Restrict to specific countries
types?: ('address' | 'establishment' | 'geocode')[];
placeholder?: string;
}
declare global {
interface Window {
google: typeof google;
initGooglePlaces: () => void;
}
}
export function AddressAutocomplete({
id,
label,
onAddressSelect,
countries = ['us', 'ca', 'gb'],
types = ['address'],
placeholder = 'Start typing your address...'
}: AddressInputProps) {
const inputRef = useRef<HTMLInputElement>(null);
const autocompleteRef = useRef<google.maps.places.Autocomplete | null>(null);
const [inputValue, setInputValue] = useState('');
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Load Google Places script
useEffect(() => {
if (window.google?.maps?.places) {
setIsLoading(false);
return;
}
const script = document.createElement('script');
script.src = `https://maps.googleapis.com/maps/api/js?key=${process.env.NEXT_PUBLIC_GOOGLE_PLACES_API_KEY}&libraries=places&callback=initGooglePlaces`;
script.async = true;
script.defer = true;
window.initGooglePlaces = () => {
setIsLoading(false);
};
script.onerror = () => {
setError('Failed to load address autocomplete');
setIsLoading(false);
};
document.head.appendChild(script);
return () => {
delete window.initGooglePlaces;
};
}, []);
// Initialize autocomplete
useEffect(() => {
if (isLoading || !inputRef.current || !window.google?.maps?.places) {
return;
}
const options: google.maps.places.AutocompleteOptions = {
types,
componentRestrictions: countries.length > 0 ? { country: countries } : undefined,
fields: [
'address_components',
'formatted_address',
'geometry',
'place_id'
]
};
autocompleteRef.current = new google.maps.places.Autocomplete(
inputRef.current,
options
);
const listener = autocompleteRef.current.addListener('place_changed', () => {
const place = autocompleteRef.current?.getPlace();
if (place?.address_components) {
const address = parseAddressComponents(place);
setInputValue(address.formattedAddress);
onAddressSelect(address);
}
});
return () => {
google.maps.event.removeListener(listener);
};
}, [isLoading, countries, types, onAddressSelect]);
const parseAddressComponents = (
place: google.maps.places.PlaceResult
): AddressComponents => {
const components = place.address_components || [];
const getComponent = (type: string): string => {
const component = components.find(c => c.types.includes(type));
return component?.long_name || '';
};
return {
streetNumber: getComponent('street_number'),
route: getComponent('route'),
locality: getComponent('locality') || getComponent('sublocality'),
administrativeArea: getComponent('administrative_area_level_1'),
country: getComponent('country'),
postalCode: getComponent('postal_code'),
formattedAddress: place.formatted_address || '',
latitude: place.geometry?.location?.lat() || 0,
longitude: place.geometry?.location?.lng() || 0
};
};
// Handle manual input for accessibility
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value);
};
// Keyboard accessibility for dropdown
const handleKeyDown = (e: React.KeyboardEvent) => {
// Google Places handles most keyboard navigation
// but we ensure Escape clears the input
if (e.key === 'Escape') {
setInputValue('');
inputRef.current?.blur();
}
};
if (error) {
return (
<div className="address-input-error">
<label htmlFor={id}>{label}</label>
<input
id={id}
type="text"
placeholder="Enter address manually"
onChange={(e) => setInputValue(e.target.value)}
className="address-input"
/>
<span className="error-text">{error}</span>
</div>
);
}
return (
<div className="address-input-container">
<label htmlFor={id} className="address-label">
{label}
</label>
<div className="address-input-wrapper">
<input
ref={inputRef}
id={id}
type="text"
value={inputValue}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
placeholder={isLoading ? 'Loading...' : placeholder}
disabled={isLoading}
autoComplete="off"
aria-describedby={`${id}-hint`}
className="address-input"
/>
{isLoading && <span className="loading-indicator" aria-hidden="true" />}
</div>
<span id={`${id}-hint`} className="address-hint">
Start typing and select from suggestions
</span>
</div>
);
}
// Hook for using address data in forms
export function useAddressForm() {
const [address, setAddress] = useState<AddressComponents | null>(null);
const handleAddressSelect = useCallback((selectedAddress: AddressComponents) => {
setAddress(selectedAddress);
}, []);
const addressFields = address ? {
street: `${address.streetNumber} ${address.route}`.trim(),
city: address.locality,
state: address.administrativeArea,
postalCode: address.postalCode,
country: address.country
} : null;
return { address, addressFields, handleAddressSelect };
}
Intelligent Field Validation
Traditional validation is reactive: it waits for users to submit and then displays errors. AI-powered validation is proactive, providing real-time feedback and suggestions before errors occur. This approach aligns with WCAG form validation guidelines while improving user experience.
// intelligent-validator.ts
import { z } from 'zod';
interface ValidationResult {
isValid: boolean;
errors: ValidationError[];
suggestions: string[];
confidence: number;
}
interface ValidationError {
field: string;
message: string;
severity: 'error' | 'warning' | 'info';
}
class IntelligentFormValidator {
private schemas: Map<string, z.ZodSchema> = new Map();
private commonTypos: Map<string, string> = new Map([
['gmial.com', 'gmail.com'],
['gmal.com', 'gmail.com'],
['yaho.com', 'yahoo.com'],
['hotmal.com', 'hotmail.com'],
['outloo.com', 'outlook.com']
]);
// Email validation with typo detection
validateEmail(email: string): ValidationResult {
const errors: ValidationError[] = [];
const suggestions: string[] = [];
// Basic format check
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
errors.push({
field: 'email',
message: 'Please enter a valid email address',
severity: 'error'
});
return { isValid: false, errors, suggestions, confidence: 0 };
}
// Domain typo detection
const [localPart, domain] = email.split('@');
const correctedDomain = this.commonTypos.get(domain.toLowerCase());
if (correctedDomain) {
suggestions.push(`Did you mean ${localPart}@${correctedDomain}?`);
errors.push({
field: 'email',
message: `"${domain}" might be a typo`,
severity: 'warning'
});
}
// Check for common patterns
if (domain.split('.').length < 2) {
errors.push({
field: 'email',
message: 'Email domain appears incomplete',
severity: 'error'
});
}
// Disposable email detection
const disposableDomains = ['tempmail.com', 'throwaway.com', '10minutemail.com'];
if (disposableDomains.includes(domain.toLowerCase())) {
errors.push({
field: 'email',
message: 'Please use a permanent email address',
severity: 'warning'
});
}
return {
isValid: errors.filter(e => e.severity === 'error').length === 0,
errors,
suggestions,
confidence: errors.length === 0 ? 1 : 0.7
};
}
// Phone number validation with formatting
validatePhone(phone: string, country: string = 'US'): ValidationResult {
const errors: ValidationError[] = [];
const suggestions: string[] = [];
// Remove non-numeric characters for analysis
const digits = phone.replace(/\D/g, '');
const formats: Record<string, { length: number; format: string }> = {
US: { length: 10, format: '(XXX) XXX-XXXX' },
UK: { length: 11, format: '+44 XXXX XXXXXX' },
IN: { length: 10, format: '+91 XXXXX XXXXX' }
};
const countryFormat = formats[country] || formats.US;
if (digits.length !== countryFormat.length) {
errors.push({
field: 'phone',
message: `Phone number should be ${countryFormat.length} digits`,
severity: 'error'
});
suggestions.push(`Expected format: ${countryFormat.format}`);
}
// Format suggestion
if (digits.length === countryFormat.length && country === 'US') {
const formatted = `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`;
if (phone !== formatted) {
suggestions.push(`Formatted: ${formatted}`);
}
}
return {
isValid: errors.filter(e => e.severity === 'error').length === 0,
errors,
suggestions,
confidence: errors.length === 0 ? 1 : 0.5
};
}
// Real-time password strength analyzer
analyzePasswordStrength(password: string): {
score: number;
feedback: string[];
suggestions: string[];
} {
let score = 0;
const feedback: string[] = [];
const suggestions: string[] = [];
// Length check
if (password.length >= 12) {
score += 25;
feedback.push('Good length');
} else if (password.length >= 8) {
score += 15;
suggestions.push('Consider using 12+ characters');
} else {
suggestions.push('Password should be at least 8 characters');
}
// Complexity checks
if (/[a-z]/.test(password) && /[A-Z]/.test(password)) {
score += 20;
feedback.push('Mix of upper and lowercase');
} else {
suggestions.push('Add both upper and lowercase letters');
}
if (/\d/.test(password)) {
score += 20;
feedback.push('Contains numbers');
} else {
suggestions.push('Add numbers');
}
if (/[!@#$%^&*(),.?":{}|<>]/.test(password)) {
score += 20;
feedback.push('Contains special characters');
} else {
suggestions.push('Add special characters (!@#$%^&*)');
}
// Common pattern detection
const commonPatterns = [
/^123/, /password/i, /qwerty/i, /^abc/i
];
const hasCommonPattern = commonPatterns.some(p => p.test(password));
if (hasCommonPattern) {
score -= 20;
suggestions.push('Avoid common patterns like "123" or "password"');
}
// Sequential characters
if (/(.)\1{2,}/.test(password)) {
score -= 10;
suggestions.push('Avoid repeating characters');
}
return {
score: Math.max(0, Math.min(100, score)),
feedback,
suggestions
};
}
}
// React hook for real-time validation
export function useFieldValidation(
value: string,
fieldType: 'email' | 'phone' | 'password',
options?: { country?: string; debounceMs?: number }
) {
const [result, setResult] = useState<ValidationResult | null>(null);
const [isValidating, setIsValidating] = useState(false);
const validator = useMemo(() => new IntelligentFormValidator(), []);
useEffect(() => {
if (!value) {
setResult(null);
return;
}
setIsValidating(true);
const timeout = setTimeout(() => {
let validationResult: ValidationResult;
switch (fieldType) {
case 'email':
validationResult = validator.validateEmail(value);
break;
case 'phone':
validationResult = validator.validatePhone(value, options?.country);
break;
case 'password':
const strength = validator.analyzePasswordStrength(value);
validationResult = {
isValid: strength.score >= 60,
errors: strength.score < 60 ? [{
field: 'password',
message: 'Password is not strong enough',
severity: 'warning'
}] : [],
suggestions: strength.suggestions,
confidence: strength.score / 100
};
break;
}
setResult(validationResult);
setIsValidating(false);
}, options?.debounceMs || 300);
return () => clearTimeout(timeout);
}, [value, fieldType, options?.country, options?.debounceMs, validator]);
return { result, isValidating };
}
Conversational Forms
Conversational forms transform data collection from a static form into an interactive dialogue. By presenting one question at a time and understanding natural language responses, they reduce cognitive load and create a more engaging experience. Tools like Typeform and Landbot have proven that conversational interfaces can achieve 40% higher completion rates than traditional forms.
// conversational-form.tsx
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
interface FormStep {
id: string;
type: 'text' | 'email' | 'phone' | 'select' | 'multiselect' | 'date' | 'number';
question: string;
placeholder?: string;
options?: { value: string; label: string }[];
validation?: (value: any) => string | null;
conditionalShow?: (answers: Record<string, any>) => boolean;
}
interface ConversationalFormProps {
steps: FormStep[];
onComplete: (answers: Record<string, any>) => void;
onStepChange?: (stepIndex: number, totalSteps: number) => void;
welcomeMessage?: string;
completionMessage?: string;
}
export function ConversationalForm({
steps,
onComplete,
onStepChange,
welcomeMessage = "Hi! Let's get started.",
completionMessage = "Thank you! We've received your information."
}: ConversationalFormProps) {
const [currentStepIndex, setCurrentStepIndex] = useState(-1); // -1 for welcome
const [answers, setAnswers] = useState<Record<string, any>>({});
const [inputValue, setInputValue] = useState('');
const [error, setError] = useState<string | null>(null);
const [isComplete, setIsComplete] = useState(false);
const [messages, setMessages] = useState<Array<{
type: 'bot' | 'user';
content: string;
}>>([]);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
// Filter visible steps based on conditions
const visibleSteps = steps.filter(step =>
!step.conditionalShow || step.conditionalShow(answers)
);
const currentStep = currentStepIndex >= 0 ? visibleSteps[currentStepIndex] : null;
// Scroll to bottom when messages change
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
// Focus input when step changes
useEffect(() => {
inputRef.current?.focus();
}, [currentStepIndex]);
// Notify parent of step changes
useEffect(() => {
if (currentStepIndex >= 0) {
onStepChange?.(currentStepIndex + 1, visibleSteps.length);
}
}, [currentStepIndex, visibleSteps.length, onStepChange]);
// Start conversation
useEffect(() => {
setMessages([{ type: 'bot', content: welcomeMessage }]);
const timer = setTimeout(() => {
setCurrentStepIndex(0);
if (visibleSteps[0]) {
setMessages(prev => [...prev, {
type: 'bot',
content: visibleSteps[0].question
}]);
}
}, 1000);
return () => clearTimeout(timer);
}, []);
const handleSubmit = useCallback((e?: React.FormEvent) => {
e?.preventDefault();
if (!currentStep || !inputValue.trim()) return;
// Validate input
if (currentStep.validation) {
const validationError = currentStep.validation(inputValue);
if (validationError) {
setError(validationError);
return;
}
}
setError(null);
// Add user message
setMessages(prev => [...prev, { type: 'user', content: inputValue }]);
// Store answer
const newAnswers = { ...answers, [currentStep.id]: inputValue };
setAnswers(newAnswers);
setInputValue('');
// Move to next step
const nextIndex = currentStepIndex + 1;
setTimeout(() => {
if (nextIndex < visibleSteps.length) {
const nextStep = visibleSteps[nextIndex];
// Check if next step should be shown based on new answers
if (!nextStep.conditionalShow || nextStep.conditionalShow(newAnswers)) {
setCurrentStepIndex(nextIndex);
setMessages(prev => [...prev, {
type: 'bot',
content: nextStep.question
}]);
} else {
// Skip to next valid step
handleNextValidStep(nextIndex + 1, newAnswers);
}
} else {
// Form complete
setIsComplete(true);
setMessages(prev => [...prev, {
type: 'bot',
content: completionMessage
}]);
onComplete(newAnswers);
}
}, 500);
}, [currentStep, inputValue, answers, currentStepIndex, visibleSteps]);
const handleNextValidStep = (fromIndex: number, currentAnswers: Record<string, any>) => {
for (let i = fromIndex; i < visibleSteps.length; i++) {
const step = visibleSteps[i];
if (!step.conditionalShow || step.conditionalShow(currentAnswers)) {
setCurrentStepIndex(i);
setMessages(prev => [...prev, {
type: 'bot',
content: step.question
}]);
return;
}
}
// No more steps
setIsComplete(true);
setMessages(prev => [...prev, {
type: 'bot',
content: completionMessage
}]);
onComplete(currentAnswers);
};
// Handle select options
const handleOptionSelect = (option: { value: string; label: string }) => {
setInputValue(option.value);
// Auto-submit for select types
setTimeout(() => {
const fakeEvent = { preventDefault: () => {} } as React.FormEvent;
handleSubmit(fakeEvent);
}, 0);
};
return (
<div className="conversational-form" role="form" aria-label="Conversational form">
<div className="messages-container" aria-live="polite">
<AnimatePresence>
{messages.map((message, index) => (
<motion.div
key={index}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3 }}
className={`message ${message.type}`}
>
{message.type === 'bot' && (
<div className="bot-avatar" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 3c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 14.2c-2.5 0-4.71-1.28-6-3.22.03-1.99 4-3.08 6-3.08 1.99 0 5.97 1.09 6 3.08-1.29 1.94-3.5 3.22-6 3.22z"/>
</svg>
</div>
)}
<div className="message-content">{message.content}</div>
</motion.div>
))}
</AnimatePresence>
<div ref={messagesEndRef} />
</div>
{!isComplete && currentStep && (
<div className="input-container">
{currentStep.type === 'select' && currentStep.options ? (
<div className="options-grid" role="listbox">
{currentStep.options.map(option => (
<button
key={option.value}
type="button"
onClick={() => handleOptionSelect(option)}
className="option-button"
role="option"
>
{option.label}
</button>
))}
</div>
) : (
<form onSubmit={handleSubmit} className="text-input-form">
<input
ref={inputRef}
type={currentStep.type === 'email' ? 'email' :
currentStep.type === 'phone' ? 'tel' :
currentStep.type === 'number' ? 'number' :
currentStep.type === 'date' ? 'date' : 'text'}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
placeholder={currentStep.placeholder || 'Type your answer...'}
aria-label={currentStep.question}
aria-invalid={!!error}
aria-describedby={error ? 'input-error' : undefined}
className="conversation-input"
/>
<button
type="submit"
disabled={!inputValue.trim()}
className="send-button"
aria-label="Send"
>
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/>
</svg>
</button>
</form>
)}
{error && (
<div id="input-error" className="error-message" role="alert">
{error}
</div>
)}
</div>
)}
{/* Progress indicator */}
{!isComplete && currentStepIndex >= 0 && (
<div className="progress-indicator" aria-hidden="true">
<div
className="progress-bar"
style={{ width: `${((currentStepIndex + 1) / visibleSteps.length) * 100}%` }}
/>
<span className="progress-text">
{currentStepIndex + 1} of {visibleSteps.length}
</span>
</div>
)}
</div>
);
}
Form Abandonment Prevention
Form abandonment is a critical metric for conversion optimization. AI can detect abandonment signals and intervene with targeted assistance. According to Formisimo research, 29% of users abandon forms due to length, while 27% leave due to security concerns.
// abandonment-prevention.ts
interface AbandonmentSignal {
type: 'mouse_leave' | 'idle' | 'rapid_deletion' | 'error_frustration' | 'field_hesitation';
timestamp: number;
metadata?: Record<string, any>;
}
interface InterventionConfig {
enabled: boolean;
threshold: number;
action: 'tooltip' | 'modal' | 'chat' | 'save_progress';
message: string;
}
class FormAbandonmentDetector {
private signals: AbandonmentSignal[] = [];
private interventionTriggered = false;
private idleTimer: NodeJS.Timeout | null = null;
private lastActivity = Date.now();
private errorCount = 0;
private deletionCount = 0;
private fieldTimes: Map<string, number> = new Map();
constructor(
private formElement: HTMLFormElement,
private onAbandonmentDetected: (signals: AbandonmentSignal[]) => void,
private config: {
idleThreshold: number;
deletionThreshold: number;
errorThreshold: number;
hesitationThreshold: number;
} = {
idleThreshold: 30000, // 30 seconds
deletionThreshold: 3,
errorThreshold: 3,
hesitationThreshold: 10000 // 10 seconds on one field
}
) {
this.attachListeners();
}
private attachListeners(): void {
// Mouse leave detection (exit intent)
document.addEventListener('mouseleave', this.handleMouseLeave);
// Idle detection
this.resetIdleTimer();
document.addEventListener('mousemove', this.resetIdleTimer);
document.addEventListener('keypress', this.resetIdleTimer);
// Input monitoring
this.formElement.addEventListener('input', this.handleInput);
this.formElement.addEventListener('focusin', this.handleFocusIn);
this.formElement.addEventListener('focusout', this.handleFocusOut);
// Error detection
this.formElement.addEventListener('invalid', this.handleInvalid, true);
}
private handleMouseLeave = (e: MouseEvent): void => {
// Only trigger for upward mouse movement (exit intent)
if (e.clientY < 50 && !this.interventionTriggered) {
this.addSignal({
type: 'mouse_leave',
timestamp: Date.now(),
metadata: { clientY: e.clientY }
});
this.checkIntervention();
}
};
private resetIdleTimer = (): void => {
this.lastActivity = Date.now();
if (this.idleTimer) {
clearTimeout(this.idleTimer);
}
this.idleTimer = setTimeout(() => {
this.addSignal({
type: 'idle',
timestamp: Date.now(),
metadata: { idleDuration: this.config.idleThreshold }
});
this.checkIntervention();
}, this.config.idleThreshold);
};
private handleInput = (e: Event): void => {
const target = e.target as HTMLInputElement;
const inputType = (e as InputEvent).inputType;
// Detect rapid deletion (frustration signal)
if (inputType === 'deleteContentBackward' || inputType === 'deleteContentForward') {
this.deletionCount++;
if (this.deletionCount >= this.config.deletionThreshold) {
this.addSignal({
type: 'rapid_deletion',
timestamp: Date.now(),
metadata: { fieldName: target.name, deletionCount: this.deletionCount }
});
this.deletionCount = 0;
this.checkIntervention();
}
} else {
this.deletionCount = 0;
}
};
private handleFocusIn = (e: FocusEvent): void => {
const target = e.target as HTMLInputElement;
this.fieldTimes.set(target.name, Date.now());
};
private handleFocusOut = (e: FocusEvent): void => {
const target = e.target as HTMLInputElement;
const startTime = this.fieldTimes.get(target.name);
if (startTime) {
const duration = Date.now() - startTime;
if (duration > this.config.hesitationThreshold && !target.value) {
this.addSignal({
type: 'field_hesitation',
timestamp: Date.now(),
metadata: {
fieldName: target.name,
duration,
fieldLabel: this.getFieldLabel(target)
}
});
this.checkIntervention();
}
}
};
private handleInvalid = (e: Event): void => {
this.errorCount++;
if (this.errorCount >= this.config.errorThreshold) {
this.addSignal({
type: 'error_frustration',
timestamp: Date.now(),
metadata: { errorCount: this.errorCount }
});
this.checkIntervention();
}
};
private getFieldLabel(input: HTMLInputElement): string {
const label = this.formElement.querySelector(`label[for="${input.id}"]`);
return label?.textContent || input.placeholder || input.name;
}
private addSignal(signal: AbandonmentSignal): void {
this.signals.push(signal);
}
private checkIntervention(): void {
if (this.interventionTriggered) return;
// Calculate abandonment risk score
const riskScore = this.calculateRiskScore();
if (riskScore >= 0.7) {
this.interventionTriggered = true;
this.onAbandonmentDetected(this.signals);
}
}
private calculateRiskScore(): number {
let score = 0;
const weights = {
mouse_leave: 0.3,
idle: 0.2,
rapid_deletion: 0.25,
error_frustration: 0.35,
field_hesitation: 0.15
};
const signalTypes = new Set(this.signals.map(s => s.type));
signalTypes.forEach(type => {
score += weights[type] || 0;
});
return Math.min(score, 1);
}
public destroy(): void {
document.removeEventListener('mouseleave', this.handleMouseLeave);
document.removeEventListener('mousemove', this.resetIdleTimer);
document.removeEventListener('keypress', this.resetIdleTimer);
if (this.idleTimer) {
clearTimeout(this.idleTimer);
}
}
}
// Save progress automatically
export function useFormPersistence(
formId: string,
values: Record<string, any>,
excludeFields: string[] = ['password', 'creditCard', 'cvv']
) {
const storageKey = `form_progress_${formId}`;
// Save on change (debounced)
useEffect(() => {
const timer = setTimeout(() => {
const safeValues = { ...values };
excludeFields.forEach(field => delete safeValues[field]);
localStorage.setItem(storageKey, JSON.stringify({
values: safeValues,
timestamp: Date.now()
}));
}, 1000);
return () => clearTimeout(timer);
}, [values, storageKey, excludeFields]);
// Restore on mount
const restoreProgress = useCallback(() => {
const saved = localStorage.getItem(storageKey);
if (saved) {
const { values, timestamp } = JSON.parse(saved);
// Only restore if less than 24 hours old
if (Date.now() - timestamp < 86400000) {
return values;
}
}
return null;
}, [storageKey]);
const clearProgress = useCallback(() => {
localStorage.removeItem(storageKey);
}, [storageKey]);
return { restoreProgress, clearProgress };
}
Maintaining Accessibility
Smart forms must remain accessible to all users, including those using screen readers, keyboard navigation, or other assistive technologies. Following WCAG 2.1 Input Assistance guidelines is essential when implementing AI-powered features. For more on accessibility implementation, see our guide on AI for Accessibility (a11y) Implementation.
// accessible-form-field.tsx
interface AccessibleFieldProps {
id: string;
label: string;
type?: string;
error?: string;
hint?: string;
required?: boolean;
suggestions?: string[];
onSuggestionSelect?: (suggestion: string) => void;
value: string;
onChange: (value: string) => void;
}
export function AccessibleFormField({
id,
label,
type = 'text',
error,
hint,
required = false,
suggestions = [],
onSuggestionSelect,
value,
onChange
}: AccessibleFieldProps) {
const [showSuggestions, setShowSuggestions] = useState(false);
const [highlightedIndex, setHighlightedIndex] = useState(-1);
const errorId = `${id}-error`;
const hintId = `${id}-hint`;
const suggestionsId = `${id}-suggestions`;
// Combine aria-describedby values
const describedBy = [
hint && hintId,
error && errorId,
showSuggestions && suggestions.length > 0 && suggestionsId
].filter(Boolean).join(' ') || undefined;
return (
<div className="form-field">
<label htmlFor={id} className="form-label">
{label}
{required && <span className="required-indicator" aria-hidden="true">*</span>}
{required && <span className="sr-only">(required)</span>}
</label>
{hint && (
<p id={hintId} className="form-hint">
{hint}
</p>
)}
<div className="input-wrapper">
<input
id={id}
type={type}
value={value}
onChange={(e) => onChange(e.target.value)}
onFocus={() => setShowSuggestions(true)}
onBlur={() => setTimeout(() => setShowSuggestions(false), 200)}
required={required}
aria-required={required}
aria-invalid={!!error}
aria-describedby={describedBy}
aria-autocomplete={suggestions.length > 0 ? 'list' : undefined}
aria-controls={suggestions.length > 0 ? suggestionsId : undefined}
aria-activedescendant={
highlightedIndex >= 0 ? `${id}-suggestion-${highlightedIndex}` : undefined
}
className={`form-input ${error ? 'has-error' : ''}`}
/>
{/* Suggestions dropdown with live region */}
{showSuggestions && suggestions.length > 0 && (
<>
<ul
id={suggestionsId}
role="listbox"
aria-label={`Suggestions for ${label}`}
className="suggestions-list"
>
{suggestions.map((suggestion, index) => (
<li
key={suggestion}
id={`${id}-suggestion-${index}`}
role="option"
aria-selected={highlightedIndex === index}
onClick={() => onSuggestionSelect?.(suggestion)}
onMouseEnter={() => setHighlightedIndex(index)}
className={highlightedIndex === index ? 'highlighted' : ''}
>
{suggestion}
</li>
))}
</ul>
<div aria-live="polite" className="sr-only">
{suggestions.length} suggestions available
</div>
</>
)}
</div>
{/* Error message with live region */}
{error && (
<p id={errorId} className="form-error" role="alert">
<span aria-hidden="true">!</span>
{error}
</p>
)}
</div>
);
}
// Screen reader only styles (add to CSS)
/*
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
*/
Measuring Form Performance
Track the impact of AI enhancements with comprehensive metrics. The goal is to measure not just completion rates, but the quality of the user experience:
// form-analytics.ts
interface FormMetrics {
formId: string;
sessionId: string;
startTime: number;
completionTime?: number;
abandoned: boolean;
abandonedAt?: string;
fieldInteractions: FieldInteraction[];
suggestionAcceptanceRate: number;
validationErrors: number;
autocompletionsUsed: number;
}
interface FieldInteraction {
fieldId: string;
focusTime: number;
blurTime?: number;
changes: number;
errorsShown: number;
suggestionsShown: number;
suggestionAccepted: boolean;
}
class FormAnalytics {
private metrics: FormMetrics;
private currentField: FieldInteraction | null = null;
constructor(formId: string) {
this.metrics = {
formId,
sessionId: crypto.randomUUID(),
startTime: Date.now(),
abandoned: true,
fieldInteractions: [],
suggestionAcceptanceRate: 0,
validationErrors: 0,
autocompletionsUsed: 0
};
}
trackFieldFocus(fieldId: string): void {
this.currentField = {
fieldId,
focusTime: Date.now(),
changes: 0,
errorsShown: 0,
suggestionsShown: 0,
suggestionAccepted: false
};
}
trackFieldBlur(): void {
if (this.currentField) {
this.currentField.blurTime = Date.now();
this.metrics.fieldInteractions.push({ ...this.currentField });
this.currentField = null;
}
}
trackChange(): void {
if (this.currentField) {
this.currentField.changes++;
}
}
trackSuggestionShown(): void {
if (this.currentField) {
this.currentField.suggestionsShown++;
}
}
trackSuggestionAccepted(): void {
if (this.currentField) {
this.currentField.suggestionAccepted = true;
}
this.metrics.autocompletionsUsed++;
}
trackValidationError(): void {
this.metrics.validationErrors++;
if (this.currentField) {
this.currentField.errorsShown++;
}
}
trackSubmission(success: boolean): void {
this.metrics.abandoned = !success;
this.metrics.completionTime = Date.now();
this.calculateSuggestionAcceptanceRate();
this.sendMetrics();
}
trackAbandonment(lastFieldId?: string): void {
this.metrics.abandoned = true;
this.metrics.abandonedAt = lastFieldId;
this.calculateSuggestionAcceptanceRate();
this.sendMetrics();
}
private calculateSuggestionAcceptanceRate(): void {
const fieldsWithSuggestions = this.metrics.fieldInteractions.filter(
f => f.suggestionsShown > 0
);
if (fieldsWithSuggestions.length > 0) {
const accepted = fieldsWithSuggestions.filter(f => f.suggestionAccepted).length;
this.metrics.suggestionAcceptanceRate = accepted / fieldsWithSuggestions.length;
}
}
private async sendMetrics(): Promise<void> {
const summary = {
...this.metrics,
totalTime: (this.metrics.completionTime || Date.now()) - this.metrics.startTime,
avgFieldTime: this.calculateAvgFieldTime(),
completionRate: this.metrics.abandoned ? 0 : 1
};
await fetch('/api/analytics/form-metrics', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(summary)
});
}
private calculateAvgFieldTime(): number {
const times = this.metrics.fieldInteractions
.filter(f => f.blurTime)
.map(f => f.blurTime! - f.focusTime);
return times.length > 0 ? times.reduce((a, b) => a + b, 0) / times.length : 0;
}
}
Key Takeaways
Remember These Points
- Predictive text reduces friction: AI-powered autocomplete with debouncing and keyboard navigation can reduce input time by 50%
- Address autocomplete is essential: Google Places API integration eliminates address validation errors and reduces abandonment by 25-30%
- Proactive validation beats reactive: Real-time feedback with typo detection and suggestions prevents errors before submission
- Conversational forms increase completion: One question at a time with progress indicators achieves 40% higher completion rates
- Detect abandonment signals early: Exit intent, idle time, and frustration patterns trigger timely interventions
- Accessibility is non-negotiable: ARIA attributes, keyboard navigation, and screen reader support must accompany all AI features
- Measure everything: Track suggestion acceptance, field time, validation errors, and abandonment points to continuously optimize
Conclusion
Smart forms powered by AI represent a significant evolution in web user experience. By implementing predictive text input, address auto-completion with services like the Google Places API, intelligent validation, and conversational interfaces, you can achieve the 30%+ improvement in form completion rates that leading organizations report.
The key is to view forms not as data collection barriers but as opportunities for intelligent assistance. Every keystroke is a chance to anticipate user needs, every validation error an opportunity to guide rather than frustrate, and every moment of hesitation a signal to provide helpful context.
For related topics, explore our guides on AI Bias in Accessibility for inclusive form design and AI-Driven Personalization Engines for dynamic form customization based on user behavior. External resources like the web.dev Forms Guide and Nielsen Norman Group's form design research provide additional best practices for form optimization.