As a web developer working on progressive web apps (PWAs), I've discovered that mobile development tools can feel frustratingly limited compared to native app development. One specific challenge that often gets overlooked is how virtual keyboards interact with your app's interface.
The Mobile Keyboard Challenge
When developing for mobile platforms, the virtual keyboard is a unique beast. Unlike desktop environments, mobile devices overlay the keyboard on top of your app, typically obscuring around 40% of the screen. This can create unexpected user experience issues, especially for apps with multiple input types.
In my current project, chordel - a collaborative song writing app - I frequently encountered a subtle but annoying problem: some touch interactions were accidentally triggering text input focus and summoning the virtual keyboard when it wasn't needed.
Why Simulation Matters
Testing mobile behaviors on desktop browsers often means missing these nuanced interactions. While real device testing is an option, it comes with complications:
- Requires physically having the device
- Involves setting up remote debugging
- Can be time-consuming to switch between development environments
A Custom Virtual Keyboard Simulator
I developed a React component to simulate mobile keyboard behavior during development. Here's how it works:
- Use
MutationObserver
to detect when the DOM is fully loaded - Attach
focus
andblur
handlers to all text-editable elements - Show/hide a simulated keyboard overlay with the handlers
- Add smooth scrolling and animations (optionally :))
Key Component Features
The simulator provides:
- Automatic keyboard overlay
- Smooth scrolling to focused elements
- A dismissal mechanism
- Optional animation effects
Here's the complete implementation:
const MobileKeyboardSimulator = ({
enableAutoScroll = true,
keyboardHeight = 300
}) => {
const [activeElement, setActiveElement] = useState<HTMLElement | null>(null);
const [isKeyboardVisible, setIsKeyboardVisible] = useState(false);
const [isKeyboardDismissing, setIsKeyboardDismissing] = useState(false);
const keyboardOverlayRef = useRef(null);
const originalScrollPositionRef = useRef<null | number>(null);
const blurTimeoutRef = useRef<null | ReturnType<typeof setTimeout>>(null);
// Dismiss keyboard function
const dismissKeyboard = useCallback(() => {
// Trigger slide-down animation
setIsKeyboardDismissing(true);
// Use a slight delay to allow animation to complete
setTimeout(() => {
if (activeElement) {
activeElement.blur();
}
setIsKeyboardVisible(false);
setIsKeyboardDismissing(false);
// Restore original scroll position if auto-scrolled
if (originalScrollPositionRef.current !== null) {
window.scrollTo(0, originalScrollPositionRef.current);
originalScrollPositionRef.current = null;
}
}, 300); // Match the animation duration
}, [activeElement]);
// Memoized focus handler to ensure stable reference
const createFocusHandler = useCallback(() => {
return (e: Event) => {
console.debug('Focus handler triggered', e.target);
// Clear any pending blur timeout
if (blurTimeoutRef.current) {
clearTimeout(blurTimeoutRef.current);
blurTimeoutRef.current = null;
}
setActiveElement(e.target as HTMLElement | null);
setIsKeyboardVisible(true);
setIsKeyboardDismissing(false);
// Auto-scroll functionality
if (enableAutoScroll) {
// Store original scroll position
originalScrollPositionRef.current = window.scrollY;
// Calculate element position
const rect = (e.target as HTMLElement).getBoundingClientRect();
const viewportHeight = window.innerHeight;
// Calculate scroll amount to center the element
const scrollAmount =
rect.top -
(viewportHeight - keyboardHeight - rect.height) / 2;
// Smooth scroll to element
window.scrollTo({
top: scrollAmount,
behavior: 'smooth'
});
}
};
}, [enableAutoScroll, keyboardHeight]);
// Memoized blur handler
const createBlurHandler = useCallback(() => {
return () => {
console.debug('Blur handler triggered');
// Create a timeout to delay keyboard dismissal
blurTimeoutRef.current = setTimeout(() => {
// Check if the new active element is another input/contenteditable
const newActiveElement = document.activeElement;
const isNewElementInteractive =
newActiveElement?.matches('input, textarea, [contenteditable="true"]');
if (!isNewElementInteractive) {
dismissKeyboard();
setActiveElement(null);
}
}, 100);
};
}, [dismissKeyboard]);
const attachListeners = useCallback(() => {
const selectableElements = document.querySelectorAll(
'input, textarea, [contenteditable], [contenteditable="true"]'
);
const focusHandler = createFocusHandler();
const blurHandler = createBlurHandler();
const cleanup: (() => void)[] =[]
selectableElements.forEach((element, index) => {
element.addEventListener('focus', focusHandler);
element.addEventListener('blur', blurHandler);
cleanup.push(() => {
element.removeEventListener('focus', focusHandler);
element.removeEventListener('blur', blurHandler);
})
});
return () => {
cleanup.forEach((fn) => {
fn();
});
};
}, [createBlurHandler, createFocusHandler]);
useEffect(() => {
let cleanup: null | (() => void) = null;
const observer = new MutationObserver(() => {
cleanup = attachListeners();
});
observer.observe(document.body, {
childList: true,
subtree: true
});
return () => {
observer.disconnect();
if(cleanup)
cleanup();
};
}, [attachListeners]);
// Don't render if keyboard is not visible
if (!isKeyboardVisible) return null;
return (
<div
ref={keyboardOverlayRef}
className={`
touch-none
pointer-events-none
fixed bottom-0 left-0 w-full
flex flex-col
p-4
bg-muted
border-t border-border
z-[1000]
shadow-[0_-2px_10px_rgba(0,0,0,0.1)]
${isKeyboardDismissing
? 'animate-[slideDown_0.3s_ease-out]'
: 'animate-[slideUp_0.3s_ease-out]'}
animate-fill-forward
`}
style={{height: `${keyboardHeight}px`}}
>
<div className='w-full flex justify-end items-center mb-4'>
<button
className='px-2 py-1 pointer-events-auto'
onClick={dismissKeyboard}
>
Dismiss
</button>
</div>
<div>
{/* Example keyboard layout */}
{['q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p'].map((key) => (
<button
key={key}
className='px-2 py-1 w-[30px] h-[40px] border border-border rounded-md mr-2 mb-2 text-muted-foreground'
>
{key}
</button>
))}
</div>
{/* Global style for animations */}
<style>{`
@keyframes slideUp {
from { transform: translateY(100%); }
to { transform: translateY(0); }
}
@keyframes slideDown {
from { transform: translateY(0); }
to { transform: translateY(100%); }
}
`}</style>
</div>
);
};
Platform-Specific Considerations
To ensure the simulator only runs when appropriate, I added a detection method for iOS devices, but something similar can be done for Android as well:
const isiOS = useCallback(() => {
const iosQuirkPresent = function () {
const audio = new Audio();
audio.volume = 0.5;
return audio.volume === 1; // volume cannot be changed from "1" on iOS 12 and below
};
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
const isAppleDevice = navigator.userAgent.includes('Macintosh');
const isTouchScreen = navigator.maxTouchPoints >= 1; // true for iOS 13 (and hopefully beyond)
return isIOS || (isAppleDevice && (isTouchScreen || iosQuirkPresent()));
}, []);
Usage and Deployment
Simply drop the MobileKeyboardSimulator
component into your layout. It's designed to only render during development and on mobile platforms.
By using Vite's environment variables (import.meta.env.PROD
), you can easily prevent the simulator from rendering in production.
Final Thoughts
While not a complete solution for all mobile testing scenarios, this custom keyboard simulator provides developers with a quick, lightweight way to catch mobile interaction edge cases earlier in the development process.
Testing is crucial, but having tools that help us simulate real-world conditions can save significant time and frustration.
Top comments (0)