"use client"; /** * Tracks the *visual* viewport — the part of the page not covered by the * software keyboard. * * Why this exists: on iOS Safari `100dvh` does not shrink when the keyboard * opens. The dynamic viewport units respond to browser chrome collapsing, not * to the keyboard, so a composer pinned to the bottom of a `100dvh` column * ends up underneath it. `window.visualViewport` is the only thing iOS updates * reliably, so the thread is sized from that instead while the keyboard is up. * * `offsetTop` matters as much as `height`: iOS scrolls the layout viewport to * reveal the focused field rather than resizing it, so a panel pinned to the * visible area has to follow that offset or it drifts off-screen. */ import { useEffect, useState } from "react"; export type VisualViewport = { /** Visible height in px, excluding the keyboard. */ height: number; /** How far the layout viewport has scrolled to reveal the focused field. */ offsetTop: number; /** True once the visible area is materially shorter than the window. */ keyboardOpen: boolean; }; /** * A URL bar collapsing costs ~50–60px; a keyboard costs several hundred. The * threshold keeps ordinary chrome changes from being read as a keyboard. */ const KEYBOARD_THRESHOLD_PX = 120; export function useVisualViewport(): VisualViewport | null { const [state, setState] = useState(null); useEffect(() => { const vv = window.visualViewport; // Absent on older browsers — callers fall back to their CSS height. if (!vv) return; const apply = () => { setState({ height: vv.height, offsetTop: vv.offsetTop, keyboardOpen: window.innerHeight - vv.height > KEYBOARD_THRESHOLD_PX, }); }; apply(); vv.addEventListener("resize", apply); // iOS fires scroll (not resize) as the layout viewport shifts under the // keyboard, so both are needed to stay pinned. vv.addEventListener("scroll", apply); return () => { vv.removeEventListener("resize", apply); vv.removeEventListener("scroll", apply); }; }, []); return state; }