React Native WebView that auto-sizes to match its HTML content—whether you load local HTML or full external websites—without manual measurements, timers, or layout flicker.
Important
⚡️ SizedWebView keeps the parent scroll view in charge by disabling the inner WebView scroll and syncing height changes via a lightweight bridge.
Tip
💡 Works out-of-the-box with dynamic CMS pages, FAQs, marketing landers, local HTML snippets, or full external sites.
Three defaults changed in the 1.1.x line. Each is a one-line migration:
- Named import only. The default export was removed to keep tree-shaking predictable across bundlers.
- import SizedWebView from 'react-native-sized-webview'; + import { SizedWebView } from 'react-native-sized-webview';
originWhitelistnow defaults to['http://*', 'https://*']. Standard HTTP(S) navigation keeps working; non-web schemes (file:,javascript:,data:,intent:) are blocked by default. Tighten it for production if you only load a specific origin:<SizedWebView + originWhitelist={['https://your-trusted-domain.com']} source={{ uri: 'https://your-trusted-domain.com/page' }} />javaScriptEnabledis now respected. Passingfalsedisables auto-sizing; the container falls back tominHeight(orcontainerStyle.height). This unblocks rendering static HTML on iOS 26 (#3).
- 📐 Wrapper-based measurement keeps the WebView content in a dedicated container, so height always reflects the real DOM footprint.
- 🚀 Modern pipeline powered by
ResizeObserver,MutationObserver,visualViewport, and font-load events with graceful fallbacks. - 🖼 Media aware: images, iframes, and videos schedule immediate + next-frame re-measures as soon as they finish loading.
- 🧼 Auto-prunes trailing
<br>/empty<p>tags that CMS editors often append, eliminating phantom spacing. - 🛡️ Sanity guard clamps runaway heights and retries with the last good value, so flaky pages never lock your layout.
- 🧵 Keeps the WebView scroll-disabled so outer
ScrollViews and gesture handlers stay silky smooth. - 🎨 Transparent background by default; style the container however you like.
- ⚙️ Friendly API with
minHeight,containerStyle,loadingContainerStyle, andonHeightChangecallbacks. - 🌲 ESM-first build, fully typed,
sideEffects: falsefor optimal tree shaking. - 📱 Verified on iOS, Android, and Expo Go out of the box.
yarn add react-native-sized-webview react-native-webview
# or
npm install react-native-sized-webview react-native-webviewNo native steps are needed beyond the upstream react-native-webview dependency.
import { SizedWebView } from 'react-native-sized-webview';
const Article = () => (
<SizedWebView
minHeight={180}
source={{
html: `
<html>
<body>
<h1>Privacy policy</h1>
<p>Generated by your CMS and sized automatically ✨</p>
</body>
</html>
`,
}}
containerStyle={{ borderRadius: 12, overflow: 'hidden' }}
onHeightChange={(height) => console.log('content height', height)}
/>
);yarn
yarn example ios # or yarn example androidThe example showcases four scenarios the auto-sizing pipeline must handle correctly:
- Local HTML demo — toggle a switch to mutate the document and watch the WebView re-size live.
- Remote site picker — swap between Marvel, NFL, Google, Wikipedia, and The Verge to verify CMS-driven layouts resolve cleanly.
- Custom Google Font — a local HTML page that imports the
Lobsterfont over the network. The WebView hangs briefly while the font downloads, then the bridge re-measures viadocument.fonts.loadingdoneand snaps to the final height with no clipping. - Long-form article — a CMS-style payload with lazy images and trailing margins that exercises the multi-source measurement path (
scrollHeight+getBoundingClientRect().bottom + computedMarginBottom).
The demo is also wired up with babel-plugin-react-compiler so you can see how the library composes inside a React Compiler–enabled app.
Note
🧪 The demo is built with Expo; swap any uri to test your own pages instantly.
| Prop | Type | Default | Description |
|---|---|---|---|
minHeight |
number |
0 |
Minimum height (dp) applied to the container. When 0, the container is unsized until the first measurement arrives (avoids layout flicker and the iOS 26 WKWebView 1px feedback loop). |
containerStyle |
StyleProp<ViewStyle> |
— | Styles applied to the wrapping View. Use it for padding, borders, or shadows. Do not set height — it is managed by the hook. |
loadingContainerStyle |
StyleProp<ViewStyle> |
— | Styles applied to the wrapping View only while height is still being measured (i.e. while the bridge has not yet reported a value). Use { flex: 1 } inside a ScrollView with contentContainerStyle={{ flexGrow: 1 }} so the native activity indicator is never clipped. Dropped automatically once the first measurement commits. |
onHeightChange |
(height: number) => void |
— | Callback fired whenever a new height is committed. Great for analytics or debugging. Never fires for invalid or out-of-range values. |
originWhitelist |
string[] |
['http://*', 'https://*'] |
Origins the WebView is allowed to navigate to. Blocks non-web schemes (file:, javascript:, data:, intent:) by default. Tighten it to a specific origin list for stricter environments. |
javaScriptEnabled |
boolean |
true |
When false, the auto-height bridge is not injected and the container falls back to minHeight. Use for static HTML that doesn't need JS. |
...WebViewProps |
— | — | All remaining props are forwarded to the underlying react-native-webview. User-supplied values always win over the defaults above. |
Note
🧩 scrollEnabled defaults to false so sizing remains deterministic. Only enable it if the WebView should manage its own scroll.
When minHeight is 0 (the default), the container has no height until the bridge reports the first measurement. Inside a ScrollView this means the native loading spinner is rendered in a 0 dp frame and gets clipped.
Fix it with loadingContainerStyle:
<ScrollView contentContainerStyle={{ flexGrow: 1 }}>
<SizedWebView
source={{ uri: 'https://example.com/article' }}
loadingContainerStyle={{ flex: 1 }}
containerStyle={{ borderRadius: 12, overflow: 'hidden' }}
renderLoading={() => <ActivityIndicator size="large" style={{ flex: 1 }} />}
/>
</ScrollView>- During loading: container is
[{ flex: 1 }, containerStyle]— fills the scroll area, spinner is fully visible. - After measurement: container becomes
[{ height: N }, containerStyle]— shrinks to content height,loadingContainerStyleis dropped.
- Namespaced message protocol. The injected bridge posts values prefixed with
__RN_SIZED_WV__:and the hook rejects everything else, so your ownonMessagetraffic cannot accidentally (or maliciously) mutate the container height. - Safe-by-default origin list.
originWhitelistdefaults to['http://*', 'https://*']— HTTP(S) navigation works, but non-web schemes (file:,javascript:,data:,intent:) are blocked. Tighten to a specific origin for production apps that only load trusted content. - Respected JS toggle.
javaScriptEnabled={false}is honored; the bridge is not injected when you disable scripts. - Clamped heights. A shared
MAX_COMMITTED_HEIGHT(120 000 dp) caps both sides of the bridge to defend against runaway values from broken markup or third-party scripts. - No native code. This package ships only JavaScript/TypeScript — there is no Objective-C, Swift, Java, or Kotlin to audit.
- Warning. Never interpolate untrusted strings into
injectedJavaScriptorinjectedJavaScriptBeforeContentLoaded. Anything passed there runs inside the WebView page context and can reach React Native throughwindow.ReactNativeWebView.
- Trailing
<br>and empty<p>tags are stripped automatically so CMS exports don’t leave phantom padding. - Images, iframes, and videos reschedule measurements the moment they finish loading—perfect for hero images at the end of an article.
- Wrapper rebuild + fallback timers keep measurements stable even if the remote page rewrites the entire DOM after load.
- Measurements above safe bounds are retried and then clamped to the last known good height, protecting against broken markup or third-party scripts.
The injected bridge is idempotent and may run at both injectedJavaScriptBeforeContentLoaded and injectedJavaScript (the second injection is a no-op when the first already published the global handle, but covers iOS inline source.html cases where the early hook is skipped). It turns the WebView into a self-measuring component:
-
Measures the page in place. The bridge does not re-parent
<body>children into a wrapper and does not inject inline styles; it reads layout directly from the document's existing flow so framework- or CMS-generated DOM stays untouched (preserving margin collapse, author CSS, and any structure the page expects). -
Multi-source measurement (the production-grade fix). Each measurement is the
Math.maxof authoritative layout sources:document.body.scrollHeight/document.body.offsetHeight— primary document metrics for normal block flow.document.documentElement.scrollHeight/document.documentElement.offsetHeight— backstop when frameworks stylehtmldirectly or when the root box exceedsbody.- The last non-inert in-flow child's
getBoundingClientRect().bottom + computedMarginBottom— catches margin-collapse, late image reflow, and end-of-document cases where scroll metrics momentarily under-report on iOS WKWebView.
Inert siblings (
SCRIPT,STYLE,META,LINK,TITLE,HEAD,NOSCRIPT) and out-of-flow positions (fixed/sticky/absolute) are skipped during the last-in-flow-child walk so they never short-circuit the probe. -
Bootstrap-grace adaptive fallback. A timer re-arms itself only while either condition holds:
pendingLoads > 0(an image / iframe / video is still loading), orDate.now() - bootstrapAt < BOOTSTRAP_GRACE_MS(5 s grace window from script start, refreshed onmarkLoading, fontloadingdone, and externalstate.refreshcalls).
Once both expire only signal-driven re-measures (mutation, resize, font, viewport, message) trigger work — the steady-state CPU cost is zero.
-
Signal-driven observers.
MutationObserver,ResizeObserver,visualViewport, font-load events, and a namespacedpostMessagechannel each schedule a single rAF-batched measure. -
Rendered through
useAutoHeight. Heights are validated, clamped toMAX_COMMITTED_HEIGHT(120 000 dp), diff-thresholded, and committed at most once per animation frame. -
Public surface stays small. The package exports the bridge string, the hook, and helpers individually, so you can build bespoke wrappers (e.g. around a custom WebView component) without forking.
| Scenario | Plain react-native-webview |
react-native-sized-webview |
|---|---|---|
| Initial render layout shifts | Requires timers / manual height guesswork | Zero shifts; height resolved before paint |
| React state updates on content change | Manual postMessage plumbing |
Automatic bridge with RAF + debounce guard |
Scrolling in parent ScrollView |
Nested scroll can fight gestures | Parent retains full momentum and gesture priority |
Benchmarks were captured on CMS articles up to 3k words in a 60 fps RN dev build. The bridge batches DOM mutations so even long documents resize without thrashing the JS thread.
Every hot path is designed to run at its theoretical complexity floor — no allocations in steady state, no repeated DOM walks, and at most one forced layout per measurement frame.
| Hot path | Complexity | Notes |
|---|---|---|
Message parsing (useAutoHeight) |
O(1) | Namespaced-prefix check, single Number() coerce, constant-bound clamp. |
| Height commit (rAF-batched) | O(1) amortized per frame | Sub-pixel diffs are dropped; at most one React render per animation frame. |
| DOM mutation callback | O(added nodes) | Scans only each mutation's addedNodes, not the whole tree. Media elements are deduped via a WeakSet. |
measureHeight |
O(k), single forced reflow | Math.max of scrollHeight/offsetHeight (constant) + a getBoundingClientRect() on the last non-inert child (k = number of trailing inert siblings, typically 0–2). |
| Trailing-node prune DFS | Runs only when the DOM is dirty | A mutation-driven dirty flag skips the recursive walk on resize / font / viewport ticks when nothing structural changed. |
| Late web-font reflow | Bootstrap-grace + adaptive | Font loadingdone refreshes the bootstrap window; the fallback timer keeps re-arming with 1.5× backoff until layout settles, then disarms automatically. |
The net effect: resize storms, font loads, and viewport changes cost a single layout flush per frame — nothing more. Paired with sideEffects: false and named-only exports, the library stays fast and small in the final bundle. The library is also compiled with babel-plugin-react-compiler, so memoization is automatic and free of stale closures.
- Ships as ESM-first (
lib/module/**) with"sideEffects": false. - Named exports only — no default export — so every bundler can drop what you don't use.
- Importing only
useAutoHeightorcomposeInjectedScriptdoes not pull the injected-bridge string into your bundle.
yarn testJest runs with full coverage collection and enforces 100% statements, branches, functions, and lines across the TypeScript source.
yarn
yarn lint
yarn typecheck
yarn test --watch=false
yarn example ios # or yarn example androidThis project uses react-native-builder-bob for packaging and release-it for publishing.
Caution
🔬 Before submitting PRs that touch the bridge script, please test the example app on both iOS and Android to catch edge cases with interactive embeds.
MIT © Mateus Andrade