Retool's legacy Custom Component (custom widget) runs HTML/CSS/JS inside an iframe. Pass data in via the model object (set in Inspector), access it with window.Retool.subscribe(). Send data back out with window.Retool.modelUpdate({key: value}). Trigger Retool queries from the widget with window.Retool.triggerQuery('queryName'). For complex React-based widgets, use the modern Custom Component Library instead.
| Fact | Value |
|---|---|
| Tool | Retool |
| Difficulty | Intermediate |
| Time required | 25-35 min |
| Compatibility | Retool Cloud and Self-hosted |
| Last updated | March 2026 |
Legacy Custom Component (Widget) Architecture
Retool's Custom Component (sometimes called a custom widget) embeds an HTML/CSS/JavaScript application inside a sandboxed iframe. This is the legacy approach — it predates the newer Custom Component Library which uses a React/CLI workflow. Despite being legacy, it remains widely used because it requires no build tooling: you write HTML directly in the Retool editor.
The communication bridge between Retool and the iframe works through two mechanisms: (1) Retool passes data into the iframe via the model object, which your JS reads using window.Retool.subscribe(). (2) The iframe sends data back to Retool using window.Retool.modelUpdate(), which updates the component's value that other Retool components can reference via {{ customComponent1.model.key }}.
The third communication path — triggering Retool queries from inside the widget — uses window.Retool.triggerQuery('queryName', { params }).
This tutorial covers the full lifecycle: creating the component, wiring data in and out, and deciding when to upgrade to the modern approach.
Prerequisites
- A Retool app in Editor mode
- Basic knowledge of HTML, CSS, and JavaScript
- Understanding of what you want the custom widget to do that built-in components cannot
- Optional: familiarity with React if planning to use the modern Custom Component Library
Step-by-step guide
Add a Custom Component to the canvas
In the Retool editor, click the + button (or drag from the component panel) to open the component picker. Search for 'Custom Component' — it appears under the Advanced or Custom section. Drag it onto the canvas and resize it. The Custom Component shows a placeholder until you add HTML content. In the Inspector panel, you'll see three key sections: HTML/CSS/JS editor, Model (for passing data in), and Properties (the component's output value).
Expected result: A Custom Component frame appears on the canvas showing 'Custom Component' placeholder text.
Write the iframe HTML content
Click the Custom Component to select it. In the Inspector, find the HTML/CSS/JS tab. This is where you write the content of the iframe. The iframe document has access to the window.Retool API. Write standard HTML with a <script> tag for JavaScript. Include the Retool bridge script at the top of your HTML — without it, window.Retool is undefined.
1<!-- Custom Component HTML (paste into Inspector → HTML/CSS/JS → HTML tab) -->2<!DOCTYPE html>3<html>4<head>5 <style>6 body {7 margin: 0;8 padding: 12px;9 font-family: Inter, sans-serif;10 font-size: 14px;11 }12 .widget-container {13 background: white;14 border-radius: 8px;15 padding: 16px;16 }17 .value-display {18 font-size: 24px;19 font-weight: 600;20 color: #7C3AED;21 }22 button {23 margin-top: 12px;24 padding: 8px 16px;25 background: #7C3AED;26 color: white;27 border: none;28 border-radius: 6px;29 cursor: pointer;30 }31 </style>32</head>33<body>34 <div class="widget-container">35 <p>Custom Widget — Current Value:</p>36 <div class="value-display" id="valueDisplay">—</div>37 <button onclick="handleButtonClick()">Send to Retool</button>38 </div>39 40 <script>41 // Subscribe to model updates from Retool42 window.Retool.subscribe(function(model) {43 // model contains the data passed in from Inspector → Model field44 document.getElementById('valueDisplay').textContent = 45 model.currentValue || 'No data';46 });47 48 function handleButtonClick() {49 // Send data back to Retool50 window.Retool.modelUpdate({51 selectedValue: document.getElementById('valueDisplay').textContent,52 clickedAt: new Date().toISOString()53 });54 }55 </script>56</body>57</html>Expected result: The Custom Component renders the HTML content inside the iframe frame on the canvas.
Configure the Model to pass data into the widget
In the Inspector panel with the Custom Component selected, find the Model section. This is a JSON object that Retool passes into the iframe. You can include static values or `{{ }}` expressions that reference other Retool components and queries. The model is available inside the iframe via the argument passed to window.Retool.subscribe(callback). Whenever a `{{ }}` expression in the model changes value (e.g., because a query re-ran), window.Retool.subscribe fires again with the new model.
1// Inspector → Model field (JSON object with {{ }} expressions):2{3 "currentValue": "{{ query1.data[0].value }}",4 "userName": "{{ current_user.fullName }}",5 "theme": "{{ darkMode.value ? 'dark' : 'light' }}",6 "tableData": "{{ query1.data }}",7 "selectedId": "{{ table1.selectedRow.id }}"8}Expected result: The model JSON with live values appears in the Inspector. Changes to referenced queries/components automatically flow into the widget.
Read the widget output in Retool with {{ customComponent1.model }}
When the widget calls window.Retool.modelUpdate({ key: value }), Retool updates the component's model object. Other Retool components can read these values using `{{ customComponent1.model.key }}`. For example, after modelUpdate({ selectedValue: 'foo', clickedAt: '2024-01-01' }), you can: - In a Text component: `{{ customComponent1.model.selectedValue }}` - In a query's WHERE clause: `WHERE id = {{ customComponent1.model.selectedId }}` - In an event handler's 'Only run when': `{{ customComponent1.model.selectedValue !== null }}`
1// Inside the iframe JavaScript:2window.Retool.modelUpdate({3 selectedValue: selectedItem.value,4 selectedLabel: selectedItem.label,5 isValid: selectedItem.value !== null6});78// In Retool, read the output:9// Text component: {{ customComponent1.model.selectedLabel }}10// Query parameter: {{ customComponent1.model.selectedValue }}11// Button hidden property: {{ !customComponent1.model.isValid }}Expected result: Values sent via modelUpdate() are immediately accessible as {{ customComponent1.model.* }} in any Retool component.
Trigger a Retool query from inside the widget
To trigger a Retool query from inside the custom widget (for example, when a user interacts with a custom chart and you want to run a database query), use window.Retool.triggerQuery(). Pass the query name as a string and optionally pass additional parameters. Note: The query must exist in the Retool app and be accessible (not resource-restricted in a way that prevents it from being called).
1// Inside the iframe JavaScript:2function handleRowSelection(rowData) {3 // First update the model with the selected data4 window.Retool.modelUpdate({5 selectedRowId: rowData.id,6 selectedRowName: rowData.name7 });8 9 // Then trigger a Retool query10 // The query can reference {{ customComponent1.model.selectedRowId }}11 window.Retool.triggerQuery('fetchRowDetails');12}1314// Trigger with additional params (passes to query's additionalScope):15window.Retool.triggerQuery('logUserAction', {16 action: 'widget_interaction',17 timestamp: Date.now()18});Expected result: The Retool query named 'fetchRowDetails' runs when the widget calls triggerQuery. Query results are available via fetchRowDetails.data.
Handle errors and loading states
Custom widgets run in sandboxed iframes with limited error visibility. Errors inside the iframe don't appear in Retool's debug panel. Use browser DevTools Console to debug — errors surface there. Common issues: window.Retool undefined (missing bridge script init), model not updating (modelUpdate called before subscribe initializes), and CORS errors (external API calls from iframe blocked by CSP). Handle loading states by setting an initial model value and showing a loading UI until subscribe fires.
1// Safe initialization pattern:2let isInitialized = false;34window.Retool.subscribe(function(model) {5 isInitialized = true;6 7 // Hide loading state8 document.getElementById('loading').style.display = 'none';9 document.getElementById('content').style.display = 'block';10 11 // Update the widget content12 updateWidget(model);13});1415// Set a timeout to show an error if Retool never calls subscribe16setTimeout(function() {17 if (!isInitialized) {18 document.getElementById('loading').textContent = 19 'Error: Retool bridge not initialized. Check console.';20 }21}, 3000);Expected result: The widget shows a loading indicator until Retool provides model data, and shows an error if the bridge fails to initialize.
Know when to use legacy widget vs modern Custom Component Library
The legacy Custom Component (custom widget) is best for: simple HTML widgets, quick prototypes, widgets using vanilla JS libraries, and cases where you don't want a build pipeline. Use the modern Custom Component Library (React CLI approach) when you need: npm packages, React components, TypeScript, component versioning across apps, or shared organization-wide components. The modern approach requires running `retool-ccl create my-library` locally and publishing via `npm publish`. It's significantly more powerful but requires Node.js tooling.
Expected result: You have a clear understanding of which approach fits your use case.
Complete working example
1<!DOCTYPE html>2<html>3<head>4 <style>5 body {6 margin: 0;7 padding: 16px;8 font-family: Inter, sans-serif;9 font-size: 14px;10 background: transparent;11 }12 .color-picker-container {13 display: flex;14 flex-direction: column;15 gap: 12px;16 }17 label {18 font-weight: 500;19 color: #374151;20 }21 .color-input-row {22 display: flex;23 align-items: center;24 gap: 12px;25 }26 input[type="color"] {27 width: 48px;28 height: 48px;29 border: 1px solid #E5E7EB;30 border-radius: 8px;31 cursor: pointer;32 padding: 2px;33 }34 .hex-display {35 font-family: monospace;36 font-size: 16px;37 font-weight: 600;38 color: #111827;39 }40 .preview-box {41 width: 100%;42 height: 48px;43 border-radius: 8px;44 border: 1px solid #E5E7EB;45 transition: background-color 0.2s;46 }47 </style>48</head>49<body>50 <div class="color-picker-container">51 <label id="labelText">Pick a color</label>52 <div class="color-input-row">53 <input type="color" id="colorInput" value="#7C3AED" 54 oninput="handleColorChange(this.value)">55 <span class="hex-display" id="hexDisplay">#7C3AED</span>56 </div>57 <div class="preview-box" id="previewBox" style="background-color: #7C3AED;"></div>58 </div>59 60 <script>61 window.Retool.subscribe(function(model) {62 // Initialize from model63 if (model.defaultColor) {64 document.getElementById('colorInput').value = model.defaultColor;65 updateDisplay(model.defaultColor);66 }67 if (model.label) {68 document.getElementById('labelText').textContent = model.label;69 }70 });71 72 function handleColorChange(hexValue) {73 updateDisplay(hexValue);74 75 // Send selected color back to Retool76 window.Retool.modelUpdate({77 selectedColor: hexValue,78 selectedColorRgb: hexToRgb(hexValue)79 });80 }81 82 function updateDisplay(hexValue) {83 document.getElementById('hexDisplay').textContent = hexValue.toUpperCase();84 document.getElementById('previewBox').style.backgroundColor = hexValue;85 }86 87 function hexToRgb(hex) {88 const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);89 return result ? {90 r: parseInt(result[1], 16),91 g: parseInt(result[2], 16),92 b: parseInt(result[3], 16)93 } : null;94 }95 </script>96</body>97</html>Common mistakes
Why it's a problem: Calling window.Retool.triggerQuery('queryName') before calling modelUpdate(), causing the query to read stale values from customComponent1.model
How to avoid: Always call modelUpdate() first, then triggerQuery(). The modelUpdate is synchronous from Retool's perspective, so the query will read the updated model values when it runs.
Why it's a problem: Accessing window.Retool.model directly instead of using window.Retool.subscribe()
How to avoid: window.Retool.model is not a live object. Use window.Retool.subscribe(function(model) { ... }) to receive model updates. The subscribe callback fires once initially and again whenever any {{ }} expression in the model changes.
Why it's a problem: Making external API calls from inside the iframe and getting CORS or Content Security Policy errors
How to avoid: External API calls from within the iframe sandbox are blocked by Retool's CSP. Route all external API calls through Retool queries (REST API resource) instead, and use triggerQuery() to invoke them from the widget.
Best practices
- Always call window.Retool.modelUpdate() before window.Retool.triggerQuery() so the query reads the latest values from customComponent1.model
- Keep the Model object lean — only include data the widget actually needs, not entire query datasets
- Use window.Retool.subscribe() for all model reads — never try to access window.Retool.model directly as a synchronous value
- Debug custom widget JS errors in browser DevTools → Console, not in Retool's Debug Panel which doesn't capture iframe errors
- For production custom widgets used across many apps, consider migrating to the Custom Component Library for proper versioning
- Add a initialization timeout in your widget to catch cases where the Retool bridge fails to initialize
- Test the widget with edge case data: null values, empty arrays, very long strings — the model can receive any value from Retool queries
Still stuck?
Copy one of these prompts to get a personalized, step-by-step explanation.
I'm building a Retool custom widget using the legacy Custom Component (iframe approach). I want to create a color picker widget that shows a native color input, sends the selected hex color back to Retool via modelUpdate(), and also updates a label based on data passed in from the Retool model. Write the complete HTML/JS for the Custom Component, including window.Retool.subscribe() to read incoming model data and window.Retool.modelUpdate() to send the color selection back to Retool.
Create a Custom Component in this Retool app that acts as a rating widget (1-5 stars). Pass in model: { label: 'Rate this item', currentRating: {{ query1.data[0].rating }} }. Inside the HTML, render 5 star buttons that highlight on hover/click. On star click, call window.Retool.modelUpdate({ rating: starNumber }) and window.Retool.triggerQuery('saveRating').
Frequently asked questions
What is the difference between the legacy Custom Component and the Custom Component Library in Retool?
The legacy Custom Component embeds HTML/CSS/JS in an iframe directly in the Retool editor — no build tools needed, great for simple widgets. The Custom Component Library (CCL) is a modern React-based approach using a CLI tool, npm packages, and a proper build pipeline. CCL supports TypeScript, npm packages, and enables sharing versioned components across your organization. Use legacy for simplicity; use CCL for production-grade, reusable components.
Can a Retool custom widget make direct database calls?
No. The custom widget runs inside a sandboxed iframe and cannot make database calls directly. Instead, call window.Retool.triggerQuery('yourQueryName') to invoke a Retool query that makes the database call. Retool queries have access to your configured resources (PostgreSQL, MySQL, etc.) and their results come back via the query's .data property in Retool.
How do I pass large datasets (thousands of rows) into a custom widget?
Include the data array in the Model field as { "tableData": "{{ query1.data }}" } and access it in the iframe via the model argument in window.Retool.subscribe(). However, for performance, paginate or limit data before passing it to the widget. Very large model objects cause frequent subscribe callbacks and can slow down the iframe. Consider passing only the current page of data.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation