Mobile-First Chatbot Widget Development
Overview
Floating chat widgets are common on desktop, but they break in surprising ways on mobile — keyboards push content offscreen, panels overflow the viewport, and inputs trigger unwanted zoom. This article covers the proven patterns for building chat widgets that feel native on mobile while remaining elegant floating panels on desktop. The key insight: use CSS-driven fullscreen on mobile instead of fighting the browser with JavaScript viewport calculations.
The Core Problem
On desktop, a chat widget is typically a fixed-position panel (e.g., 360px wide, 500px tall) anchored to the bottom-right corner. This works great — until you open it on a phone:
| Issue | What Happens |
|---|---|
| Panel overflow | A 360px-wide, 500px-tall panel doesn't fit a 390px-wide mobile screen |
| Keyboard occlusion | The virtual keyboard covers the input field — the one thing users need to see |
| Background scrolling | Users scroll the page behind the chat instead of the chat messages |
| iOS zoom | Inputs with font-size < 16px trigger Safari's auto-zoom, breaking the layout |
| Safe area | Notched devices (iPhone X+) clip content behind the home indicator |
The Solution: CSS-First Fullscreen
Instead of trying to resize and reposition a floating panel with JavaScript, go fullscreen on mobile using pure CSS and let the browser handle keyboard layout natively.
Layout Strategy
Desktop (>640px) Mobile (<=640px)
+---------------------------+ +-------------------+
| [chat] | | Header [X] |
| +----+ | | |
| | H | | | Messages |
| | M | | | (flex: 1) |
| | I | | | |
| +----+ | | Starters |
| | | |
+---------------------------+ | [Input] [Send] |
+-------------------+
Desktop: Fixed-position floating panel with border-radius, shadow, backdrop blur.
Mobile: position: fixed; inset: 0 — full viewport, no border-radius, no border.
Implementation (Tailwind CSS + React)
{/* Panel container */}
<div className={`
fixed z-50 flex flex-col overflow-hidden bg-navy-900
transition-all duration-300
max-sm:inset-0
sm:bottom-24 sm:right-6 sm:w-[360px] sm:rounded-xl
sm:border sm:border-slate-700/50 sm:shadow-2xl
${isOpen
? "pointer-events-auto translate-y-0 opacity-100"
: "pointer-events-none translate-y-full opacity-0 sm:translate-y-4"
}
`}>
Key classes explained:
| Class | Purpose |
|---|---|
max-sm:inset-0 | Fullscreen on mobile (< 640px) — sets top/right/bottom/left to 0 |
sm:bottom-24 sm:right-6 | Floating position on desktop only |
sm:w-[360px] | Fixed width on desktop only |
sm:rounded-xl sm:border | Visual chrome on desktop only |
translate-y-full (closed) | Slides down off-screen on mobile; sm:translate-y-4 for subtle desktop animation |
Flexbox Layout for the Panel Interior
The panel uses a vertical flexbox with three sections:
{/* Header — fixed height */}
<div className="flex-shrink-0 border-b px-4 py-3">
...
{/* Close button — mobile only */}
<button className="sm:hidden" onClick={() => setIsOpen(false)}>X</button>
</div>
{/* Messages — fills remaining space */}
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-4">
...
</div>
{/* Input — fixed height, pinned to bottom */}
<div className="flex-shrink-0 border-t px-4 py-3"
style={{ paddingBottom: "calc(0.75rem + env(safe-area-inset-bottom, 0px))" }}>
...
</div>
Critical details:
min-h-0on the messages container: Without this,flex: 1children with overflow content won't actually scroll — they expand the parent instead. This is one of the most common flexbox gotchas.overscroll-contain: Prevents scroll chaining — when the user scrolls to the end of messages, it won't start scrolling the page behind the panel.flex-shrink-0on header and input: Prevents them from compressing when messages overflow.
Body Scroll Locking
When the chat panel is open on mobile, the page behind it must not scroll. CSS overflow: hidden on <body> alone isn't enough — iOS Safari ignores it. The fix:
useEffect(() => {
if (!isOpen) return;
const isMobile = window.innerWidth < 640;
if (!isMobile) return;
const scrollY = window.scrollY;
document.body.style.overflow = "hidden";
document.body.style.position = "fixed";
document.body.style.width = "100%";
document.body.style.top = `-${scrollY}px`;
return () => {
document.body.style.overflow = "";
document.body.style.position = "";
document.body.style.width = "";
document.body.style.top = "";
window.scrollTo(0, scrollY); // Restore scroll position
};
}, [isOpen]);
Why position: fixed on the body?
overflow: hiddenalone doesn't prevent iOS rubber-band scrollingposition: fixedremoves the body from the scroll flow entirely- We save
scrollYbefore locking and restore it on cleanup — otherwise the page jumps to the top when the user closes the chat
iOS Input Zoom Prevention
Safari automatically zooms in when the user focuses an input with font-size less than 16px. This is disorienting and breaks the fullscreen layout.
{/* Use text-base (16px) on mobile, smaller on desktop */}
<input
className="text-base sm:text-xs ..."
placeholder="Ask a question..."
/>
The text-base class sets font-size: 1rem (16px), which is the threshold Safari uses. On desktop (sm: breakpoint), we switch back to text-xs for the compact floating panel aesthetic.
Safe Area Insets
Devices with notches or home indicators (iPhone X and later) have safe area insets — regions where content can be clipped. The input area needs bottom padding to account for this:
<div style={{
paddingBottom: "calc(0.75rem + env(safe-area-inset-bottom, 0px))"
}}>
You also need the viewport meta tag:
<meta name="viewport"
content="width=device-width, initial-scale=1, viewport-fit=cover" />
The viewport-fit=cover tells the browser to extend the layout into the safe areas, and env(safe-area-inset-bottom) lets you add padding where needed.
Toggle Button Visibility
On desktop, the toggle button (floating chat icon) stays visible when the panel is open — users click it to close. On mobile, the panel covers the entire screen, so the toggle button is hidden and replaced by a close button in the header:
{/* Toggle button — hidden on mobile when open */}
<button className={`fixed bottom-6 right-6 ${isOpen ? "max-sm:hidden" : ""}`}>
...
</button>
{/* Close button in header — visible only on mobile */}
<button className="sm:hidden" onClick={() => setIsOpen(false)}>
X
</button>
Approach Comparison: CSS vs. JavaScript Viewport Tracking
An alternative approach uses the window.visualViewport API to dynamically resize the panel based on the visible viewport (accounting for the keyboard). While this can work, it introduces significant complexity:
| CSS Fullscreen | JavaScript Viewport Tracking | |
|---|---|---|
| Complexity | Low — a few Tailwind classes | High — event listeners, state, calculations |
| Keyboard handling | Browser handles it natively | Manual height/offset recalculation |
| SSR compatibility | Works out of the box | Requires typeof window guards |
| iOS quirks | Minimal | visualViewport events fire inconsistently |
| Maintenance | Set and forget | Ongoing debugging across devices |
Recommendation: Use the CSS approach. The browser's native behavior with position: fixed; inset: 0 handles keyboard appearance correctly on modern mobile browsers. The JavaScript approach adds complexity without meaningful benefit.
Checklist
When building or reviewing a mobile chat widget:
- Panel goes fullscreen on mobile (
inset: 0or equivalent) - Body scroll is locked when panel is open on mobile
- Messages area uses
min-h-0+flex: 1+overflow-y: auto -
overscroll-containprevents scroll chaining - Input font size is >= 16px on mobile (prevents iOS zoom)
- Safe area insets are respected (
env(safe-area-inset-bottom)) - Close button is visible in the header on mobile
- Toggle button is hidden on mobile when panel is open
- Panel animates with
translate-y(GPU-accelerated, no layout thrashing) - Desktop layout is unaffected (floating panel with border-radius, shadow)