This repository provides a step-by-step guide and working example for creating self-contained, embeddable JavaScript widgets using Svelte.
The example widget is a simple notepad that can be added to any website with a single script tag.
The repository is organized as follows:
.
├── src/
│ ├── Widget.svelte # Main widget component
│ ├── styles.css # Widget styles
│ ├── main.js # Entry point that creates global object
│ ├── dev.js # Development entry point
│ └── Dev.svelte # Development harness
├── tests/ # Tests for the widget
│ ├── pages/ # Test HTML pages
│ │ └── test.html # Sample test page
│ └── widget.spec.js # Playwright test script
├── vite.config.js # Vite configuration
├── svelte.config.js # Svelte configuration
├── playwright.config.js # Playwright test configuration
└── package.json # Dependencies
# Clone the repository
git clone https://github.com/helgesverre/simple-widget.git
cd simple-widget
# Install dependencies
npm install
# Start development server
npm run dev
This is the main Svelte component that defines our widget's UI and behavior:
<script>
import "./styles.css";
import { onMount } from "svelte";
import classNames from "classnames";
// Widget state
export let heading = "My Notes";
export let open = false;
export let position = null;
let notes = "";
let isInitialized = false;
let skipTransition = true;
onMount(() => {
// Load saved notes
notes = localStorage.getItem("simpleWidget.notes") || "";
if (open === false) {
open = localStorage.getItem("simpleWidget.open") === "true";
}
position =
localStorage.getItem("simpleWidget.position") ||
position ||
"right";
isInitialized = true;
// Enable transitions after a short delay (allows initial render without animation)
setTimeout(() => {
skipTransition = false;
}, 100);
});
// Save state when it changes
$: if (isInitialized) {
localStorage.setItem("simpleWidget.open", open?.toString());
localStorage.setItem("simpleWidget.notes", notes);
localStorage.setItem("simpleWidget.position", position);
}
</script>
<div
id="simple-widget-root"
class={classNames({
"widget-container": true,
"right": position === "right",
"left": position === "left",
"open": open,
"closed": !open,
"no-transition": skipTransition,
})}
>
<div class="widget-panel">
<button class="widget-tab" on:click={() => (open = !open)}>
<span class="tab-icon">
{#if position === "right"}
{open ? "›" : "‹"}
{:else}
{!open ? "›" : "‹"}
{/if}
</span>
</button>
<div id="simple-widget">
<div class="widget-header">
<h1>{heading}</h1>
</div>
<div class="widget-content">
<textarea
bind:value={notes}
placeholder="Type your notes here..."
></textarea>
</div>
</div>
</div>
</div>
The styling is carefully structured to avoid conflicts with host pages:
/* Base container */
#simple-widget-root {
position: fixed;
z-index: 9000;
top: 0;
height: 100vh;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
Ubuntu, Cantarell, sans-serif;
display: flex;
}
/* Positioning */
#simple-widget-root.right {
right: 0;
}
#simple-widget-root.left {
left: 0;
}
/* ...more styles... */
See styles.css for the full stylesheet.
This file creates the global API that websites will use to interact with the widget:
import Widget from "./Widget.svelte";
// Define global object with API
const SimpleWidget = {
config: {},
// Method to initialize the widget
init: function (target, props = {}) {
// Merge default config with props
const mergedProps = { ...SimpleWidget.config, ...props };
return new Widget({
target: target || document.body,
props: mergedProps,
});
},
// Parse data attributes from script tag
parseDataAttributes: function (dataset) {
const config = {};
for (const key in dataset) {
let value = dataset[key];
// Convert strings to appropriate types
if (value === "true") value = true;
if (value === "false") value = false;
if (!isNaN(value) && value.trim() !== "") value = Number(value);
config[key] = value;
}
return config;
},
};
// Read config from script tag
SimpleWidget.config = {
open: false,
position: "right",
autoInit: false,
...SimpleWidget.parseDataAttributes(document.currentScript?.dataset || {}),
};
// Auto-initialize if configured
if (SimpleWidget.config.autoInit) {
SimpleWidget.init(document.body, SimpleWidget.config);
}
// Make available globally
window.SimpleWidget = SimpleWidget;
The Vite configuration is crucial for bundling everything into a single embeddable file:
import { defineConfig } from "vite";
import { svelte } from "@sveltejs/vite-plugin-svelte";
import cssInjectedByJsPlugin from "vite-plugin-css-injected-by-js";
export default defineConfig({
plugins: [
svelte({
compilerOptions: {
legacy: false,
},
}),
// This plugin injects CSS into JS instead of creating separate files
cssInjectedByJsPlugin({
styleId: "simple-widget-styles",
useStrictCSP: false,
topExecutionPriority: true,
}),
],
// The critical configuration for embedding
build: {
lib: {
entry: "src/main.js", // Entry point
name: "SimpleWidget", // Global variable name
formats: ["iife"], // Use IIFE for script tag compatibility
fileName: () => "widget.js", // Output filename
},
},
});
This component makes it easy to test the widget during development:
<script>
import Widget from "./Widget.svelte";
let widgetProps = {};
// Create a new instance with updated props
function updateWidget(newProps) {
widgetProps = { ...widgetProps, ...newProps };
}
</script>
<div class="dev-container">
<h1>SimpleWidget Development</h1>
<p>This page allows you to test the notepad widget during development.</p>
<div class="controls">
<button on:click={() => updateWidget({ position: "left" })}>
← Left
</button>
<button on:click={() => updateWidget({ open: !widgetProps.open })}>
{widgetProps.open ? "Close " : "Open "}
</button>
<button on:click={() => updateWidget({ position: "right" })}>
Right →
</button>
</div>
<div class="content">
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</p>
<!-- More content... -->
</div>
<Widget {...widgetProps} />
</div>
The repository includes automated tests using Playwright:
test("widget should persist notes when i reload", async ({ page }) => {
await loadTestPage(page, "pages/test.html");
await page.waitForSelector(".widget-tab");
await page.locator(".widget-tab").click();
await page.locator("textarea").fill("This is a test note");
await page.waitForTimeout(500);
await page.reload();
await page.waitForSelector(".widget-tab");
await page.locator(".widget-tab").click();
await expect(page.locator("textarea")).toHaveValue("This is a test note");
});
After building, the widget can be embedded on any website with a single script tag:
<!-- Basic usage -->
<script src="path/to/widget.js" defer></script>
<script>
document.addEventListener("DOMContentLoaded", function () {
SimpleWidget.init();
});
</script>
<!-- With auto-initialization -->
<script
src="path/to/widget.js"
data-heading="Quick Notes"
data-position="left"
data-auto-init="true"
defer
></script>
The widget is compiled as an IIFE (Immediately Invoked Function Expression):
(function () {
// All widget code is contained here
// No global variables except the explicit API
window.SimpleWidget = {
/* API */
};
})();
This pattern:
All styles use specific selectors to avoid affecting the host page:
#simple-widget-root .widget-panel {
/* styles */
}
The widget uses localStorage to persist state across page loads:
// Save state
localStorage.setItem("simpleWidget.notes", notes);
// Load state
notes = localStorage.getItem("simpleWidget.notes") || "";
To build the widget for production:
npm run build
This generates a single file in dist/widget.js
that can be hosted anywhere:
When creating your own embeddable widgets:
This project is licensed under the MIT License - see the LICENSE file for details.
Contributions to improve this tutorial are welcome! Please feel free to submit a Pull Request.