Live Geometry Lab
This lab is not trying to beat CSS layout. It shows the browser geometry React Viewport exposes to application logic.
Looking for keyboard-safe layout? See the CSS baseline →
- Focus the input and open the software keyboard.
- Watch geometry and the result budget. Scroll to change browser chrome.
- Place a target, then scroll, rotate or pinch zoom where supported.
- Close the keyboard and compare values.
Browser policy: interactive-widget=resizes-content. Requested, not detected. When both viewports shrink, keyboard bottom occlusion can stay zero. iOS may ignore the request.
What does this browser expose?
- layout.width
- Pending
- layout.height
- Pending
- visual.width
- Pending
- visual.height
- Pending
- visual.offsetTop
- Pending
- visual.offsetLeft
- Pending
- visual.pageTop
- Pending
- visual.pageLeft
- Pending
- visual.scale
- Pending
- keyboard.open
- Pending
- keyboard.height (bottom occlusion)
- Pending
- safeArea.top
- Pending
- safeArea.right
- Pending
- safeArea.bottom
- Pending
- safeArea.left
- Pending
- Orientation
- Pending
- VisualViewport API
- Pending
- VirtualKeyboard API
- Pending
Raw geometry from the current browser. API presence does not identify the active keyboard source. No simulation.
Application decision
Measuring viewport…
Example policy: reserve 320px for search controls, allow 48px per row, cap at 8. This is a viewport-derived budget, not a measurement of this card.
CSS can resize a container. This JavaScript decision changes which optional results React creates. The constants are application policy, not library defaults.
Is this point actually visible?
Target visible? Place a target
A selected 20 × 20 document-coordinate target. Place it near the visible bottom, then open the keyboard, scroll or zoom. Its document coordinates stay fixed.
Keep the target selected while the actual keyboard changes the viewport. This field is not stored or sent.
Keyboard-aware scroll correction
The application recalculates a vertical scroll correction when viewport geometry changes. CSS cannot call a selection algorithm. Try the default observation first; opt in only to test correction.
Suggested vertical correction: Pending
Correction runs after selection, viewport-size, scale or keyboard-state changes, not ordinary scrolling. Opt-in page scrolling can be clamped at document boundaries. It does not manage focus, defeat native browser panning, or guarantee visibility inside nested scrollers.
Zoom-aware application logic
Optional annotation hit tolerance: Pending
A canvas tool can convert a 12 CSS-pixel tolerance at unit scale using 12 / visual.scale. This is an algorithm input, not a smaller button or text size.
Pinch zoom is user viewport geometry, not responsive design. Essential controls remain available at every scale.
Safe-area input for a custom drawing surface: Pending.
Use these raw protected-edge values only after mapping the drawing surface into the same coordinate system. For ordinary padding, use env() directly.
Reading unexpected results
Compare visual dimensions, offsets and scale before interpreting keyboard state. Browser chrome and zoom can change geometry without an open keyboard. Keyboard state is native where usable and otherwise conservatively inferred.
A real keyboard with zero bottom occlusion can reflect native layout resizing, a floating or split keyboard, or conservative inference. Capture before/during/after geometry to investigate. Safe-area values may remain non-zero on WebKit.
Browser updates can lag during fast scrolling. No layout workaround here changes the raw values or claims to fix that platform behavior. Physical iPhone Safari and Android Chrome geometry QA remains pending.
Expected: dimensions and offsets follow browser changes; result count follows visible height; target intersection follows the selected document coordinates. These are manual checks, not automatic pass badges.
'use client'
import { InteractionHint } from './InteractionHint'
import { useState } from 'react'
import Link from 'next/link'
import { useViewport } from '@nipe-solutions/react-viewport'
import { CodeBlock } from './CodeBlock'
import { LiveGeometry } from './LiveGeometry'
import { ResultBudget } from './ResultBudget'
import { CoordinateVisibility } from './CoordinateVisibility'
import { ZoomLogic } from './ZoomLogic'
export function DeviceLab({
sources,
build,
}: {
readonly sources: ReadonlyArray<{ label: string; code: string }>
readonly build: string
}) {
const viewport = useViewport()
const { ready, layout, visual, keyboard, safeArea, orientation, supported } = viewport
const [showBounds, setShowBounds] = useState(false)
const [copyStatus, setCopyStatus] = useState('')
async function copyDiagnostics() {
// Geometry allowlist only. No input text, user agent, identifiers or network request.
const diagnostics = {
build,
viewport: { ready, layout, visual, keyboard, safeArea },
orientation,
supported,
requestedKeyboardPolicy: 'resizes-content',
}
try {
await navigator.clipboard.writeText(JSON.stringify(diagnostics, null, 2))
setCopyStatus('Diagnostics copied. No input text included.')
} catch {
setCopyStatus('Clipboard unavailable. Record the visible geometry below.')
}
}
return (
<div className="geometry-lab lab-content">
<Link className="lab-home" href="/">
← React Viewport
</Link>
<header className="lab-intro">
<h1>Live Geometry Lab</h1>
<p>
This lab is not trying to beat CSS layout. It shows the browser geometry React Viewport
exposes to application logic.
</p>
<p>
<a href="/lab/css">Looking for keyboard-safe layout? See the CSS baseline →</a>
</p>
<ol className="lab-instructions">
<li>Focus the input and open the software keyboard.</li>
<li>Watch geometry and the result budget. Scroll to change browser chrome.</li>
<li>Place a target, then scroll, rotate or pinch zoom where supported.</li>
<li>Close the keyboard and compare values.</li>
</ol>
<p className="lab-policy">
Browser policy: <code>interactive-widget=resizes-content</code>.{' '}
<strong>Requested, not detected.</strong> When both viewports shrink, keyboard bottom
occlusion can stay zero. iOS may ignore the request.
</p>
</header>
<InteractionHint />
<div className="lab-controls">
<button type="button" onClick={copyDiagnostics} disabled={!ready}>
Copy diagnostics
</button>
<button type="button" aria-pressed={showBounds} onClick={() => setShowBounds(!showBounds)}>
Outline visual bounds
</button>
</div>
<p role="status">{copyStatus}</p>
<div className="geometry-lab-grid">
<div>
<h2>What does this browser expose?</h2>
<LiveGeometry />
</div>
<div>
<h2>Application decision</h2>
<form className="geometry-input" onSubmit={(event) => event.preventDefault()}>
<label htmlFor="geometry-keyboard-input">Open your software keyboard</label>
<input
id="geometry-keyboard-input"
placeholder="Type to change browser geometry…"
autoComplete="off"
/>
<p>Normal CSS layout. Typed text is never sent, saved or included in diagnostics.</p>
</form>
<ResultBudget />
<p>
CSS can resize a container. This JavaScript decision changes which optional results
React creates. The constants are application policy, not library defaults.
</p>
</div>
</div>
<h2>Is this point actually visible?</h2>
<CoordinateVisibility />
<h2>Zoom-aware application logic</h2>
<ZoomLogic />
<section className="lab-help">
<h2>Reading unexpected results</h2>
<p>
Compare visual dimensions, offsets and scale before interpreting keyboard state. Browser
chrome and zoom can change geometry without an open keyboard. Keyboard state is native
where usable and otherwise conservatively inferred.
</p>
<p>
A real keyboard with zero bottom occlusion can reflect native layout resizing, a floating
or split keyboard, or conservative inference. Capture before/during/after geometry to
investigate. Safe-area values may remain non-zero on WebKit.
</p>
<p>
Browser updates can lag during fast scrolling. No layout workaround here changes the raw
values or claims to fix that platform behavior. Physical iPhone Safari and Android Chrome
geometry QA remains pending.
</p>
<p>
Expected: dimensions and offsets follow browser changes; result count follows visible
height; target intersection follows the selected document coordinates. These are manual
checks, not automatic pass badges.
</p>
</section>
{sources.map((source) => (
<CodeBlock key={source.label} collapsible label={source.label} code={source.code} />
))}
{showBounds && ready && visual && (
<div
className="geometry-visual-outline"
aria-hidden="true"
style={{
position: 'absolute',
left: visual.pageLeft,
top: visual.pageTop,
width: visual.width,
height: visual.height,
}}
/>
)}
</div>
)
}
'use client'
import { formatGeometry } from './format-geometry'
import { useViewport } from '@nipe-solutions/react-viewport'
export function LiveGeometry({ compact = false }: { readonly compact?: boolean }) {
const { ready, layout, visual, keyboard, safeArea, orientation, supported } = useViewport()
const px = (n: number | undefined) =>
ready && n !== undefined ? `${formatGeometry(n)} px` : 'Pending'
const rows = compact
? [
['Visual height', px(visual?.height)],
['Offset top', px(visual?.offsetTop)],
['Scale', visual ? formatGeometry(visual.scale) : 'Pending'],
['Keyboard', ready ? (keyboard.open ? 'open' : 'closed') : 'Pending'],
['Bottom occlusion', px(keyboard.height)],
['Safe bottom', px(safeArea.bottom)],
]
: [
['layout.width', px(layout?.width)],
['layout.height', px(layout?.height)],
['visual.width', px(visual?.width)],
['visual.height', px(visual?.height)],
['visual.offsetTop', px(visual?.offsetTop)],
['visual.offsetLeft', px(visual?.offsetLeft)],
['visual.pageTop', px(visual?.pageTop)],
['visual.pageLeft', px(visual?.pageLeft)],
['visual.scale', visual ? formatGeometry(visual.scale) : 'Pending'],
['keyboard.open', ready ? String(keyboard.open) : 'Pending'],
['keyboard.height (bottom occlusion)', px(keyboard.height)],
['safeArea.top', px(safeArea.top)],
['safeArea.right', px(safeArea.right)],
['safeArea.bottom', px(safeArea.bottom)],
['safeArea.left', px(safeArea.left)],
['Orientation', orientation ?? 'Pending'],
['VisualViewport API', ready ? String(supported.visualViewport) : 'Pending'],
['VirtualKeyboard API', ready ? String(supported.virtualKeyboard) : 'Pending'],
]
return (
<section
className="lab-geometry"
data-testid={compact ? 'hero-geometry' : 'lab-geometry'}
aria-label={compact ? 'Live snapshot' : 'Live geometry'}
>
<span className="mode-badge">{ready ? 'LIVE' : 'Measuring…'}</span>
<dl>
{rows.map(([label, value]) => (
<div key={label}>
<dt>{label}</dt>
<dd>{value}</dd>
</div>
))}
</dl>
{!compact && (
<p>
Raw geometry from the current browser. API presence does not identify the active keyboard
source. No simulation.
</p>
)}
</section>
)
}
'use client'
import { formatGeometry } from './format-geometry'
import { useViewport } from '@nipe-solutions/react-viewport'
// Example application policy, not library defaults. Reserve space for the
// search overlay's controls, and limit how many local results React creates.
const RESERVED_SPACE = 320
const ROW_HEIGHT = 48
const MAX_RESULTS = 8
const results = [
'Document outline',
'Selection anchor',
'Search overlay',
'Visible-area panel',
'Canvas controls',
'Virtualized results',
'Zoom-aware overlay',
'Browser diagnostics',
]
export function ResultBudget() {
const { ready, visual } = useViewport()
const limit =
ready && visual
? Math.min(
MAX_RESULTS,
Math.max(0, Math.floor((visual.height - RESERVED_SPACE) / ROW_HEIGHT)),
)
: null
return (
<section className="result-budget" aria-label="JavaScript result budget">
<p role="status">
{limit === null
? 'Measuring viewport…'
: `Render budget: ${limit} results · visible height ${formatGeometry(visual?.height ?? 0)} px`}
</p>
<p>
Example policy: reserve {RESERVED_SPACE}px for search controls, allow {ROW_HEIGHT}px per
row, cap at {MAX_RESULTS}. This is a viewport-derived budget, not a measurement of this
card.
</p>
{limit === 0 && <p>No result rows fit the current budget.</p>}
{limit !== null && limit > 0 && (
<ul>
{results.slice(0, limit).map((result) => (
<li key={result}>{result}</li>
))}
</ul>
)}
</section>
)
}
'use client'
import { formatGeometry } from './format-geometry'
import { useEffect, useRef, useState, useId } from 'react'
import { createPortal } from 'react-dom'
import { useViewport } from '@nipe-solutions/react-viewport'
import {
correctionToReveal,
intersectsDocumentViewport,
type DocumentTarget,
} from './geometry-logic'
export function CoordinateVisibility() {
const { ready, visual, keyboard } = useViewport()
const [target, setTarget] = useState<DocumentTarget | null>(null)
const [correctScroll, setCorrectScroll] = useState(false)
const lastCorrectionTrigger = useRef('')
const inputId = useId()
const visible = target && visual ? intersectsDocumentViewport(target, visual) : null
const correction = target && visual ? correctionToReveal(target, visual) : null
// Explicit opt-in application policy: re-evaluate a selected document target
// after geometry changes. Never move focus or change the measured snapshot.
useEffect(() => {
if (!visual) return
// Page scrolling/panning alone must not pull the reader back to the target.
const trigger = JSON.stringify([
correctScroll,
target,
visual.width,
visual.height,
visual.scale,
keyboard.open,
])
if (trigger === lastCorrectionTrigger.current) return
lastCorrectionTrigger.current = trigger
if (!correctScroll || !target) return
const delta = correctionToReveal(target, visual)
if (Math.abs(delta) > 1) window.scrollBy({ top: delta, behavior: 'instant' })
}, [correctScroll, target, visual, keyboard.open])
function placeTarget() {
if (!visual) return
setTarget({
left: visual.pageLeft + Math.max(0, (visual.width - 20) / 2),
top: visual.pageTop + Math.max(0, visual.height - 48),
width: 20,
height: 20,
})
}
return (
<section className="logic-demo" aria-label="Coordinate visibility">
<p role="status" data-testid="target-visible">
Target visible?{' '}
<strong>{visible === null ? 'Place a target' : visible ? 'YES' : 'NO'}</strong>
</p>
<p>
A selected 20 × 20 document-coordinate target. Place it near the visible bottom, then open
the keyboard, scroll or zoom. Its document coordinates stay fixed.
</p>
<button type="button" onClick={placeTarget} disabled={!ready || !visual}>
Place target near visible bottom
</button>
<div className="geometry-input coordinate-input">
<label htmlFor={inputId}>Focus to test target visibility</label>
<input id={inputId} placeholder="Open the keyboard near this test…" autoComplete="off" />
<p>
Keep the target selected while the actual keyboard changes the viewport. This field is not
stored or sent.
</p>
</div>
{target && (
<>
<p data-testid="target-coordinates">
Document target: x {formatGeometry(target.left)}, y {formatGeometry(target.top)} CSS px.
</p>
<p>
Any positive rectangle overlap counts as visible. Other elements, clipping and floating
keyboards can still cover it.
</p>
<button
type="button"
onClick={() => {
setCorrectScroll(false)
setTarget(null)
}}
>
Clear target
</button>
</>
)}
<details>
<summary>Keyboard-aware scroll correction</summary>
<p>
The application recalculates a vertical scroll correction when viewport geometry changes.
CSS cannot call a selection algorithm. Try the default observation first; opt in only to
test correction.
</p>
<p data-testid="scroll-correction">
Suggested vertical correction:{' '}
{correction === null ? 'Pending' : `${formatGeometry(correction)} px`}
</p>
<label className="lab-scroll-mode">
<input
type="checkbox"
checked={correctScroll}
onChange={(event) => setCorrectScroll(event.target.checked)}
/>
Automatically reveal the selected target
</label>
<p>
Correction runs after selection, viewport-size, scale or keyboard-state changes, not
ordinary scrolling. Opt-in page scrolling can be clamped at document boundaries. It does
not manage focus, defeat native browser panning, or guarantee visibility inside nested
scrollers.
</p>
</details>
{ready &&
target &&
createPortal(
<div
className="document-target"
data-testid="document-target"
aria-hidden="true"
style={{
left: target.left,
top: target.top,
width: target.width,
height: target.height,
}}
/>,
document.body,
)}
</section>
)
}
import type { VisualViewportState } from '@nipe-solutions/react-viewport'
// Application helpers, not package API. Both rectangles use document CSS pixels.
export interface DocumentTarget {
readonly left: number
readonly top: number
readonly width: number
readonly height: number
}
export function intersectsDocumentViewport(target: DocumentTarget, visual: VisualViewportState) {
return (
visual.width > 0 &&
visual.height > 0 &&
target.width > 0 &&
target.height > 0 &&
target.left + target.width > visual.pageLeft &&
target.left < visual.pageLeft + visual.width &&
target.top + target.height > visual.pageTop &&
target.top < visual.pageTop + visual.height
)
}
export function correctionToReveal(target: DocumentTarget, visual: VisualViewportState) {
if (target.top < visual.pageTop) return target.top - visual.pageTop
return Math.max(0, target.top + target.height - (visual.pageTop + visual.height))
}
// Optional canvas hit-testing tolerance in document CSS pixels. This is not
// devicePixelRatio, physical pixels, or a responsive UI breakpoint.
export function zoomTolerance(scale: number) {
return scale > 0 && Number.isFinite(scale) ? 12 / scale : null
}
'use client'
import { formatGeometry } from './format-geometry'
import { useViewport } from '@nipe-solutions/react-viewport'
import { zoomTolerance } from './geometry-logic'
export function ZoomLogic() {
const { visual, safeArea, ready } = useViewport()
const tolerance = visual ? zoomTolerance(visual.scale) : null
return (
<section className="logic-demo" aria-label="Zoom-aware tool">
<p data-testid="zoom-tolerance">
Optional annotation hit tolerance:{' '}
<strong>
{tolerance === null ? 'Pending' : `${formatGeometry(tolerance)} document CSS px`}
</strong>
</p>
<p>
A canvas tool can convert a 12 CSS-pixel tolerance at unit scale using{' '}
<code>12 / visual.scale</code>. This is an algorithm input, not a smaller button or text
size.
</p>
<p>
Pinch zoom is user viewport geometry, not responsive design. Essential controls remain
available at every scale.
</p>
<p>
Safe-area input for a custom drawing surface:{' '}
{ready
? `top ${formatGeometry(safeArea.top)}, right ${formatGeometry(safeArea.right)}, bottom ${formatGeometry(safeArea.bottom)}, left ${formatGeometry(safeArea.left)} CSS px`
: 'Pending'}
.
</p>
<p>
Use these raw protected-edge values only after mapping the drawing surface into the same
coordinate system. For ordinary padding, use <code>env()</code> directly.
</p>
</section>
)
}