Rendering decisions
Adapt the optional content React renders to the visible region. A virtualizer or custom rendering engine needs a numeric height.
Explore the application example →React Viewport is for application logic that needs viewport geometry as data. It is not a replacement for 100dvh, safe-area CSS or normal responsive layout.
100dvh, env(), media and container queries.window.visualViewport?.height directly.useViewport() may be useful for a coherent snapshot across consumers.| Problem | CSS | React Viewport |
|---|---|---|
| Full-height layout | Preferred: 100dvh | Unnecessary |
| Safe-area padding | Preferred: env() | Unnecessary |
| Responsive styling | Preferred: queries | Unnecessary |
| Sticky / fixed footer | Usually sufficient | Usually unnecessary |
| Visual dimensions and offsets in React logic | Not a reactive JS snapshot | Yes |
| Pinch zoom in coordinate algorithms | Not a JS algorithm input | visual.scale |
| Keyboard occlusion as JS state | Not a React state source | keyboard |
| Safe-area insets in JS calculations | env() needs measurement | safeArea |
| JS rendering budget / coordinate visibility | Cannot make the JS decision | Geometry inputs for your algorithm |
This compares CSS with a React abstraction. Native browser APIs already expose geometry; the library does not invent exclusive access to it.
If all you need is one visualViewport.height read, you probably should. React Viewport adds useSyncExternalStore integration, shared subscriptions per Window, an SSR-safe snapshot, layout and visual geometry, measured safe areas and a conservative keyboard abstraction.
A CSS-variable bridge can share that same normalized state with styles. It is unnecessary for recreating ordinary env() padding. Read the store architecture.
What it does not do: replace CSS, move UI automatically, manage focus, detect devices, universally expose a physical keyboard rectangle, or solve all mobile-browser quirks.
Adapt the optional content React renders to the visible region. A virtualizer or custom rendering engine needs a numeric height.
Explore the application example →Test whether editor, canvas or annotation coordinates intersect the visual viewport.
Explore the application example →Convert optional tool tolerances for coordinate-sensitive hit testing as visual scale changes.
Explore the application example →Recompute selection visibility or scroll correction when the visible region changes.
Explore the application example →Use protected-edge measurements in custom drawing calculations, after mapping coordinate systems.
Explore the application example →This live example changes which results React creates. CSS owns their layout.
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.
'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>
)
}
Open the Live Geometry Lab → Focus an input, scroll, rotate or zoom to observe real browser changes.
This composer requires no React Viewport. Browser-native layout is the recommended approach when it meets your requirements. If it solves your problem, stop here.
One browser store per Window → shared subscription → useSyncExternalStore → consistent React snapshots.
npm install @nipe-solutions/react-viewport
import { useViewport } from '@nipe-solutions/react-viewport'
export function ViewportReadout() {
const viewport = useViewport()
if (!viewport.ready || viewport.visual === null) {
return <p>Measuring viewport…</p>
}
return (
<p>
Visible: {viewport.visual.width} × {viewport.visual.height}
</p>
)
}The layout reference: window.innerWidth and window.innerHeight.
The visible region, with layout-relative offsets, document-relative page coordinates and zoom scale.
Raw bottom occlusion and protected-edge measurements. Application policy decides whether and how to combine them.
Explore the concepts and simulator
Before “Shifted keyboard”: visual.offsetTop moves the visible region. Bottom occlusion is Math.max(0, layoutHeight - (visualOffsetTop + visualHeight)). Shrinkage alone is not proof of a keyboard.
Compare what changes in each browser state and why. Layout, visual viewport, keyboard occlusion, and safe area stay separate.
Math.max(0, layoutHeight - (visualOffsetTop + visualHeight))'use client'
import { useMemo, useState } from 'react'
import { CodeBlock } from './CodeBlock'
import { useViewport, type ViewportState } from '@nipe-solutions/react-viewport'
import {
createScenarioGeometry,
defaultCustomGeometry,
geometryScenarios,
getSimulationBottomOcclusion,
validateCustomGeometry,
type CustomGeometryInput,
type GeometryModel,
type GeometryScenario,
} from './geometry-simulation'
type DemoMode = 'live' | GeometryScenario
const scenarioOrder: readonly GeometryScenario[] = [
'normal',
'browser-chrome',
'soft-keyboard',
'shifted-keyboard',
'zoom',
'custom',
]
const customFields: ReadonlyArray<{
key: keyof CustomGeometryInput
label: string
min?: number
step?: number
}> = [
{ key: 'layoutWidth', label: 'Layout viewport width', min: 0 },
{ key: 'layoutHeight', label: 'Layout viewport height', min: 0 },
{ key: 'visualWidth', label: 'Visual viewport width', min: 0 },
{ key: 'visualHeight', label: 'Visual viewport height', min: 0 },
{ key: 'visualOffsetTop', label: 'Visual viewport offset top' },
{ key: 'visualOffsetLeft', label: 'Visual viewport offset left' },
{ key: 'visualScale', label: 'Visual viewport scale', min: 0, step: 0.1 },
{ key: 'keyboardHeight', label: 'Keyboard occlusion', min: 0 },
{ key: 'safeTop', label: 'Safe area top', min: 0 },
{ key: 'safeRight', label: 'Safe area right', min: 0 },
{ key: 'safeBottom', label: 'Safe area bottom', min: 0 },
{ key: 'safeLeft', label: 'Safe area left', min: 0 },
]
export function GeometryDemo({ code }: { readonly code: string }) {
const viewport = useViewport()
const [mode, setMode] = useState<DemoMode>('live')
const [custom, setCustom] = useState<CustomGeometryInput>(defaultCustomGeometry)
const realGeometry = geometryFromViewport(viewport)
const geometry = useMemo(
() => (mode === 'live' ? realGeometry : createScenarioGeometry(mode, custom)),
[custom, mode, realGeometry],
)
const customWarning =
mode === 'custom' && geometry !== null ? validateCustomGeometry(geometry) : null
const bottomOcclusion =
geometry === null
? null
: getSimulationBottomOcclusion({
layoutHeight: geometry.layout.height,
visualHeight: geometry.visual.height,
visualOffsetTop: geometry.visual.offsetTop,
})
const modeLabel =
mode === 'live'
? geometry === null
? 'Initializing viewport measurement'
: 'Live browser geometry'
: `Geometry simulator · ${mode === 'custom' ? 'Custom' : geometryScenarios[mode].label}`
const keyboardStatus =
geometry === null
? 'Keyboard status: pending'
: `Keyboard status: ${geometry.keyboard.open ? 'open' : 'closed'}`
return (
<section className="geometry-demo" aria-labelledby="geometry-heading">
<header className="geometry-demo__header">
<div>
<span className="mode-badge">{mode === 'live' ? 'LIVE BROWSER' : 'SIMULATION'}</span>
<h2 id="geometry-heading">One screen, four measured regions</h2>
<p>
Compare what changes in each browser state and why. Layout, visual viewport, keyboard
occlusion, and safe area stay separate.
</p>
</div>
<output
className="mode-indicator"
data-state={geometry === null ? 'pending' : 'ready'}
data-testid="geometry-mode"
>
{modeLabel}
</output>
</header>
<div className="geometry-demo__body">
<GeometryPlane geometry={geometry} />
<GeometryReadout geometry={geometry} bottomOcclusion={bottomOcclusion} mode={mode} />
</div>
<div className="geometry-formula">
<span>Bottom occlusion</span>
<code>Math.max(0, layoutHeight - (visualOffsetTop + visualHeight))</code>
</div>
<fieldset className="scenario-controls">
<legend>View</legend>
<div className="scenario-selector" aria-label="Geometry view">
<button type="button" aria-pressed={mode === 'live'} onClick={() => setMode('live')}>
Live browser
</button>
{scenarioOrder.map((scenarioName) => (
<button
type="button"
aria-pressed={mode === scenarioName}
key={scenarioName}
onClick={() => setMode(scenarioName)}
>
{scenarioName === 'custom' ? 'Custom' : geometryScenarios[scenarioName].label}
</button>
))}
</div>
<p className="scenario-description" data-testid="scenario-description">
{mode === 'live'
? 'Measured by the library in your current browser. Simulator controls do not change it.'
: mode === 'custom'
? 'Custom geometry can represent states that do not correspond to a typical real browser configuration.'
: geometryScenarios[mode].description}{' '}
<strong data-testid="scenario-keyboard-status">{keyboardStatus}</strong>
</p>
{mode === 'custom' ? (
<div className="custom-geometry-controls">
{customFields.map((field) => (
<label key={field.key}>
<span>{field.label}</span>
<input
type="number"
min={field.min}
step={field.step ?? 1}
value={custom[field.key]}
onChange={(event) =>
setCustom((current) => ({
...current,
[field.key]: Number(event.target.value),
}))
}
/>
<small>{field.key === 'visualScale' ? '×' : 'px'}</small>
</label>
))}
</div>
) : null}
{customWarning === null ? null : (
<p className="geometry-warning" role="status" data-testid="custom-warning">
{customWarning}
</p>
)}
</fieldset>
<CodeBlock collapsible label="GeometryDemo.tsx · actual source" code={code} />
</section>
)
}
function GeometryPlane({ geometry }: { readonly geometry: GeometryModel | null }) {
const summary =
geometry === null
? 'Nested viewport coordinate plane awaiting its first browser measurement'
: `Layout viewport ${round(geometry.layout.width)} by ${round(geometry.layout.height)}. Visual viewport ${round(geometry.visual.width)} by ${round(geometry.visual.height)} at offset ${round(geometry.visual.offsetLeft)}, ${round(geometry.visual.offsetTop)}. Scale ${round(geometry.visual.scale)}. Keyboard occlusion ${round(geometry.keyboard.height)} pixels.`
return (
<figure className="coordinate-plane" role="img" aria-label={summary}>
<svg className="coordinate-plane__sizer" width="100vw" height="100vh" aria-hidden="true" />
{geometry === null ? (
<div className="coordinate-plane__pending" aria-hidden="true">
<span>Awaiting client geometry</span>
</div>
) : (
<>
<svg
className="coordinate-drawing"
viewBox={`0 0 ${geometry.layout.width} ${geometry.layout.height}`}
preserveAspectRatio="xMidYMid meet"
aria-hidden="true"
>
<rect
className="layout-plane"
x="0"
y="0"
width={geometry.layout.width}
height={geometry.layout.height}
/>
<rect
className="visual-plane"
x={geometry.visual.offsetLeft}
y={geometry.visual.offsetTop}
width={geometry.visual.width}
height={geometry.visual.height}
/>
<rect
className="safe-band safe-band--top"
data-testid="safe-top"
x={geometry.visual.offsetLeft}
y={geometry.visual.offsetTop}
width={geometry.visual.width}
height={geometry.safeArea.top}
/>
<rect
className="safe-band safe-band--right"
data-testid="safe-right"
x={geometry.visual.offsetLeft + geometry.visual.width - geometry.safeArea.right}
y={geometry.visual.offsetTop}
width={geometry.safeArea.right}
height={geometry.visual.height}
/>
<rect
className="safe-band safe-band--bottom"
data-testid="safe-bottom"
x={geometry.visual.offsetLeft}
y={geometry.visual.offsetTop + geometry.visual.height - geometry.safeArea.bottom}
width={geometry.visual.width}
height={geometry.safeArea.bottom}
/>
<rect
className="safe-band safe-band--left"
data-testid="safe-left"
x={geometry.visual.offsetLeft}
y={geometry.visual.offsetTop}
width={geometry.safeArea.left}
height={geometry.visual.height}
/>
{geometry.keyboard.open ? (
<rect
className="keyboard-plane"
data-testid="keyboard-region"
x="0"
y={geometry.layout.height - geometry.keyboard.height}
width={geometry.layout.width}
height={geometry.keyboard.height}
/>
) : null}
</svg>
<span className="plane-label plane-label--layout" aria-hidden="true">
layout
</span>
<span className="plane-label plane-label--visual" aria-hidden="true">
visual
</span>
{geometry.keyboard.open ? (
<span className="plane-label plane-label--keyboard" aria-hidden="true">
keyboard occlusion
</span>
) : null}
</>
)}
<figcaption>Origin 0,0 · one unit equals one CSS pixel before diagram scaling</figcaption>
</figure>
)
}
function GeometryReadout({
geometry,
bottomOcclusion,
mode,
}: {
readonly geometry: GeometryModel | null
readonly bottomOcclusion: number | null
readonly mode: DemoMode
}) {
const derived = mode === 'soft-keyboard' || mode === 'shifted-keyboard'
return (
<dl className="geometry-readout" aria-live="polite">
<Readout
label="Layout viewport"
kind="layout"
value={
geometry === null ? 'Pending' : formatSize(geometry.layout.width, geometry.layout.height)
}
/>
<Readout
label="Visual viewport"
kind="visual"
value={geometry === null ? 'Pending' : `${round(geometry.visual.height)} px`}
detail={
geometry === null
? undefined
: `${round(geometry.visual.width)} wide · offset ${round(geometry.visual.offsetLeft)}, ${round(geometry.visual.offsetTop)} · scale ${geometry.visual.scale.toFixed(2)}`
}
testId="visual-height"
/>
<Readout
label="Safe area"
kind="safe"
value={
geometry === null
? 'Pending'
: `${geometry.safeArea.top} / ${geometry.safeArea.right} / ${geometry.safeArea.bottom} / ${geometry.safeArea.left} px`
}
/>
<Readout
label="Bottom occlusion"
kind="keyboard"
value={bottomOcclusion === null ? 'Pending' : `${round(bottomOcclusion)} px`}
testId="bottom-occlusion"
/>
<Readout
label="Keyboard occlusion"
kind="keyboard"
value={geometry === null ? 'Pending' : `${round(geometry.keyboard.height)} px`}
detail={
derived
? 'Derived from visual geometry'
: geometry?.keyboard.open
? 'Custom value'
: 'No keyboard inferred'
}
testId="keyboard-height"
/>
</dl>
)
}
function Readout({
label,
kind,
value,
detail,
testId,
}: {
readonly label: string
readonly kind: 'layout' | 'visual' | 'safe' | 'keyboard'
readonly value: string
readonly detail?: string | undefined
readonly testId?: string | undefined
}) {
return (
<div>
<dt>
<i className={`legend-swatch legend-swatch--${kind}`} />
{label}
</dt>
<dd>
<span data-testid={testId}>{value}</span>
{detail === undefined ? null : <small>{detail}</small>}
</dd>
</div>
)
}
function geometryFromViewport(viewport: ViewportState): GeometryModel | null {
if (!viewport.ready || viewport.layout === null || viewport.visual === null) return null
return {
layout: viewport.layout,
visual: viewport.visual,
safeArea: viewport.safeArea,
keyboard: viewport.keyboard,
}
}
function formatSize(width: number, height: number): string {
return `${round(width)} × ${round(height)} px`
}
function round(value: number): number {
return Math.round(value * 100) / 100
}
Automated evidence. Deterministic Chromium, Firefox and WebKit tests cover package geometry and website behavior.
Physical-device status. iPhone Safari and Android Chrome geometry testing remains pending. Automation is not physical keyboard or pinch-zoom verification.