A lightweight (~3KB) JavaScript library for creating embeddable widgets that work across origins. Handles form submissions, link clicks, and content updates via fetch() without requiring Turbo, htmx, or any other framework.
Both Turbo and htmx intentionally block cross-origin navigation for security reasons. If you're building widgets that need to be embedded on third-party websites, you need custom handling. WidgetFrame provides:
- Zero cross-origin restrictions - Works on any domain
- Fetch-based navigation - All forms and links handled via
fetch()with proper credentials - Lightweight - ~3KB, no dependencies
- Framework agnostic - Works with any backend (Rails, Django, Express, etc.)
<script src="https://cdn.jsdelivr.net/gh/cmer/widget-frame@1.0.0/widget-frame.js"></script><script src="https://cdn.jsdelivr.net/gh/cmer/widget-frame@main/widget-frame.js"></script>Download widget-frame.js and include it in your project.
<div id="my-widget"></div>
<script src="https://cdn.jsdelivr.net/gh/cmer/widget-frame@main/widget-frame.js"></script>
<script>
var frame = WidgetFrame.create({
container: document.querySelector('#my-widget'),
baseUrl: 'https://app.example.com',
initialUrl: 'https://app.example.com/widget/content'
});
</script>The frame automatically handles:
- Form submissions (GET, POST, PATCH, DELETE)
- Link clicks (except
target="_blank"andtarget="_top") - Content updates via
fetch()
WidgetFrame.create({
// Required
container: document.querySelector('#widget'), // Container element
baseUrl: 'https://app.example.com', // Base URL for resolving relative paths
// Optional
frameId: 'my-frame', // ID for the frame element
initialUrl: '/widget/start', // URL to load initially
loadingHtml: '<div>Loading...</div>', // HTML shown while loading
errorHtml: '<div>Error loading content</div>', // HTML shown on error
frameClass: 'my-widget-frame', // CSS class for the frame
credentials: 'include', // Fetch credentials mode
scrollToTop: true, // Scroll widget into view on navigation if top is off-screen
headers: { 'X-Custom': 'value' }, // Additional headers
scrollOffset: '.site-navbar', // Pixel offset (number) or selector for a fixed navbar
// Callbacks
onLoad: function(element) { }, // Called after content loads
onError: function(error) { } // Called on error
});| Option | Type | Required | Default | Description |
|---|---|---|---|---|
container |
HTMLElement | Yes | - | Container element to append frame to |
baseUrl |
string | Yes | - | Base URL for resolving relative paths |
frameId |
string | No | Auto-generated | ID for the frame element |
initialUrl |
string | No | - | URL to load initially |
loadingHtml |
string | No | <div class="br-loading">Loading...</div> |
HTML to show while loading |
errorHtml |
string | No | <div class="br-error">Failed to load...</div> |
HTML to show on error |
frameClass |
string | No | widget-frame-container |
CSS class for the frame element |
credentials |
string | No | include |
Fetch credentials mode |
scrollToTop |
boolean | No | true |
Scroll the widget into view after navigation when its top edge is above the viewport. Skipped on initial load. |
headers |
object | No | See below | Additional headers for requests |
scrollOffset |
number | string | No | 0 |
Space to leave above the widget when scrolling it into view (e.g. for a sticky navbar). Accepts a pixel number, a CSS length string ("80px", "10vh", "2rem", "5%"), or a CSS selector whose element height is measured at scroll time so responsive navbars are handled correctly. Invalid values resolve to 0. |
onLoad |
function | No | - | Callback after content loads |
onError |
function | No | - | Callback on error |
{
'Accept': 'text/html, application/xhtml+xml',
'X-Requested-With': 'XMLHttpRequest'
}A subset of options can be set directly on the container element via data-* attributes. This is useful for embed snippets where the person dropping the HTML into a page doesn't own the JS init call.
<div
id="my-widget"
data-widget-frame-base-url="https://app.example.com"
data-widget-frame-initial-url="/widget/start"
data-widget-frame-scroll-offset=".site-navbar"
></div>When both the JS option and the data attribute are set, the data attribute wins — so embedders can override defaults supplied by a shared init script.
| Data attribute | JS option | Notes |
|---|---|---|
data-widget-frame-base-url |
baseUrl |
|
data-widget-frame-initial-url |
initialUrl |
|
data-widget-frame-id |
frameId |
|
data-widget-frame-class |
frameClass |
|
data-widget-frame-scroll-to-top |
scrollToTop |
"true" / "false" / "" (empty = true); anything else falls back to the JS option |
data-widget-frame-scroll-offset |
scrollOffset |
Accepts pixel numbers ("80"), CSS lengths ("80px", "10vh", "2rem", "5%"), or a CSS selector |
Options not listed here (loadingHtml, errorHtml, credentials, sessionHeader, headers, onLoad, onError) are JS-only.
Factory method to create a new WidgetFrame instance.
var frame = WidgetFrame.create({
container: document.querySelector('#widget'),
baseUrl: 'https://app.example.com'
});Load content from a URL into the frame.
// Simple GET request
frame.load('/widget/page');
// POST request with form data
frame.load('/widget/submit', {
method: 'POST',
body: new FormData(formElement)
});Directly set the frame's HTML content.
frame.setContent('<div>New content</div>');Remove the frame from the DOM and clean up.
frame.destroy();Fired on the frame element after content is successfully loaded.
frame.element.addEventListener('widget-frame:load', function(e) {
console.log('Content loaded');
});When a form is submitted within the frame:
- Default submission is prevented
- Form data is collected via
FormData - For GET requests, data is appended to the URL
- For POST/PATCH/DELETE, data is sent as the request body
- Response HTML is parsed and the frame is updated
When a link is clicked within the frame:
- Default navigation is prevented (except
target="_blank"ortarget="_top") - Relative URLs are resolved against
baseUrl - Content is fetched and the frame is updated
The library parses HTML responses looking for:
- A
<turbo-frame>element (for compatibility with Turbo's Turbo Frames) - Falls back to
<body>content if no turbo-frame found
Your server needs to return proper CORS headers for cross-origin requests:
Access-Control-Allow-Origin: https://embedding-site.com
Access-Control-Allow-Credentials: true
Access-Control-Allow-Headers: Content-Type, X-Requested-With
Use absolute URLs in your widget views:
<%# Good - absolute URL %>
<%= form_with url: widget_url(@widget) do |f| %>
<%# Bad - relative path won't work cross-origin %>
<%= form_with url: widget_path(@widget) do |f| %>- Chrome 42+
- Firefox 39+
- Safari 10.1+
- Edge 14+
Uses fetch(), Promise, URL, and FormData APIs.
MIT License - see LICENSE for details.