Building Embeddable Widgets with Svelte: A Step-by-Step Guide
Learn how to build a production-ready embeddable widget using Svelte 5, the Shadow DOM, Tailwind CSS, and Rolldown. A complete tutorial from setup to deployment.
Andebet TeamEmbeddable widgets are how a product travels. Instead of asking a customer to adopt your framework, your build pipeline, or your design system, you ship one script tag — and a working piece of UI appears inside a page you do not control.
In this tutorial we build a feedback widget from scratch: the small report a bug or request a feature panel that lives in the corner of a lot of SaaS products. By the end you will have a single JavaScript file that anyone can embed with one script tag.
<!-- This is all your users need -->
<script src="https://cdn.example.com/widget.js"></script>
<script>
EmbeddableWidget('init', { widgetId: 'wgt_abc123' });
</script>The widgetId identifies which widget configuration to load from your backend: brand colors, which features are enabled, where submissions go.
The widget will:
- Float in the corner of any website
- Open a form when clicked
- Submit feedback to an API
- Never conflict with the host page’s styles
Let’s build it.
Why Svelte for Embeddable Widgets?
When you are building an embeddable widget, every kilobyte matters. Unlike a traditional SPA, where a spinner is an accepted part of the experience, widget users expect instant availability. That constraint eliminates most frameworks immediately.
The Virtual DOM Tax
React, Vue, and similar frameworks ship a virtual DOM diffing engine. That abstraction is powerful for large applications, but it adds tens of kilobytes before your first component renders. When your widget loads alongside dozens of other scripts on a marketing page, that overhead is hard to justify.
Svelte compiles your components into imperative DOM operations at build time. There is no runtime diffing: the compiler generates exactly the code needed to update the DOM when state changes.
A counter written like this:
<script>
let count = $state(0);
</script>
<button onclick={() => count++}>
Clicked {count} times
</button>compiles to something like:
let count = 0;
const button = document.createElement('button');
const text = document.createTextNode(`Clicked ${count} times`);
button.appendChild(text);
button.addEventListener('click', () => {
count++;
text.nodeValue = `Clicked ${count} times`;
});No virtual DOM and no reconciliation — just direct DOM manipulation. What does ship is a small runtime for transitions and reactivity primitives.
Svelte 5 Runes
Svelte 5 introduced runes, a reactivity system that is both more explicit and easier to optimise. The $state and $props runes replace the old reactive declarations:
<script lang="ts">
interface Props {
widgetId: string;
onClose?: () => void;
}
// Props are explicit
let { widgetId, onClose }: Props = $props();
// State is explicit
let isOpen = $state(false);
let messages = $state<Message[]>([]);
// Derived values
let messageCount = $derived(messages.length);
</script>That explicitness makes components easier to reason about, and it gives the bundler more to work with when it eliminates dead code.
The Stack
Here is what we use, and why:
| Tool | Purpose | Why not the alternatives |
|---|---|---|
| Svelte 5 | UI framework | Compiles away; tiny runtime |
| Rolldown | Production bundler | Faster than Rollup, better tree-shaking than esbuild |
| Tailwind CSS | Styling | Atomic classes mean a smaller CSS payload once purged |
| Shadow DOM | Style isolation | Native browser API, no runtime cost |
Why Rolldown Over Vite?
Vite uses Rollup under the hood, so why not just use Vite?
Vite is designed for applications. It gives you a dev server, hot module replacement, and code splitting — none of which you want inside a widget. You want a single IIFE file with everything inlined.
You can configure Vite for that (build.lib with formats: ['iife']), but you spend the whole time fighting its defaults. Using Rolldown directly gives you:
- 1.IIFE output by default — no module system, just a single
scripttag. - 2.Full control over CSS — inline everything, no external stylesheets.
- 3.Simpler plugin wiring — no abstraction layer between you and the bundler.
Why Tailwind?
Tailwind looks counterintuitive for a widget. Will it not bloat the bundle with utilities you never use?
The opposite. Atomic classes mean:
- Aggressive purging — only the classes you actually use are included.
- High compression — repeated class names compress extremely well with gzip.
- No specificity wars — utility classes do not fight with the host page’s styles.
- Rapid development — no context switching to a CSS file.
A typical widget’s Tailwind CSS compresses to somewhere between 8 and 15 KB gzipped — smaller than most hand-written stylesheets.
Embedding Strategies
There are two mainstream ways to get your UI onto somebody else’s page, and the trade-off is always isolation versus integration.
iframes
// Fully isolated: the browser owns the whole subtree
const frame = document.createElement('iframe');
frame.src = 'https://widget.example.com';
frame.width = '400';
frame.height = '600';
document.body.appendChild(frame);Pros:
- Complete isolation — styles, JavaScript, even crashes
- Simple to implement
- Works everywhere
Cons:
- A separate network request for the iframe content
- Communication requires postMessage
- Fixed dimensions are awkward to make responsive
- It feels embedded rather than native
Shadow DOM (What We’ll Use)
const container = document.createElement('div');
const shadow = container.attachShadow({ mode: 'open' });
// Mount your app inside the shadow rootPros:
- Single script, no additional requests
- Native-feeling integration
- Full access to host page context when you need it
- Flexible sizing and positioning
Cons:
- More complex CSS handling (we solve this below)
- You have to be careful with global state
For most widgets — feedback forms, chat bubbles, audio players — Shadow DOM is the better default. It loads faster and feels native. Reach for an iframe when you need bulletproof isolation: payment forms, third-party auth, or anything that must keep running even if the host page throws.
Now let’s build it.
Prerequisites
You’ll need:
- Node.js 18+ or Bun
- Basic familiarity with Svelte
Step 1: Project Setup
Create a new directory and initialize the project:
mkdir embeddable-widget && cd embeddable-widget
bun init -y
bun add svelte
bun add -d rolldown rollup-plugin-svelte @rollup/plugin-replace @rollup/plugin-terser postcss tailwindcss @tailwindcss/postcss autoprefixer cssnano
mkdir -p src/components scripts
touch src/sdk.ts src/Widget.svelte src/components/FeedbackForm.svelte src/widget.css src/types.d.ts scripts/build.tsYour structure should look like this:
embeddable-widget/
├── src/
│ ├── sdk.ts # Entry point, handles embedding
│ ├── Widget.svelte # Main widget component
│ ├── widget.css # Tailwind styles
│ ├── types.d.ts # Type declarations
│ └── components/
│ └── FeedbackForm.svelte
├── scripts/
│ └── build.ts # Build script
└── package.jsonStep 2: The Widget Component
Start with the shell. It renders a trigger button and, when open, the panel that hosts the form:
<!-- src/Widget.svelte -->
<script lang="ts">
import { fly } from 'svelte/transition';
import FeedbackForm from './components/FeedbackForm.svelte';
interface Props {
widgetId: string;
}
let { widgetId }: Props = $props();
let isOpen = $state(false);
export function open() {
isOpen = true;
}
export function close() {
isOpen = false;
}
export function toggle() {
isOpen = !isOpen;
}
</script>
<!-- Trigger button -->
<button
type="button"
class="fixed right-5 bottom-5 z-50 flex h-14 w-14 items-center justify-center rounded-full border-none bg-blue-500 text-white shadow-lg transition-all hover:scale-105 hover:bg-blue-600"
onclick={() => (isOpen = !isOpen)}
aria-label="Open feedback form"
>
{#if isOpen}
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
{:else}
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
</svg>
{/if}
</button>
<!-- Feedback panel -->
{#if isOpen}
<div
class="fixed right-5 bottom-24 z-50 max-h-125 w-95 overflow-hidden rounded-xl bg-white shadow-2xl"
transition:fly={{ y: 10, duration: 200 }}
>
<div class="flex items-center justify-between border-b border-gray-200 px-5 py-4">
<h2 class="text-base font-semibold text-gray-900">Send feedback</h2>
<button
type="button"
onclick={() => (isOpen = false)}
class="flex items-center justify-center rounded p-1 text-gray-500 hover:bg-gray-100 hover:text-gray-900"
aria-label="Close"
>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<FeedbackForm {widgetId} onSuccess={() => (isOpen = false)} />
</div>
{/if}Then the form itself, which owns the submission logic:
<!-- src/components/FeedbackForm.svelte -->
<script lang="ts">
interface Props {
widgetId: string;
onSuccess: () => void;
}
let { widgetId, onSuccess }: Props = $props();
let feedbackType = $state<'bug' | 'feature' | 'other'>('bug');
let message = $state('');
let email = $state('');
let isSubmitting = $state(false);
let error = $state<string | null>(null);
let success = $state(false);
async function handleSubmit(event: Event) {
event.preventDefault();
if (!message.trim()) {
error = 'Please enter a message';
return;
}
isSubmitting = true;
error = null;
try {
const response = await fetch('https://api.example.com/feedback', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
widgetId,
type: feedbackType,
message: message.trim(),
email: email.trim() || null,
url: window.location.href,
userAgent: navigator.userAgent,
}),
});
if (!response.ok) {
throw new Error('Failed to submit feedback');
}
success = true;
setTimeout(() => {
onSuccess();
message = '';
email = '';
success = false;
}, 1500);
} catch (err) {
error = err instanceof Error ? err.message : 'Something went wrong';
} finally {
isSubmitting = false;
}
}
</script>
<form onsubmit={handleSubmit} class="p-5">
{#if success}
<div class="px-5 py-10 text-center text-emerald-600">
<p class="mt-4 text-base font-medium">Thanks for your feedback!</p>
</div>
{:else}
<!-- Feedback type selector -->
<div class="mb-4 flex gap-2">
<button
type="button"
class="flex-1 cursor-pointer rounded-md border bg-white px-3 py-2 text-sm transition-colors {feedbackType === 'bug'
? 'border-blue-500 bg-blue-50 text-blue-700'
: 'border-gray-200'}"
onclick={() => (feedbackType = 'bug')}
>
Bug
</button>
<button
type="button"
class="flex-1 cursor-pointer rounded-md border bg-white px-3 py-2 text-sm transition-colors {feedbackType === 'feature'
? 'border-blue-500 bg-blue-50 text-blue-700'
: 'border-gray-200'}"
onclick={() => (feedbackType = 'feature')}
>
Feature
</button>
<button
type="button"
class="flex-1 cursor-pointer rounded-md border bg-white px-3 py-2 text-sm transition-colors {feedbackType === 'other'
? 'border-blue-500 bg-blue-50 text-blue-700'
: 'border-gray-200'}"
onclick={() => (feedbackType = 'other')}
>
Other
</button>
</div>
<!-- Message input -->
<div class="mb-4">
<label for="message" class="mb-1.5 block text-sm font-medium text-gray-700">Message</label>
<textarea
id="message"
bind:value={message}
placeholder="Tell us what is on your mind..."
rows="4"
class="w-full resize-y rounded-md border border-gray-200 px-3 py-2.5 text-sm focus:border-blue-500 focus:outline-none"
></textarea>
</div>
<!-- Optional email -->
<div class="mb-4">
<label for="email" class="mb-1.5 block text-sm font-medium text-gray-700">
Email (optional)
</label>
<input
type="email"
id="email"
bind:value={email}
placeholder="[email protected]"
class="w-full rounded-md border border-gray-200 px-3 py-2.5 text-sm focus:border-blue-500 focus:outline-none"
/>
</div>
{#if error}
<div class="mb-4 rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-600">
{error}
</div>
{/if}
<button
type="submit"
disabled={isSubmitting}
class="w-full cursor-pointer rounded-md bg-blue-500 py-3 text-sm font-medium text-white transition-colors hover:bg-blue-600 disabled:cursor-not-allowed disabled:opacity-60"
>
{#if isSubmitting}
Sending...
{:else}
Send feedback
{/if}
</button>
{/if}
</form>Step 3: The SDK Entry Point
This is where the embedding happens. The SDK entry point:
- Creates a container element
- Attaches a shadow root for style isolation
- Mounts the Svelte component
- Exposes a single global function
// src/sdk.ts
import { mount, unmount } from 'svelte';
import Widget from './Widget.svelte';
import widgetStyles from './widget.css?inline';
type Command = 'init' | 'open' | 'close' | 'toggle' | 'destroy';
interface InitOptions {
widgetId: string; // Identifies which widget config to load from your backend
position?: 'bottom-right' | 'bottom-left';
}
interface WidgetAPI {
(command: 'init', options: InitOptions): void;
(command: 'open' | 'close' | 'toggle' | 'destroy'): void;
q?: IArguments[];
}
declare global {
interface Window {
EmbeddableWidget: WidgetAPI;
}
}
let widgetInstance: ReturnType<typeof mount> | null = null;
let containerEl: HTMLDivElement | null = null;
const CONTAINER_ID = 'embeddable-widget-container';
// Tailwind utilities read CSS custom properties, and those variables have to be
// declared on :host or utilities like shadow-lg and ring-2 silently do nothing.
const HOST_STYLES = `
:host {
display: block;
font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
text-align: left;
--tw-border-style: solid;
--tw-shadow: 0 0 #0000;
--tw-inset-shadow: 0 0 #0000;
--tw-ring-shadow: 0 0 #0000;
--tw-inset-ring-shadow: 0 0 #0000;
--tw-ring-offset-width: 0px;
--tw-ring-offset-color: #fff;
--tw-ring-offset-shadow: 0 0 #0000;
--tw-outline-style: solid;
}
`;
function createWidget(options: InitOptions) {
// Guard against a second embed on the same page.
if (document.getElementById(CONTAINER_ID)) {
console.warn('[EmbeddableWidget] Already initialized');
return;
}
containerEl = document.createElement('div');
containerEl.id = CONTAINER_ID;
document.body.appendChild(containerEl);
// Shadow DOM keeps the widget's styles and the host's styles apart.
const shadowRoot = containerEl.attachShadow({ mode: 'open' });
const styleEl = document.createElement('style');
styleEl.textContent = `${HOST_STYLES}\n${widgetStyles}`;
shadowRoot.appendChild(styleEl);
const mountPoint = document.createElement('div');
mountPoint.id = 'widget-mount';
shadowRoot.appendChild(mountPoint);
widgetInstance = mount(Widget, {
target: mountPoint,
props: { widgetId: options.widgetId },
});
return widgetInstance;
}
function handleCommand(command: Command, ...args: unknown[]) {
switch (command) {
case 'init': {
const options = args[0] as InitOptions;
if (!options?.widgetId) {
console.error('[EmbeddableWidget] init requires a widgetId');
return;
}
createWidget(options);
break;
}
case 'open':
(widgetInstance as any)?.open?.();
break;
case 'close':
(widgetInstance as any)?.close?.();
break;
case 'toggle':
(widgetInstance as any)?.toggle?.();
break;
case 'destroy':
if (widgetInstance) {
unmount(widgetInstance);
widgetInstance = null;
}
containerEl?.remove();
containerEl = null;
break;
}
}
// Replay anything the loader queued before this script evaluated.
function processQueue() {
const queue = window.EmbeddableWidget?.q;
if (queue && Array.isArray(queue)) {
queue.forEach((args) => {
const [command, ...params] = Array.from(args);
handleCommand(command as Command, ...params);
});
}
}
const sdk: WidgetAPI = (command: Command, ...args: unknown[]) => {
handleCommand(command, ...args);
};
window.EmbeddableWidget = sdk;
processQueue();The ?inline suffix tells the bundler to hand back the CSS as a string instead of injecting it. TypeScript does not understand that import by default, so add a declaration file:
// src/types.d.ts
declare module '*.css?inline' {
const content: string;
export default content;
}Understanding the Shadow DOM
The Shadow DOM is what makes this whole approach work. Without it, your widget’s styles leak into the host page and the host page’s styles break your widget.
Here is the tree once attachShadow() has run:
Host document
├── <html>
├── <head>
│ └── <style>button { background: red; }</style> <!-- never reaches the widget -->
├── <body>
│ └── <div id="embeddable-widget-container">
│ └── #shadow-root (open) <!-- the style boundary -->
│ ├── <style>/* widget styles */</style>
│ └── <div id="widget-mount">
│ └── <!-- Svelte component mounts here -->The shadow boundary prevents:
- Host CSS from selecting elements inside the shadow root
- Widget CSS from selecting elements outside the shadow root
- External JavaScript from querying widget elements directly
Why mode: 'open'?
const shadowRoot = containerEl.attachShadow({ mode: 'open' });With mode: 'open', the shadow root is reachable through element.shadowRoot. That helps when you are:
- Debugging in DevTools
- Writing automated tests
- Deliberately exposing an integration point to the host page
Use mode: 'closed' if you want to discourage external access — but note that a determined developer can still get at it.
Step 4: Tailwind CSS in Shadow DOM
Tailwind needs a little help inside a shadow root. The CSS custom properties it relies on have to be declared on the :host selector.
Create src/widget.css with a single import:
@import 'tailwindcss';The important part is the HOST_STYLES constant in sdk.ts. It seeds the custom properties that Tailwind utilities depend on — transforms, shadows, rings. Without those declarations on :host, utilities such as shadow-lg and ring-2 fail silently, because they reference variables that were never defined.
Step 5: Building with Rolldown
Rolldown bundles everything into a single IIFE file. Create scripts/build.ts:
// scripts/build.ts
import { rolldown, type Plugin } from 'rolldown';
import svelte from 'rollup-plugin-svelte';
import replace from '@rollup/plugin-replace';
import terser from '@rollup/plugin-terser';
import postcss from 'postcss';
import tailwindcss from '@tailwindcss/postcss';
import autoprefixer from 'autoprefixer';
import cssnano from 'cssnano';
import { readFileSync, existsSync } from 'fs';
import { resolve, dirname } from 'path';
// Rollup plugins emit source maps that rolldown cannot always consume.
function fixSourcemaps(plugin: any): Plugin {
return {
...plugin,
transform: plugin.transform
? async function (this: any, code: string, id: string) {
const result = await plugin.transform.call(this, code, id);
if (result && typeof result === 'object' && result.map) {
return { ...result, map: null };
}
return result;
}
: undefined,
};
}
// Resolve the inline CSS suffix and compile those files with PostCSS + Tailwind.
function cssPlugin() {
return {
name: 'css-inline',
resolveId(source: string, importer: string | undefined) {
if (source.endsWith('?inline')) {
const cleanSource = source.replace('?inline', '');
const resolvedPath = importer
? resolve(dirname(importer), cleanSource)
: resolve(cleanSource);
return resolvedPath + '?inline';
}
return null;
},
async load(id: string) {
if (!id.endsWith('?inline')) return null;
const cleanId = id.replace('?inline', '');
if (!existsSync(cleanId)) {
throw new Error(`CSS file not found: ${cleanId}`);
}
const css = readFileSync(cleanId, 'utf-8');
const result = await postcss([
tailwindcss(),
autoprefixer(),
cssnano({ preset: 'default' }),
]).process(css, { from: cleanId });
return `export default ${JSON.stringify(result.css)};`;
},
};
}
async function build() {
console.log('Building widget...');
const bundle = await rolldown({
input: 'src/sdk.ts',
plugins: [
replace({
preventAssignment: true,
values: { 'process.env.NODE_ENV': JSON.stringify('production') },
}),
cssPlugin(),
fixSourcemaps(
svelte({
compilerOptions: { css: 'injected' },
emitCss: false,
})
),
terser({
compress: {
drop_console: false, // Keep console.warn/error for debugging
drop_debugger: true,
passes: 2,
},
mangle: {
reserved: ['EmbeddableWidget'], // Never mangle the public API
},
format: { comments: false },
}),
],
});
await bundle.write({
file: 'dist/widget.js',
format: 'iife',
name: 'EmbeddableWidgetBundle',
sourcemap: false,
});
const stats = readFileSync('dist/widget.js');
console.log(`Built dist/widget.js (${(stats.length / 1024).toFixed(1)} KB)`);
}
build().catch(console.error);The configuration choices that matter:
| Setting | Value | Why |
|---|---|---|
format | 'iife' | Self-executing function, no module system required |
css: 'injected' | Injected CSS | Component CSS is bundled into the JS, not shipped separately |
mangle.reserved | ['EmbeddableWidget'] | Keeps the public API name readable after minification |
sourcemap | false | Smaller file, and implementation details stay hidden |
Add the build script to package.json:
{
"scripts": {
"build": "bun scripts/build.ts",
"dev": "bun --watch scripts/build.ts"
}
}Then run the build:
bun run buildStep 6: Testing the Widget
Test the widget against a deliberately hostile host page. Create a small HTML file with aggressive global styles:
<!-- test/index.html -->
<!DOCTYPE html>
<html>
<head>
<title>Widget test</title>
<style>
/* Deliberately hostile global styles */
* { margin: 0; padding: 0; }
button {
background: red !important;
color: yellow !important;
border: 5px solid green !important;
}
body {
font-family: 'Comic Sans MS', cursive;
background: #f0f0f0;
padding: 40px;
}
</style>
</head>
<body>
<h1>Host page</h1>
<p>This page has aggressive global styles. The widget should be unaffected.</p>
<button onclick="alert('Host button')">Host button (styled red)</button>
<hr style="margin: 40px 0;" />
<button onclick="EmbeddableWidget('open')">Open widget</button>
<button onclick="EmbeddableWidget('close')">Close widget</button>
<button onclick="EmbeddableWidget('toggle')">Toggle widget</button>
<button onclick="EmbeddableWidget('destroy')">Destroy widget</button>
<script src="../dist/widget.js"></script>
<script>
EmbeddableWidget('init', { widgetId: 'wgt_test123' });
</script>
</body>
</html>Serve the folder:
bunx serve .Open http://localhost:3000/test/ and check that:
- The trigger button appears in the corner
- The widget is not red or green, even though the host page styles buttons that way — Shadow DOM isolation works
- The open, close, toggle, and destroy commands all work
- Form submission reaches your API
Step 7: Advanced Features
None of the following is required — the widget already works — but these are the patterns that come up first in real deployments.
Exposing Methods via the Instance
The command pattern is convenient for script tags. Sometimes you want the instance back so you can call methods directly. Have createWidget return its controls, and return that value from the init branch:
function createWidget(options: InitOptions) {
// ...container, shadow root and mount point setup...
widgetInstance = mount(Widget, {
target: mountPoint,
props: { widgetId: options.widgetId },
});
// Hand the controls back to the caller
return {
open: () => (widgetInstance as any)?.open?.(),
close: () => (widgetInstance as any)?.close?.(),
toggle: () => (widgetInstance as any)?.toggle?.(),
destroy: () => {
if (widgetInstance) {
unmount(widgetInstance);
widgetInstance = null;
}
containerEl?.remove();
containerEl = null;
},
};
}
// Then return it from the init branch
case 'init': {
const options = args[0] as InitOptions;
return createWidget(options);
}Callers can then hold onto the widget:
// Widen the init overload so it returns the controls, then:
const widget = EmbeddableWidget('init', { widgetId: 'wgt_abc123' });
widget.open();
widget.close();Event Callbacks
Let the host page react to what happens inside the widget:
// Extend InitOptions in sdk.ts
interface InitOptions {
widgetId: string;
position?: 'bottom-right' | 'bottom-left';
onOpen?: () => void;
onClose?: () => void;
onSubmit?: (data: { type: string; message: string }) => void;
}
// In Widget.svelte, call them when the state changes
<script lang="ts">
interface Props {
widgetId: string;
onOpen?: () => void;
onClose?: () => void;
onSubmit?: (data: { type: string; message: string }) => void;
}
let { widgetId, onOpen, onClose, onSubmit }: Props = $props();
export function open() {
isOpen = true;
onOpen?.();
}
export function close() {
isOpen = false;
onClose?.();
}
</script>Usage:
EmbeddableWidget('init', {
widgetId: 'wgt_abc123',
onOpen: () => console.log('Widget opened'),
onClose: () => console.log('Widget closed'),
onSubmit: (data) => {
console.log('Feedback submitted:', data);
analytics.track('feedback_submitted', data);
},
});Declarative Triggers
Finally, let people open the widget from any element with a data attribute — no JavaScript required:
// Add to sdk.ts
function setupTriggers() {
document.addEventListener('click', (event) => {
const target = event.target as HTMLElement;
const trigger = target.closest('[data-embed-widget]');
if (!trigger) return;
event.preventDefault();
const action = trigger.getAttribute('data-embed-widget');
switch (action) {
case 'open':
(widgetInstance as any)?.open?.();
break;
case 'close':
(widgetInstance as any)?.close?.();
break;
case 'toggle':
(widgetInstance as any)?.toggle?.();
break;
}
});
}
// Call it once, after mounting, inside createWidget
setupTriggers();Which makes the following work anywhere on the page:
<button data-embed-widget="open">Send feedback</button>
<a href="#" data-embed-widget="toggle">Toggle the widget</a>Step 8: Deployment
Build for Production
bun run buildUpload to CDN
Upload dist/widget.js wherever you serve static assets. Ship a versioned copy alongside a short-cached latest pointer, so you can roll forward and back without breaking anybody:
// scripts/deploy.ts
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
const client = new S3Client({
region: 'auto',
endpoint: `https://${process.env.CF_ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: process.env.R2_ACCESS_KEY_ID!,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
},
});
async function deploy() {
const content = await Bun.file('dist/widget.js').text();
const timestamp = Date.now();
// Immutable, versioned release you can roll back to
await client.send(
new PutObjectCommand({
Bucket: 'cdn',
Key: `widget/releases/widget-${timestamp}.js`,
Body: content,
ContentType: 'application/javascript',
})
);
// Mutable pointer with a short cache lifetime
await client.send(
new PutObjectCommand({
Bucket: 'cdn',
Key: 'widget/widget.js',
Body: content,
ContentType: 'application/javascript',
CacheControl: 'public, max-age=300',
})
);
console.log(`Deployed widget.js (${(content.length / 1024).toFixed(1)} KB)`);
}
deploy().catch(console.error);Step 9: Embedding Your Widget
Simple Embed
The simplest integration is a direct script tag:
<script src="https://cdn.example.com/widget/widget.js"></script>
<script>
EmbeddableWidget('init', { widgetId: 'YOUR_WIDGET_ID' });
</script>That loads the widget synchronously, which is fine for most use cases.
Async Loader Pattern
On a high-traffic site, load the widget asynchronously so the host page keeps painting. The classic pattern is a tiny loader, well under a kilobyte minified, that:
- Creates a command queue before the SDK exists
- Fetches the main bundle asynchronously
- Replays the queue once the SDK is ready
<script>
(function (w, d) {
var ID = 'embeddable-widget',
SRC = 'https://cdn.example.com/widget/widget.js',
NS = 'EmbeddableWidget';
// Never load the bundle twice
if (d.getElementById(ID)) return;
// Create the command queue before the SDK exists
if (typeof w[NS] !== 'function') {
var q = [];
w[NS] = function () {
q.push(arguments);
};
w[NS].q = q;
}
function load() {
var s = d.createElement('script');
s.id = ID;
s.async = true;
s.src = SRC;
d.head.appendChild(s);
}
if (d.readyState === 'complete' || d.readyState === 'interactive') load();
else d.addEventListener('DOMContentLoaded', load);
})(window, document);
// Queued now, replayed the moment the SDK is ready
EmbeddableWidget('init', { widgetId: 'YOUR_WIDGET_ID' });
</script>The trick is that callers can invoke EmbeddableWidget() immediately. Their commands land in an array, and the SDK drains that array when it loads. That gives you:
- Zero blocking of the host page
- Commands that work before the SDK has finished loading
- No race between SDK load and initialization
Summary
We ended up with a production-ready embeddable widget:
| Feature | Implementation |
|---|---|
| Style isolation | attachShadow({ mode: 'open' }) |
| Non-blocking load | Async loader with a command queue |
| Single-file output | Rolldown with IIFE format |
| Tailwind support | :host |
| Public API | One global function with a command pattern |
| Event callbacks | Props passed into the Svelte component |
| Declarative triggers | Data-attribute click delegation |
The architectural decisions that mattered:
- Shadow DOM is not optional — without it, style conflicts are inevitable.
- Queue commands before load — callers can use your API immediately.
- IIFE format — no module-system assumptions about the host page.
- Inline all CSS — one script tag, no external stylesheets.
- Svelte compiles away — minimal runtime overhead in somebody else’s page.
