The Dual Focus Pattern is an advanced UX design that allows users to seamlessly switch between typing in a textarea and navigating documentation/components with arrow keys, while maintaining visual feedback and cursor visibility throughout the interaction.
Dual Focus separates two types of focus:
- DOM Focus: Where the browser cursor appears (always stays in textarea)
- Logical Focus: Which component handles keyboard events (switches between textarea and docs)
This enables users to:
- Type naturally in the textarea (cursor visible, text input works)
- Navigate docs with arrow keys (visual selection changes)
- Return to typing instantly by pressing any character key
Main documentation search and navigation component with dual focus capabilities.
Component search and navigation with identical dual focus pattern.
Orchestrates focus between textarea and docs components.
// Visual focus (persists during character input)
const [activeIndex, setActiveIndex] = useState(-1); // Currently selected item
const [startIndex, setStartIndex] = useState(0); // Window start position
const [isFocused, setIsFocused] = useState(false); // Logical focus state
// Search state
const [searchResults, setSearchResults] = useState<DocsItem[]>([]);
const [isSearching, setIsSearching] = useState(false);┌─────────────────┐ ┌─────────────────┐
│ Textarea │ │ Docs List │
│ (DOM Focus) │ │ (No Focus) │
│ Cursor: █ │ │ │
│ │ │ No items │
└─────────────────┘ └─────────────────┘
- Textarea has DOM focus (cursor visible)
- Docs list has no logical focus
- No visual selection in docs
User types: "react"
┌─────────────────┐ ┌─────────────────┐
│ Textarea │ │ Docs List │
│ react█ │ │ (Searching) │
│ │ │ Loading... │
└─────────────────┘ └─────────────────┘
Flow:
- User types in textarea
searchQueryprop updates in DocsList- Local search executes immediately
- API search starts (non-blocking)
- Results appear, first item auto-selected
User presses: ↓ (Arrow Down)
┌─────────────────┐ ┌─────────────────┐
│ Textarea │ │ Docs List │
│ react█ │ │ (Focused) │
│ │ │ ▶ React │
│ │ │ Vue │
│ │ │ Angular │
└─────────────────┘ └─────────────────┘
Flow:
- Arrow key triggers
focusOnDocs()via global listener isFocusedbecomestrue- Visual selection appears (first item highlighted)
- Keyboard events now handled by docs component
User presses: ↓ ↓ ↑
┌─────────────────┐ ┌─────────────────┐
│ Textarea │ │ Docs List │
│ react█ │ │ (Focused) │
│ │ │ React │
│ │ │ ▶ Vue │
│ │ │ Angular │
└─────────────────┘ └─────────────────┘
Navigation Logic:
case 'ArrowDown':
const nextIndex = activeIndex + 1;
const newIndex = nextIndex < docs.length ? nextIndex : 0; // Wrap to start
setActiveIndex(newIndex);
// Update visible window if needed
break;User presses: "c" (any character)
┌─────────────────┐ ┌─────────────────┐
│ Textarea │ │ Docs List │
│ reactc█ │ │ (Visual Only) │
│ │ │ ▶ Vue │
│ │ │ Angular │
└─────────────────┘ └─────────────────┘
Critical Flow:
- Character key detected in
handleKeyDown isFocusedset tofalse(stops keyboard handling)onFocusReturn()called (focuses textarea)- Visual selection persists (activeIndex unchanged)
- Key event propagates to textarea
User presses: ⏎ (Enter)
┌─────────────────┐ ┌─────────────────┐
│ Textarea │ │ Docs List │
│ @react/vue█ │ │ (Completed) │
│ │ │ │
└─────────────────┘ └─────────────────┘
Selection Flow:
- Enter key triggers
selectActiveDoc() - Selected item added to recent docs
onDocSelectioncallback fired- Component closes, focus returns to textarea
- Selected doc inserted into textarea
// Add listener when logically focused
useEffect(() => {
if (isFocused) {
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}
}, [isFocused, handleKeyDown]);focusOnDocs: () => {
setIsFocused(true);
// CRITICAL: Don't call containerRef.current?.focus()
// This would steal DOM focus from textarea!
}// Return to textarea for character input
if ((e.key.length === 1 && !e.ctrlKey && !e.metaKey) || // Characters
e.key === 'Backspace' || e.key === 'Delete' || // Editing
(e.ctrlKey && (e.key === 'a' || e.key === 'x' || // Shortcuts
e.key === 'c' || e.key === 'v'))) {
setIsFocused(false);
onFocusReturn(); // Focus textarea
// Key continues to textarea!
}// Visual selection survives focus changes
const isItemFocused = activeIndex === startIndex + idx;
return (
<button className={cn(
'flex w-full items-center gap-2 rounded-md border p-2',
isItemFocused
? 'border-border bg-background ring-2 ring-muted-foreground'
: 'border-border bg-background hover:border-muted-foreground'
)}>interface DocsListProps {
searchQuery?: string;
onDocSelection?: (item: DocsItem) => void;
onFocusReturn?: () => void;
onFocusChange?: (isFocused: boolean, activeDoc?: DocsItem) => void;
onCloseDocs?: () => void;
onReady?: () => void;
}interface DocsListRef {
focusOnDocs: () => void; // Enable navigation mode
selectActiveDoc: () => boolean; // Select current item
}const docsRef = useRef<DocsListRef>(null);
// Trigger navigation
const handleArrowKey = () => {
docsRef.current?.focusOnDocs();
};
// Handle selection
const handleDocSelection = (doc: DocsItem) => {
insertIntoTextarea(`@${doc.id}`);
};- No focus stealing or cursor jumping
- Natural typing experience maintained
- Instant navigation when needed
- Selection remains visible during typing
- Preview shows current selection
- Smooth transitions between modes
- Arrow keys for navigation
- Character keys return to typing
- Enter to select, Escape to cancel
- Proper ARIA labels and roles
- Keyboard-only navigation
- Screen reader friendly
try {
// API search
} catch (apiError) {
// Fall back to local results
console.warn('API failed, using local results only');
}const handleContainerBlur = useCallback(() => {
setTimeout(() => {
// Only reset if truly blurred
if (!containerRef.current?.contains(document.activeElement)) {
setIsFocused(false);
}
}, 100);
}, []);const searchTimeout = setTimeout(async () => {
// Search logic
}, 300); // 300ms debounce// Show local results immediately
setSearchResults(localResults);
// Then enhance with API results
const apiResults = await fetchAPI();
setSearchResults([...localResults, ...apiResults]);const visibleDocs = useMemo(() => {
return filteredDocs.slice(startIndex, startIndex + 3);
}, [filteredDocs, startIndex]);- Type search query → Results appear
- Press ↓ → Navigation mode activates
- Navigate with arrows → Selection changes
- Press 'x' → Returns to typing
- Press Enter → Selection completes
- Empty search → No results, graceful handling
- API failure → Local results only
- Rapid typing → Debounced search
- Window resize → Layout adapts
- Keyboard shortcuts → Proper handling
- Touch Support: Swipe gestures for mobile
- Voice Input: Speech-to-text integration
- Multi-Select: Shift+click for multiple items
- Favorites: Star items for quick access
- Categories: Filter by framework type
- Virtual Scrolling: For large result sets
- Caching: API response caching
- Prefetching: Predict and preload results
This dual focus pattern provides a sophisticated yet intuitive user experience that feels natural while offering powerful navigation capabilities.