Skip to main content

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:

IssueWhat Happens
Panel overflowA 360px-wide, 500px-tall panel doesn't fit a 390px-wide mobile screen
Keyboard occlusionThe virtual keyboard covers the input field — the one thing users need to see
Background scrollingUsers scroll the page behind the chat instead of the chat messages
iOS zoomInputs with font-size < 16px trigger Safari's auto-zoom, breaking the layout
Safe areaNotched 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:

ClassPurpose
max-sm:inset-0Fullscreen on mobile (< 640px) — sets top/right/bottom/left to 0
sm:bottom-24 sm:right-6Floating position on desktop only
sm:w-[360px]Fixed width on desktop only
sm:rounded-xl sm:borderVisual 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-0 on the messages container: Without this, flex: 1 children 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-0 on 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?

  1. overflow: hidden alone doesn't prevent iOS rubber-band scrolling
  2. position: fixed removes the body from the scroll flow entirely
  3. We save scrollY before 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 FullscreenJavaScript Viewport Tracking
ComplexityLow — a few Tailwind classesHigh — event listeners, state, calculations
Keyboard handlingBrowser handles it nativelyManual height/offset recalculation
SSR compatibilityWorks out of the boxRequires typeof window guards
iOS quirksMinimalvisualViewport events fire inconsistently
MaintenanceSet and forgetOngoing 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: 0 or equivalent)
  • Body scroll is locked when panel is open on mobile
  • Messages area uses min-h-0 + flex: 1 + overflow-y: auto
  • overscroll-contain prevents 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)