# How to Use Custom Widgets in Retool

- Tool: Retool
- Difficulty: Intermediate
- Time required: 25-35 min
- Compatibility: Retool Cloud and Self-hosted
- Last updated: March 2026

## TL;DR

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.

## 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.

## Before you start

- 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

### 1. 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.

### 2. 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.

```
<!-- Custom Component HTML (paste into Inspector → HTML/CSS/JS → HTML tab) -->
<!DOCTYPE html>
<html>
<head>
  <style>
    body {
      margin: 0;
      padding: 12px;
      font-family: Inter, sans-serif;
      font-size: 14px;
    }
    .widget-container {
      background: white;
      border-radius: 8px;
      padding: 16px;
    }
    .value-display {
      font-size: 24px;
      font-weight: 600;
      color: #7C3AED;
    }
    button {
      margin-top: 12px;
      padding: 8px 16px;
      background: #7C3AED;
      color: white;
      border: none;
      border-radius: 6px;
      cursor: pointer;
    }
  </style>
</head>
<body>
  <div class="widget-container">
    <p>Custom Widget — Current Value:</p>
    <div class="value-display" id="valueDisplay">—</div>
    <button onclick="handleButtonClick()">Send to Retool</button>
  </div>
  
  <script>
    // Subscribe to model updates from Retool
    window.Retool.subscribe(function(model) {
      // model contains the data passed in from Inspector → Model field
      document.getElementById('valueDisplay').textContent = 
        model.currentValue || 'No data';
    });
    
    function handleButtonClick() {
      // Send data back to Retool
      window.Retool.modelUpdate({
        selectedValue: document.getElementById('valueDisplay').textContent,
        clickedAt: new Date().toISOString()
      });
    }
  </script>
</body>
</html>
```

**Expected result:** The Custom Component renders the HTML content inside the iframe frame on the canvas.

### 3. 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.

```
// Inspector → Model field (JSON object with {{ }} expressions):
{
  "currentValue": "{{ query1.data[0].value }}",
  "userName": "{{ current_user.fullName }}",
  "theme": "{{ darkMode.value ? 'dark' : 'light' }}",
  "tableData": "{{ query1.data }}",
  "selectedId": "{{ table1.selectedRow.id }}"
}
```

**Expected result:** The model JSON with live values appears in the Inspector. Changes to referenced queries/components automatically flow into the widget.

### 4. 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 }}`

```
// Inside the iframe JavaScript:
window.Retool.modelUpdate({
  selectedValue: selectedItem.value,
  selectedLabel: selectedItem.label,
  isValid: selectedItem.value !== null
});

// In Retool, read the output:
// Text component: {{ customComponent1.model.selectedLabel }}
// Query parameter: {{ customComponent1.model.selectedValue }}
// Button hidden property: {{ !customComponent1.model.isValid }}
```

**Expected result:** Values sent via modelUpdate() are immediately accessible as {{ customComponent1.model.* }} in any Retool component.

### 5. 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).

```
// Inside the iframe JavaScript:
function handleRowSelection(rowData) {
  // First update the model with the selected data
  window.Retool.modelUpdate({
    selectedRowId: rowData.id,
    selectedRowName: rowData.name
  });
  
  // Then trigger a Retool query
  // The query can reference {{ customComponent1.model.selectedRowId }}
  window.Retool.triggerQuery('fetchRowDetails');
}

// Trigger with additional params (passes to query's additionalScope):
window.Retool.triggerQuery('logUserAction', {
  action: 'widget_interaction',
  timestamp: Date.now()
});
```

**Expected result:** The Retool query named 'fetchRowDetails' runs when the widget calls triggerQuery. Query results are available via fetchRowDetails.data.

### 6. 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.

```
// Safe initialization pattern:
let isInitialized = false;

window.Retool.subscribe(function(model) {
  isInitialized = true;
  
  // Hide loading state
  document.getElementById('loading').style.display = 'none';
  document.getElementById('content').style.display = 'block';
  
  // Update the widget content
  updateWidget(model);
});

// Set a timeout to show an error if Retool never calls subscribe
setTimeout(function() {
  if (!isInitialized) {
    document.getElementById('loading').textContent = 
      'Error: Retool bridge not initialized. Check console.';
  }
}, 3000);
```

**Expected result:** The widget shows a loading indicator until Retool provides model data, and shows an error if the bridge fails to initialize.

### 7. 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 code example

File: `Custom Component HTML (color picker widget example)`

```html
<!DOCTYPE html>
<html>
<head>
  <style>
    body {
      margin: 0;
      padding: 16px;
      font-family: Inter, sans-serif;
      font-size: 14px;
      background: transparent;
    }
    .color-picker-container {
      display: flex;
      flex-direction: column;
      gap: 12px;
    }
    label {
      font-weight: 500;
      color: #374151;
    }
    .color-input-row {
      display: flex;
      align-items: center;
      gap: 12px;
    }
    input[type="color"] {
      width: 48px;
      height: 48px;
      border: 1px solid #E5E7EB;
      border-radius: 8px;
      cursor: pointer;
      padding: 2px;
    }
    .hex-display {
      font-family: monospace;
      font-size: 16px;
      font-weight: 600;
      color: #111827;
    }
    .preview-box {
      width: 100%;
      height: 48px;
      border-radius: 8px;
      border: 1px solid #E5E7EB;
      transition: background-color 0.2s;
    }
  </style>
</head>
<body>
  <div class="color-picker-container">
    <label id="labelText">Pick a color</label>
    <div class="color-input-row">
      <input type="color" id="colorInput" value="#7C3AED" 
             oninput="handleColorChange(this.value)">
      <span class="hex-display" id="hexDisplay">#7C3AED</span>
    </div>
    <div class="preview-box" id="previewBox" style="background-color: #7C3AED;"></div>
  </div>
  
  <script>
    window.Retool.subscribe(function(model) {
      // Initialize from model
      if (model.defaultColor) {
        document.getElementById('colorInput').value = model.defaultColor;
        updateDisplay(model.defaultColor);
      }
      if (model.label) {
        document.getElementById('labelText').textContent = model.label;
      }
    });
    
    function handleColorChange(hexValue) {
      updateDisplay(hexValue);
      
      // Send selected color back to Retool
      window.Retool.modelUpdate({
        selectedColor: hexValue,
        selectedColorRgb: hexToRgb(hexValue)
      });
    }
    
    function updateDisplay(hexValue) {
      document.getElementById('hexDisplay').textContent = hexValue.toUpperCase();
      document.getElementById('previewBox').style.backgroundColor = hexValue;
    }
    
    function hexToRgb(hex) {
      const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
      return result ? {
        r: parseInt(result[1], 16),
        g: parseInt(result[2], 16),
        b: parseInt(result[3], 16)
      } : null;
    }
  </script>
</body>
</html>
```

## Common mistakes

- **Calling window.Retool.triggerQuery('queryName') before calling modelUpdate(), causing the query to read stale values from customComponent1.model** — undefined Fix: 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.
- **Accessing window.Retool.model directly instead of using window.Retool.subscribe()** — undefined Fix: 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.
- **Making external API calls from inside the iframe and getting CORS or Content Security Policy errors** — undefined Fix: 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

## 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.

---

Source: https://www.rapidevelopers.com/retool-tutorial/how-to-use-custom-widgets-in-retool
© RapidDev — https://www.rapidevelopers.com/retool-tutorial/how-to-use-custom-widgets-in-retool
