Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Use Expo DOM components to run web code in a webview on native and as-is on web. Migrate web code to native incrementally.
| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-08 | ✗→✓ | ▲ Improved | — | — |
| case-10 | ✗→✓ | ▲ Improved | — | — |
| case-05 | ✗→✓ | ▲ Improved | — | — |
| case-01 | ✗→✓ | ▲ Improved | — | — |
| case-04 | ✗→✓ | ▲ Improved | — | — |
DOM components allow web code to run verbatim in a webview on native platforms while rendering as-is on web. This enables using web-only libraries like recharts, react-syntax-highlighter, or any React web library in your Expo app without modification.
Use DOM components when you need:
Avoid DOM components when:
_layout files cannot be DOM componentsCreate a new file with the 'use dom'; directive at the top:
tsx// components/WebChart.tsx "use dom"; export default function WebChart({ data, }: { data: number[]; dom: import("expo/dom").DOMProps; }) { return ( <div style={{ padding: 20 }}> <h2>Chart Data</h2> <ul> {data.map((value, i) => ( <li key={i}>{value}</li> ))} </ul> </div> ); }
'use dom'; directive at the top of the filedom PropEvery DOM component receives a special dom prop for webview configuration. Always type it in your props:
tsx"use dom"; interface Props { content: string; dom: import("expo/dom").DOMProps; } export default function MyComponent({ content }: Props) { return <div>{content}</div>; }
dom Prop Optionstsx// Disable body scrolling <DOMComponent dom={{ scrollEnabled: false }} /> // Flow under the notch (disable safe area insets) <DOMComponent dom={{ contentInsetAdjustmentBehavior: "never" }} /> // Control size manually <DOMComponent dom={{ style: { width: 300, height: 400 } }} /> // Combine options <DOMComponent dom={{ scrollEnabled: false, contentInsetAdjustmentBehavior: "never", style: { width: '100%', height: 500 } }} />
Pass async functions as props to expose native functionality to the DOM component:
tsx// app/index.tsx (native) import { Alert } from "react-native"; import DOMComponent from "@/components/dom-component"; export default function Screen() { return ( <DOMComponent showAlert={async (message: string) => { Alert.alert("From Web", message); }} saveData={async (data: { name: string; value: number }) => { // Save to native storage, database, etc. console.log("Saving:", data); return { success: true }; }} /> ); }
tsx// components/dom-component.tsx "use dom"; interface Props { showAlert: (message: string) => Promise<void>; saveData: (data: { name: string; value: number; }) => Promise<{ success: boolean }>; dom?: import("expo/dom").DOMProps; } export default function DOMComponent({ showAlert, saveData }: Props) { const handleClick = async () => { await showAlert("Hello from the webview!"); const result = await saveData({ name: "test", value: 42 }); console.log("Save result:", result); }; return <button onClick={handleClick}>Trigger Native Action</button>; }
DOM components can use any web library:
tsx// components/syntax-highlight.tsx "use dom"; import SyntaxHighlighter from "react-syntax-highlighter"; import { docco } from "react-syntax-highlighter/dist/esm/styles/hljs"; interface Props { code: string; language: string; dom?: import("expo/dom").DOMProps; } export default function SyntaxHighlight({ code, language }: Props) { return ( <SyntaxHighlighter language={language} style={docco}> {code} </SyntaxHighlighter> ); }
tsx// components/chart.tsx "use dom"; import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, } from "recharts"; interface Props { data: Array<{ name: string; value: number }>; dom: import("expo/dom").DOMProps; } export default function Chart({ data }: Props) { return ( <LineChart width={400} height={300} data={data}> <CartesianGrid strokeDasharray="3 3" /> <XAxis dataKey="name" /> <YAxis /> <Tooltip /> <Line type="monotone" dataKey="value" stroke="#8884d8" /> </LineChart> ); }
CSS imports must be in the DOM component file since they run in isolated context:
tsx// components/styled-component.tsx "use dom"; import "@/styles.css"; // CSS file in same directory export default function StyledComponent({ dom, }: { dom: import("expo/dom").DOMProps; }) { return ( <div className="container"> <h1 className="title">Styled Content</h1> </div> ); }
Or use inline styles / CSS-in-JS:
tsx"use dom"; const styles = { container: { padding: 20, backgroundColor: "#f0f0f0", }, title: { fontSize: 24, color: "#333", }, }; export default function StyledComponent({ dom, }: { dom: import("expo/dom").DOMProps; }) { return ( <div style={styles.container}> <h1 style={styles.title}>Styled Content</h1> </div> ); }
The expo-router <Link /> component and router API work inside DOM components:
tsx"use dom"; import { Link, useRouter } from "expo-router"; export default function Navigation({ dom, }: { dom: import("expo/dom").DOMProps; }) { const router = useRouter(); return ( <nav> <Link href="/about">About</Link> <button onClick={() => router.push("/settings")}>Settings</button> </nav> ); }
These hooks don't work directly in DOM components because they need synchronous access to native routing state:
useLocalSearchParams()useGlobalSearchParams()usePathname()useSegments()useRootNavigation()useRootNavigationState()Solution: Read these values in the native parent and pass as props:
tsx// app/[id].tsx (native) import { useLocalSearchParams, usePathname } from "expo-router"; import DOMComponent from "@/components/dom-component"; export default function Screen() { const { id } = useLocalSearchParams(); const pathname = usePathname(); return <DOMComponent id={id as string} pathname={pathname} />; }
tsx// components/dom-component.tsx "use dom"; interface Props { id: string; pathname: string; dom?: import("expo/dom").DOMProps; } export default function DOMComponent({ id, pathname }: Props) { return ( <div> <p>Current ID: {id}</p> <p>Current Path: {pathname}</p> </div> ); }
Check if code is running in a DOM component:
tsx"use dom"; import { IS_DOM } from "expo/dom"; export default function Component({ dom, }: { dom?: import("expo/dom").DOMProps; }) { return <div>{IS_DOM ? "Running in DOM component" : "Running natively"}</div>; }
Prefer requiring assets instead of using the public directory:
tsx"use dom"; // Good - bundled with the component const logo = require("../assets/logo.png"); export default function Component({ dom, }: { dom: import("expo/dom").DOMProps; }) { return <img src={logo} alt="Logo" />; }
Import and use DOM components like regular components:
tsx// app/index.tsx import { View, Text } from "react-native"; import WebChart from "@/components/web-chart"; import CodeBlock from "@/components/code-block"; export default function HomeScreen() { return ( <View style={{ flex: 1 }}> <Text>Native content above</Text> <WebChart data={[10, 20, 30, 40, 50]} dom={{ style: { height: 300 } }} /> <CodeBlock code="const x = 1;" language="javascript" dom={{ scrollEnabled: true }} /> <Text>Native content below</Text> </View> ); }
| Platform | Behavior | | -------- | ----------------------------------- | | iOS | Rendered in WKWebView | | Android | Rendered in WebView | | Web | Rendered as-is (no webview wrapper) |
On web, the dom prop is ignored since no webview is needed.
| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-08 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-10 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-22 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-11 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-07 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-05 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-18 | pass→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-02 | pass→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-06 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-20 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-13 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-16 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-01 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-04 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-03 | pass→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-21 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-09 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-17 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-12 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-19 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-14 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-15 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted. The headline lift of +27 percentage points is the difference between those two pass rates over the 22 comparable cases.
The per-case answers from this run were removed by the retention sweep, so the case table below shows the verdicts without the text either arm produced. The counts above were recorded at the time and are unaffected. Answers are now kept for 180 days.
Other measured skills in the registry, with their headline benchmark lift.