preact-embed
v1.0.0
Published
Modern, lightweight framework for embedding Preact micro-components anywhere in the DOM
Maintainers
Readme
✨ Features
🪶 Impossibly Lightweight
Under 1KB min+gzip. Your widget code will be bigger than the framework.
🔒 Shadow DOM Isolation
Optional style encapsulation. Your CSS stays in, theirs stays out.
🦥 Lazy Loading
Defer rendering until visible with IntersectionObserver. Ship fast, load smart.
🧩 Web Components
Register as native Custom Elements. Works everywhere, no framework required.
⚡ Signals Ready
Optional @preact/signals integration for reactive cross-widget state.
💧 SSR Hydration
First-class server-rendering support. Fast to paint, fast to interact.
📦 Installation
npm install preact-embed preactyarn add preact-embed preactpnpm add preact-embed preact🚀 Quick Start
1. Create your widget
// widgets/PricingCard.tsx
import { h } from 'preact';
import { useState } from 'preact/hooks';
interface Props {
plan?: string;
price?: number;
features?: string[];
}
export function PricingCard({ plan = 'Pro', price = 29, features = [] }: Props) {
const [selected, setSelected] = useState(false);
return (
<div className="pricing-card">
<h2>{plan}</h2>
<p className="price">${price}/mo</p>
<ul>
{features.map(f => <li key={f}>{f}</li>)}
</ul>
<button onClick={() => setSelected(!selected)}>
{selected ? '✓ Selected' : 'Choose Plan'}
</button>
</div>
);
}2. Create the embed
// index.tsx
import embed from 'preact-embed';
import { PricingCard } from './widgets/PricingCard';
const { render } = embed(PricingCard);
render({
selector: '[data-widget="pricing"]',
defaultProps: {
plan: 'Starter',
price: 9,
},
});3. Drop it anywhere
<!-- On any website, CMS, or app -->
<div
data-widget="pricing"
data-prop-plan="Enterprise"
data-prop-price="99"
>
<script type="application/json">
{
"features": ["Unlimited users", "Priority support", "Custom integrations"]
}
</script>
</div>
<script src="https://cdn.example.com/pricing-widget.js" async></script>That's it. Your Preact component now runs anywhere. 🎉
🎯 Examples
import embed from 'preact-embed';
import { NewsletterForm } from './NewsletterForm';
const { render } = embed(NewsletterForm);
render({
selector: '#newsletter-signup',
});<div id="newsletter-signup"></div>Perfect for third-party embeds where you can't control the host page's CSS.
render({
selector: '[data-widget="checkout"]',
shadowRoot: true, // Styles won't leak in or out
});
// Or with options
render({
selector: '[data-widget="checkout"]',
shadowRoot: { mode: 'closed' }, // Extra privacy
});Only load widgets when they scroll into view.
render({
selector: '.below-the-fold-widget',
lazy: true, // Uses IntersectionObserver
});
// With custom threshold
render({
selector: '.below-the-fold-widget',
lazy: {
threshold: 0.5, // 50% visible before loading
rootMargin: '100px', // Start loading 100px before visible
},
});Register your widget as a native HTML element.
import embed from 'preact-embed';
import { VideoPlayer } from './VideoPlayer';
const { render } = embed(VideoPlayer);
render({
tagName: 'x-video-player', // Registers <x-video-player>
shadowRoot: true,
});<!-- Now use it like native HTML! -->
<x-video-player
src="https://example.com/video.mp4"
autoplay="false"
poster="thumbnail.jpg"
></x-video-player>Hydrate server-rendered markup instead of replacing it.
render({
selector: '[data-widget="comments"]',
hydrate: true, // Attaches to existing DOM
});Widgets can talk to each other through the event bus.
// Widget A: User Selector
import { eventBus } from 'preact-embed';
function UserSelector() {
const selectUser = (user) => {
eventBus.emit('user:selected', user);
};
// ...
}
// Widget B: User Profile (different widget, same page)
import { useHabitatEvent } from 'preact-embed';
function UserProfile() {
const [user] = useHabitatEvent('user:selected');
return user ? <Profile user={user} /> : <p>Select a user</p>;
}Share reactive state across widgets without prop drilling.
// Install: npm install @preact/signals
import { getSignal, createStore } from 'preact-embed/signals';
// Global reactive counter (accessible from any widget)
const count = getSignal('cart-count', 0);
function AddToCartButton({ productId }) {
const addToCart = () => {
count.value++;
// All widgets using this signal will update!
};
return <button onClick={addToCart}>Add to Cart ({count})</button>;
}
function CartIcon() {
return <span className="cart-badge">{count}</span>;
}📖 API Reference
embed(Component)
Creates an embed instance for a Preact component.
import embed from 'preact-embed';
const { render, renderOne, unmountAll, instances } = embed(MyWidget);| Return | Type | Description |
|--------|------|-------------|
| render | (options) => WidgetInstance[] | Mount to all matching elements |
| renderOne | (element, props?) => WidgetInstance | Mount to a single element |
| unmountAll | () => void | Destroy all widget instances |
| instances | WidgetInstance[] | Array of active instances |
Render Options
interface EmbedOptions<P> {
// Target elements
selector?: string; // CSS selector to find mount points
inline?: boolean; // Mount in parent of <script> tag
clientSpecified?: boolean; // Read selector from data-mount-in attr
// Props
defaultProps?: Partial<P>; // Default props for all instances
// Rendering
clean?: boolean; // Clear innerHTML before mounting
hydrate?: boolean; // Hydrate SSR markup instead of render
// Advanced
shadowRoot?: boolean | ShadowRootInit; // Enable Shadow DOM
lazy?: boolean | IntersectionObserverInit; // Lazy load on scroll
tagName?: string; // Register as Custom Element
// Lifecycle
onBeforeMount?: (el: Element, props: P) => void | P;
onMounted?: (el: Element, props: P) => void;
onUnmount?: (el: Element) => void;
}Widget Instance
interface WidgetInstance<P> {
element: Element; // The mounted DOM element
props: P; // Current props
update: (props: Partial<P>) => void; // Update props & rerender
unmount: () => void; // Destroy this instance
}🎨 Passing Props
Via Data Attributes
<div
data-widget="pricing"
data-prop-plan="Pro"
data-prop-price="29"
data-prop-is-popular="true"
data-prop-discount-percent="20"
></div>| Attribute | Prop | Value |
|-----------|------|-------|
| data-prop-plan | plan | "Pro" |
| data-prop-price | price | 29 (number) |
| data-prop-is-popular | isPopular | true (boolean) |
| data-prop-discount-percent | discountPercent | 20 (number) |
Auto-parsing: Numbers, booleans (
true/false),null, and JSON are automatically parsed.
Via JSON Script
<div data-widget="pricing">
<script type="application/json">
{
"plan": "Enterprise",
"price": 99,
"features": ["SSO", "Audit logs", "99.99% SLA"],
"contact": {
"email": "[email protected]",
"phone": "1-800-EXAMPLE"
}
}
</script>
</div>Tip: JSON props are great for arrays, objects, and complex data structures.
🏗️ Build Configuration
// vite.config.ts
import { defineConfig } from 'vite';
import preact from '@preact/preset-vite';
export default defineConfig({
plugins: [preact()],
build: {
lib: {
entry: 'src/index.tsx',
name: 'MyWidget',
formats: ['umd', 'es'],
fileName: (format) => `widget.${format}.js`,
},
rollupOptions: {
output: {
globals: {
preact: 'preact',
},
},
},
},
});// webpack.config.js
module.exports = {
entry: './src/index.tsx',
output: {
filename: 'widget.js',
library: {
name: 'MyWidget',
type: 'umd',
},
globalObject: 'this',
},
// ... rest of config
};// tsup.config.ts
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.tsx'],
format: ['cjs', 'esm', 'iife'],
globalName: 'MyWidget',
minify: true,
dts: true,
});📊 Bundle Size
| Module | Size (min+gzip) |
|--------|-----------------|
| preact-embed | ~1.0 KB |
| preact-embed/signals | ~0.5 KB |
| preact-embed/lite | ~0.5 KB |
Compare that to your alternatives:
| Library | Size | |---------|------| | preact-embed | ~1.0 KB | | preact-island | ~1.3 KB | | preact-habitat | ~0.9 KB | | React + ReactDOM | ~45 KB |
🤔 Why preact-embed?
The Problem
You need to embed a Preact component into:
- A client's WordPress site
- A Shopify storefront
- An existing jQuery app
- Any CMS or third-party website
But you can't control the build system, the CSS, or what else is on the page.
The Solution
preact-embed gives you a bulletproof way to:
- Mount anywhere — By selector, inline, or as a Web Component
- Stay isolated — Shadow DOM keeps your styles safe
- Load smart — Lazy loading for below-the-fold widgets
- Communicate — Event bus and Signals for multi-widget pages
- Stay tiny — Under 1KB, so Preact itself is your biggest dependency
🔄 Migration from preact-habitat
Already using preact-habitat? The API is nearly identical:
- import habitat from 'preact-habitat';
+ import embed from 'preact-embed';
- const { render } = habitat(MyWidget);
+ const { render } = embed(MyWidget);
render({
selector: '.my-widget',
+ // New features available:
+ shadowRoot: true,
+ lazy: true,
});🌐 Browser Support
| Browser | Version | |---------|---------| | Chrome | 60+ | | Firefox | 55+ | | Safari | 12+ | | Edge | 79+ |
Note: For IE11 or older browsers, you'll need polyfills for
IntersectionObserver(lazy loading) andcustomElements(Web Components).
🤝 Contributing
Contributions are welcome! Please read our Contributing Guide first.
# Clone the repo
git clone https://github.com/openconjecture/preact-embed.git
# Install dependencies
pnpm install
# Run tests
pnpm test
# Build
pnpm build📄 License
MIT © Garrett Eastham
