Building Framework-Agnostic Components
Custom elements (web components) let you create reusable UI components that work in any framework—or no framework at all. They’re HTML’s native component model, supported by all modern browsers without any build step required for consumers.
Svelte excels at compiling components to custom elements, but there’s a challenge: how does your Svelte component communicate with the outside world? How does it dispatch events to parent elements? How does it access its own DOM element?
That’s where the $host rune comes in. It provides direct access to the custom element’s host element—the actual DOM node—enabling proper event dispatching, attribute manipulation, and integration with browser APIs like forms.
This tutorial covers everything from basic event dispatching to advanced patterns like form integration with ElementInternals, accessibility enhancements, and solving the common pitfalls developers encounter when building custom elements with Svelte.
What Is $host?
The $host rune is a function that returns the host element when a Svelte component is compiled as a custom element. It gives you direct access to the underlying DOM node, which is essential for:
- Dispatching custom events to parent elements
- Accessing and manipulating host attributes
- Integrating with browser APIs that require DOM element references
- Implementing proper accessibility patterns
When Does $host Work?
$host Only Works in Custom Elements
$hostthrows a runtime error if called inside a regular Svelte component. It requires both<svelte:options customElement="tag-name"/>in the file and thecustomElement: truecompiler option in your build config.
$host only works when:
- Your component has
<svelte:options customElement="tag-name"/>set - The component is compiled with the
customElement: truecompiler option
If you try to use $host in a regular Svelte component, it will throw an error.
Basic Syntax
<svelte:options customElement="my-component" />
<script>
// $host() returns the HTMLElement (the custom element itself)
const host = $host()
// Now you can use standard DOM APIs
host.dispatchEvent(new CustomEvent('my-event'))
host.setAttribute('data-ready', 'true')
host.classList.add('initialized')
</script> Dispatching Custom Events
The most common use case for $host is dispatching custom events. This is how your custom element communicates with the outside world.
Why Can’t You Just Use createEventDispatcher?
In regular Svelte components, you might use Svelte’s event system. But custom elements live in the DOM—they need to dispatch real DOM events that any JavaScript code can listen to, not Svelte-specific events.
Basic Event Dispatching
Here’s a classic stepper component that dispatches increment and decrement events:
<!-- Stepper.svelte -->
<svelte:options customElement="my-stepper" />
<script>
function dispatch(type) {
$host().dispatchEvent(new CustomEvent(type))
}
</script>
<button onclick={() => dispatch('decrement')}>−</button>
<button onclick={() => dispatch('increment')}>+</button>
<style>
button {
font-size: 1.5rem;
padding: 0.5rem 1rem;
cursor: pointer;
}
</style> Using the component:
<!-- App.svelte -->
<script>
import './Stepper.svelte'
let count = $state(0)
</script>
<my-stepper ondecrement={() => count--} onincrement={() => count++}></my-stepper>
<p>Count: {count}</p> Notice that <my-stepper> doesn’t wrap any content—it’s empty. That’s because this component is self-contained: it renders its own buttons internally. There’s no <slot> element inside Stepper.svelte, so the component doesn’t accept children. If you wanted to create a component that wraps content (like a card or tooltip), you’d add <slot></slot> to your component’s markup and then place content between the opening and closing tags.
Slots in Custom ElementsThe
<slot>element used in custom elements is the native Web Components slot, not Svelte’s deprecated<slot>. In regular Svelte 5 components, you’d use snippets and{ @render }for content projection. However, when building custom elements, you must use the native<slot>because that’s what the browser’s Shadow DOM expects.
Or in plain JavaScript:
<my-stepper></my-stepper>
<p id="count">0</p>
<script type="module">
import './Stepper.svelte'
let count = 0
const stepper = document.querySelector('my-stepper')
const display = document.querySelector('#count')
stepper.addEventListener('increment', () => {
count++
display.textContent = count
})
stepper.addEventListener('decrement', () => {
count--
display.textContent = count
})
</script> Events with Data (detail)
Custom events can carry data in their detail property:
<!-- ColorPicker.svelte -->
<svelte:options customElement="color-picker" />
<script>
let { value = '#000000' } = $props()
function handleChange(event) {
const newColor = event.target.value
$host().dispatchEvent(
new CustomEvent('colorchange', {
detail: {
color: newColor,
previousColor: value
},
bubbles: true, // Event bubbles up the DOM
composed: true // Event crosses shadow DOM boundary
})
)
value = newColor
}
</script>
<input type="color" {value} onchange={handleChange} />
<span class="preview" style:background-color={value}></span>
<style>
.preview {
display: inline-block;
width: 2rem;
height: 2rem;
border-radius: 4px;
vertical-align: middle;
margin-left: 0.5rem;
}
</style> Listening for the event:
document.querySelector('color-picker').addEventListener('colorchange', (e) => {
console.log('New color:', e.detail.color)
console.log('Previous color:', e.detail.previousColor)
}) Event Options Explained
When creating custom events, you have several options:
| Option | Default | Description |
|---|---|---|
bubbles | false | If true, event bubbles up through ancestor elements |
composed | false | If true, event can cross shadow DOM boundaries |
cancelable | false | If true, event can be canceled with preventDefault() |
When to use bubbles: true:
<script>
// Use bubbles when parent elements need to catch the event
function notifyParents() {
$host().dispatchEvent(
new CustomEvent('status-change', {
detail: { status: 'complete' },
bubbles: true,
composed: true
})
)
}
</script> When to use cancelable: true:
<script>
function attemptDelete() {
const event = new CustomEvent('beforedelete', {
detail: { itemId: 123 },
cancelable: true,
bubbles: true,
composed: true
})
$host().dispatchEvent(event)
// Check if any listener called preventDefault()
if (!event.defaultPrevented) {
performDelete()
}
}
</script> Accessing Host Element Properties
Beyond events, $host gives you full access to the host element’s properties and methods.
Reading Attributes
<svelte:options customElement="themed-button" />
<script>
import { onMount } from 'svelte'
let theme = $state('default')
onMount(() => {
const host = $host()
// Read an attribute set on the custom element
theme = host.getAttribute('data-theme') || 'default'
// Watch for attribute changes
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.attributeName === 'data-theme') {
theme = host.getAttribute('data-theme') || 'default'
}
}
})
observer.observe(host, { attributes: true })
return () => observer.disconnect()
})
</script>
<button class="theme-{theme}">
<slot></slot>
</button> Manipulating Classes and Styles
<svelte:options customElement="loading-indicator" />
<script>
let { loading = false } = $props()
$effect(() => {
const host = $host()
if (loading) {
host.classList.add('is-loading')
host.setAttribute('aria-busy', 'true')
} else {
host.classList.remove('is-loading')
host.setAttribute('aria-busy', 'false')
}
})
</script>
<div class="spinner" class:visible={loading}></div>
<slot></slot> Getting Computed Styles
<svelte:options customElement="responsive-grid" />
<script>
import { onMount } from 'svelte'
let columns = $state(3)
onMount(() => {
const host = $host()
function updateColumns() {
const width = host.getBoundingClientRect().width
if (width < 400) columns = 1
else if (width < 700) columns = 2
else columns = 3
}
const resizeObserver = new ResizeObserver(updateColumns)
resizeObserver.observe(host)
return () => resizeObserver.disconnect()
})
</script>
<div class="grid" style:--columns={columns}>
<slot></slot>
</div>
<style>
.grid {
display: grid;
grid-template-columns: repeat(var(--columns), 1fr);
gap: 1rem;
}
</style> Form Integration with ElementInternals
One of the most powerful uses of $host is integrating custom elements with HTML forms using the ElementInternals API.
The Problem
By default, custom elements don’t participate in form submission. If you create a custom input element, it won’t:
- Submit its value with the form
- Participate in form validation
- Be reset when the form resets
- Be accessible to
FormData
The Solution: ElementInternals
Using the extend option in <svelte:options>, you can create form-associated custom elements:
<!-- CustomInput.svelte -->
<svelte:options
customElement={{
tag: 'custom-input',
props: {
value: { reflect: true },
name: { reflect: true, attribute: 'name' },
required: { reflect: true, type: 'Boolean' }
},
extend: (customElementConstructor) => {
return class extends customElementConstructor {
static formAssociated = true
constructor() {
super()
this.internals = this.attachInternals()
}
}
}
}}
/>
<script>
let { value = '', name = '', required = false, internals } = $props()
let touched = $state(false)
let error = $state('')
// Update form value whenever our value changes
$effect(() => {
const host = $host()
// Set the form value
internals.setFormValue(value)
// Validate
if (required && !value && touched) {
error = 'This field is required'
internals.setValidity({ valueMissing: true }, error)
} else {
error = ''
internals.setValidity({})
}
})
function handleInput(event) {
value = event.target.value
}
function handleBlur() {
touched = true
}
</script>
<div class="input-wrapper">
<input type="text" {value} oninput={handleInput} onblur={handleBlur} aria-invalid={!!error} />
{#if error}
<span class="error">{error}</span>
{/if}
</div>
<style>
.input-wrapper {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
input {
padding: 0.5rem;
border: 1px solid #ccc;
border-radius: 4px;
}
input[aria-invalid='true'] {
border-color: #e74c3c;
}
.error {
color: #e74c3c;
font-size: 0.875rem;
}
</style> Using in a form:
<form id="myForm">
<custom-input name="username" required></custom-input>
<custom-input name="email"></custom-input>
<button type="submit">Submit</button>
</form>
<script>
document.getElementById('myForm').addEventListener('submit', (e) => {
e.preventDefault()
const formData = new FormData(e.target)
// FormData now includes custom-input values!
console.log('Username:', formData.get('username'))
console.log('Email:', formData.get('email'))
})
</script> Complete Form Element Example
Here’s a more complete custom select element with full form integration:
<!-- CustomSelect.svelte -->
<svelte:options
customElement={{
tag: 'custom-select',
shadow: 'open',
props: {
value: { reflect: true },
name: { reflect: true },
required: { reflect: true, type: 'Boolean' },
disabled: { reflect: true, type: 'Boolean' }
},
extend: (ctor) =>
class extends ctor {
static formAssociated = true
constructor() {
super()
this.internals = this.attachInternals()
}
// Called when the form is reset
formResetCallback() {
this.value = ''
}
// Called when form state is restored (e.g., back button)
formStateRestoreCallback(state) {
this.value = state
}
// Called when disabled state changes via fieldset
formDisabledCallback(disabled) {
this.disabled = disabled
}
}
}}
/>
<script>
let { value = '', name = '', required = false, disabled = false, internals } = $props()
let { options = [] } = $props()
let open = $state(false)
let selectedLabel = $derived(
options.find((opt) => opt.value === value)?.label || 'Select an option'
)
$effect(() => {
internals.setFormValue(value)
if (required && !value) {
internals.setValidity({ valueMissing: true }, 'Please select an option')
} else {
internals.setValidity({})
}
})
function select(optionValue) {
value = optionValue
open = false
$host().dispatchEvent(
new CustomEvent('change', {
detail: { value: optionValue },
bubbles: true,
composed: true
})
)
}
function handleKeydown(event) {
if (event.key === 'Enter' || event.key === ' ') {
open = !open
} else if (event.key === 'Escape') {
open = false
}
}
</script>
<div
class="select"
class:open
class:disabled
role="combobox"
aria-expanded={open}
aria-haspopup="listbox"
tabindex={disabled ? -1 : 0}
onkeydown={handleKeydown}
onclick={() => !disabled && (open = !open)}
>
<span class="selected">{selectedLabel}</span>
<span class="arrow">▼</span>
</div>
{#if open}
<ul class="options" role="listbox">
{#each options as option}
<li role="option" aria-selected={option.value === value} onclick={() => select(option.value)}>
{option.label}
</li>
{/each}
</ul>
{/if}
<style>
.select {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem;
border: 1px solid #ccc;
border-radius: 4px;
cursor: pointer;
background: white;
}
.select.disabled {
background: #f5f5f5;
cursor: not-allowed;
opacity: 0.6;
}
.select:focus {
outline: 2px solid #3498db;
outline-offset: 2px;
}
.arrow {
font-size: 0.75rem;
transition: transform 0.2s;
}
.select.open .arrow {
transform: rotate(180deg);
}
.options {
position: absolute;
top: 100%;
left: 0;
right: 0;
margin: 0;
padding: 0;
list-style: none;
border: 1px solid #ccc;
border-radius: 4px;
background: white;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
z-index: 100;
}
.options li {
padding: 0.5rem;
cursor: pointer;
}
.options li:hover {
background: #f0f0f0;
}
.options li[aria-selected='true'] {
background: #3498db;
color: white;
}
</style> Real-World Patterns
1: Tooltip Component
A tooltip that positions itself relative to its host:
<!-- Tooltip.svelte -->
<svelte:options customElement="tool-tip" />
<script>
let { text = '', position = 'top' } = $props()
let visible = $state(false)
let coords = $state({ x: 0, y: 0 })
function show() {
const host = $host()
const rect = host.getBoundingClientRect()
switch (position) {
case 'top':
coords = { x: rect.left + rect.width / 2, y: rect.top }
break
case 'bottom':
coords = { x: rect.left + rect.width / 2, y: rect.bottom }
break
case 'left':
coords = { x: rect.left, y: rect.top + rect.height / 2 }
break
case 'right':
coords = { x: rect.right, y: rect.top + rect.height / 2 }
break
}
visible = true
}
function hide() {
visible = false
}
// Set up event listeners on mount
$effect(() => {
const host = $host()
host.addEventListener('mouseenter', show)
host.addEventListener('mouseleave', hide)
host.addEventListener('focus', show)
host.addEventListener('blur', hide)
return () => {
host.removeEventListener('mouseenter', show)
host.removeEventListener('mouseleave', hide)
host.removeEventListener('focus', show)
host.removeEventListener('blur', hide)
}
})
</script>
<slot></slot>
{#if visible}
<div class="tooltip {position}" style:--x="{coords.x}px" style:--y="{coords.y}px" role="tooltip">
{text}
</div>
{/if}
<style>
.tooltip {
position: fixed;
left: var(--x);
top: var(--y);
background: #333;
color: white;
padding: 0.5rem 0.75rem;
border-radius: 4px;
font-size: 0.875rem;
white-space: nowrap;
pointer-events: none;
z-index: 1000;
}
.tooltip.top {
transform: translate(-50%, -100%) translateY(-8px);
}
.tooltip.bottom {
transform: translate(-50%, 0) translateY(8px);
}
.tooltip.left {
transform: translate(-100%, -50%) translateX(-8px);
}
.tooltip.right {
transform: translate(0, -50%) translateX(8px);
}
</style> Usage:
<tool-tip text="Click to save your progress" position="top">
<button>Save</button>
</tool-tip> 2: Modal Dialog
A modal that properly manages focus and accessibility:
<!-- Modal.svelte -->
<svelte:options customElement="modal-dialog" />
<script>
let { open = false, title = '' } = $props()
let previouslyFocused = $state(null)
let dialogRef = $state(null)
$effect(() => {
if (open) {
// Store currently focused element
previouslyFocused = document.activeElement
// Focus the dialog
requestAnimationFrame(() => {
dialogRef?.focus()
})
// Prevent body scroll
document.body.style.overflow = 'hidden'
// Notify that modal opened
$host().dispatchEvent(new CustomEvent('open', { bubbles: true }))
} else if (previouslyFocused) {
// Restore focus
previouslyFocused.focus()
previouslyFocused = null
// Restore body scroll
document.body.style.overflow = ''
// Notify that modal closed
$host().dispatchEvent(new CustomEvent('close', { bubbles: true }))
}
})
function close() {
$host().dispatchEvent(
new CustomEvent('requestclose', {
bubbles: true,
cancelable: true
})
)
}
function handleKeydown(event) {
if (event.key === 'Escape') {
close()
}
}
function handleBackdropClick(event) {
if (event.target === event.currentTarget) {
close()
}
}
</script>
{#if open}
<div class="backdrop" onclick={handleBackdropClick} onkeydown={handleKeydown}>
<div
class="dialog"
role="dialog"
aria-modal="true"
aria-labelledby="dialog-title"
tabindex="-1"
bind:this={dialogRef}
>
<header>
<h2 id="dialog-title">{title}</h2>
<button class="close-btn" onclick={close} aria-label="Close dialog"> × </button>
</header>
<div class="content">
<slot></slot>
</div>
<footer>
<slot name="footer"></slot>
</footer>
</div>
</div>
{/if}
<style>
.backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.dialog {
background: white;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
max-width: 90vw;
max-height: 90vh;
overflow: auto;
min-width: 300px;
}
.dialog:focus {
outline: none;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid #eee;
}
header h2 {
margin: 0;
font-size: 1.25rem;
}
.close-btn {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
padding: 0.25rem 0.5rem;
line-height: 1;
}
.content {
padding: 1rem;
}
footer {
padding: 1rem;
border-top: 1px solid #eee;
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
footer:empty {
display: none;
}
</style> Usage:
<modal-dialog id="confirm" title="Confirm Action">
<p>Are you sure you want to proceed?</p>
<div slot="footer">
<button onclick="this.closest('modal-dialog').open = false">Cancel</button>
<button onclick="handleConfirm()">Confirm</button>
</div>
</modal-dialog>
<button onclick="document.getElementById('confirm').open = true">Open Modal</button> 3: Accordion Component
An accordion with proper ARIA support:
<!-- Accordion.svelte -->
<svelte:options customElement="ui-accordion" />
<script>
let { multiple = false } = $props()
let openPanels = $state(new Set())
function toggle(panelId) {
if (openPanels.has(panelId)) {
openPanels.delete(panelId)
} else {
if (!multiple) {
openPanels.clear()
}
openPanels.add(panelId)
}
// Force reactivity update
openPanels = new Set(openPanels)
$host().dispatchEvent(
new CustomEvent('toggle', {
detail: {
panelId,
open: openPanels.has(panelId),
openPanels: [...openPanels]
},
bubbles: true
})
)
}
function isOpen(panelId) {
return openPanels.has(panelId)
}
// Expose methods on the host element
$effect(() => {
const host = $host()
host.open = (panelId) => {
if (!openPanels.has(panelId)) {
toggle(panelId)
}
}
host.close = (panelId) => {
if (openPanels.has(panelId)) {
toggle(panelId)
}
}
host.toggle = toggle
host.isOpen = isOpen
})
</script>
<div class="accordion" role="tablist">
<slot {toggle} {isOpen}></slot>
</div>
<style>
.accordion {
border: 1px solid #ddd;
border-radius: 4px;
overflow: hidden;
}
</style> <!-- AccordionPanel.svelte -->
<svelte:options customElement="accordion-panel" />
<script>
let { id, title, open = false } = $props()
function handleClick() {
// Find parent accordion and tell it to toggle this panel
const accordion = $host().closest('ui-accordion')
accordion?.toggle?.(id)
}
function handleKeydown(event) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
handleClick()
}
}
</script>
<div class="panel" class:open>
<button
class="header"
onclick={handleClick}
onkeydown={handleKeydown}
aria-expanded={open}
aria-controls="panel-{id}"
id="header-{id}"
>
<span>{title}</span>
<span class="icon">{open ? '−' : '+'}</span>
</button>
<div class="content" id="panel-{id}" role="region" aria-labelledby="header-{id}" hidden={!open}>
<div class="inner">
<slot></slot>
</div>
</div>
</div>
<style>
.panel {
border-bottom: 1px solid #ddd;
}
.panel:last-child {
border-bottom: none;
}
.header {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
background: #f9f9f9;
border: none;
cursor: pointer;
font-size: 1rem;
text-align: left;
}
.header:hover {
background: #f0f0f0;
}
.content {
overflow: hidden;
transition: max-height 0.3s ease;
}
.content[hidden] {
display: none;
}
.inner {
padding: 1rem;
}
.icon {
font-weight: bold;
}
</style> Common Struggles and Solutions
1: $host Is Undefined
Problem: You’re getting an error that $host is undefined or can’t be called.
Cause: You’re using $host in a component that isn’t compiled as a custom element.
Solution:
<!-- AVOID: Missing customElement option -->
<script>
const host = $host() // Error!
</script>
<!-- PREFERRED: Add the customElement option -->
<svelte:options customElement="my-component" />
<script>
const host = $host() // Works!
</script> Also ensure your build is configured correctly:
// svelte.config.js
export default {
compilerOptions: {
customElement: true
}
} 2: Events Don’t Reach Parent Elements
Always Use bubbles: true, composed: true for Public EventsCustom events dispatched inside a Shadow DOM do not cross the boundary into the document by default. Set both
bubbles: trueandcomposed: trueon every event your custom element intends to expose publicly — otherwise parent elements and vanilla JS consumers will never see them.
Problem: Custom events dispatched from your component aren’t being caught by parent elements.
Cause: Events don’t bubble or cross shadow DOM by default.
Solution:
<script>
function dispatchEvent() {
// AVOID: Won't reach elements outside shadow DOM
$host().dispatchEvent(new CustomEvent('myevent'))
// PREFERRED: Bubbles up AND crosses shadow DOM boundary
$host().dispatchEvent(
new CustomEvent('myevent', {
bubbles: true,
composed: true
})
)
}
</script> 3: Props Not Accessible as Attributes
Problem: Setting attributes on your custom element doesn’t update the component’s props.
Cause: By default, only explicitly declared props become element properties.
Solution: Use the props configuration:
<svelte:options
customElement={{
tag: 'my-element',
props: {
count: { type: 'Number', reflect: true },
label: { attribute: 'data-label' },
disabled: { type: 'Boolean', reflect: true }
}
}}
/>
<script>
let { count = 0, label = '', disabled = false } = $props()
</script> Now:
<!-- These all work -->
<my-element count="5" data-label="Hello" disabled></my-element> 4: Styles Leak In or Out
Shadow DOM Encapsulation Is On by DefaultSvelte custom elements use an open Shadow DOM by default, which fully encapsulates styles. If you see style leakage, check whether you’ve explicitly set
shadow: 'none'in<svelte:options>. Use CSS custom properties (--my-var) to allow controlled theming from the outside.
Problem: Global styles affect your component, or your component’s styles leak out.
Cause: Shadow DOM encapsulation isn’t working as expected.
Solution 1: Ensure shadow DOM is enabled (default):
<svelte:options customElement="my-element" />
<!-- Shadow DOM is enabled by default --> Solution 2: If you disabled shadow DOM intentionally:
<svelte:options
customElement={{
tag: 'my-element',
shadow: 'none' <!-- No encapsulation! -->
}}
/>
<!-- Use unique class names or CSS custom properties -->
<style>
.my-element-wrapper { /* ... */ }
.my-element-button { /* ... */ }
</style> 5: Slots Don’t Work as Expected
Problem: Slotted content renders even when it shouldn’t, or doesn’t render multiple times in {#each}.
Cause: DOM slots work differently than Svelte slots.
Important limitations:
- Slotted content always renders (even inside
{#if false}) - Slots in
{#each}don’t duplicate the content
Solution: Design around these limitations:
<!-- Instead of conditionally showing slots... -->
{#if showContent}
<slot></slot> <!-- Content still renders in DOM! -->
{/if}
<!-- ...use CSS to hide/show -->
<div class:hidden={!showContent}>
<slot></slot>
</div>
<style>
.hidden {
display: none;
}
</style> 6: Context Doesn’t Work Across Custom Elements
Problem: setContext in a parent custom element can’t be read with getContext in a child custom element.
Cause: Context is component-based, not DOM-based. Each custom element is an isolated Svelte application.
Solution: Use DOM-based communication instead:
<!-- Parent.svelte -->
<svelte:options customElement="parent-element" />
<script>
let sharedData = $state({ theme: 'dark' })
$effect(() => {
// Expose data on the DOM element
$host().sharedData = sharedData
})
</script>
<slot></slot> <!-- Child.svelte -->
<svelte:options customElement="child-element" />
<script>
import { onMount } from 'svelte'
let parentData = $state(null)
onMount(() => {
// Find parent and access its data
const parent = $host().closest('parent-element')
if (parent) {
parentData = parent.sharedData
}
})
</script> 7: Component Methods Not Available Immediately
Problem: Calling methods on a custom element right after creating it fails.
Cause: The Svelte component inside isn’t created until the next tick after connectedCallback.
Solution 1: Wait for the component to mount:
const element = document.createElement('my-element')
document.body.appendChild(element)
// AVOID: Component not ready yet
element.doSomething() // Error!
// PREFERRED: Wait for next tick
await new Promise((resolve) => setTimeout(resolve, 0))
element.doSomething() // Works! Solution 2: Use the extend option for always-available methods:
<svelte:options
customElement={{
tag: 'my-element',
extend: (ctor) =>
class extends ctor {
// This method is available immediately
doSomething() {
// Can queue work for when component mounts
this._pendingActions = this._pendingActions || []
this._pendingActions.push(['doSomething', arguments])
}
}
}}
/> Best Practices
1. Always Use bubbles and composed for Public Events
<script>
function emitChange(value) {
$host().dispatchEvent(
new CustomEvent('change', {
detail: { value },
bubbles: true, // Bubbles up the DOM
composed: true // Crosses shadow DOM
})
)
}
</script> 2. Prefix Custom Event Names
Avoid conflicts with native DOM events:
<script>
// AVOID: Could conflict with native events
$host().dispatchEvent(new CustomEvent('change'))
// PREFERRED: Prefixed to avoid conflicts
$host().dispatchEvent(new CustomEvent('mycomponent:change'))
</script> 3. Document Your Custom Element’s API
Create a clear contract for consumers:
<!--
@element my-counter
@description A simple counter component
@prop {number} value - Current count value (default: 0)
@prop {number} min - Minimum allowed value
@prop {number} max - Maximum allowed value
@prop {boolean} disabled - Whether the counter is disabled
@event increment - Fired when count increases
@event decrement - Fired when count decreases
@event change - Fired when count changes (detail: { value })
@method reset() - Reset counter to initial value
@method setValue(n) - Set counter to specific value
-->
<svelte:options customElement="my-counter" /> 4. Use CSS Custom Properties for Theming
Allow consumers to customize appearance without breaking encapsulation:
<style>
button {
background: var(--button-bg, #3498db);
color: var(--button-color, white);
padding: var(--button-padding, 0.5rem 1rem);
border-radius: var(--button-radius, 4px);
}
</style> <style>
my-button {
--button-bg: #e74c3c;
--button-color: white;
}
</style>
<my-button>Styled Button</my-button> 5. Handle Cleanup Properly
When using $host with event listeners or observers:
<script>
$effect(() => {
const host = $host()
const handler = () => console.log('clicked')
host.addEventListener('click', handler)
// Always clean up!
return () => {
host.removeEventListener('click', handler)
}
})
</script> 6. Make Components Progressively Enhanced
Design components that work even before JavaScript loads:
<!-- Works without JS (shows native select) -->
<custom-select>
<select>
<option value="a">Option A</option>
<option value="b">Option B</option>
</select>
</custom-select> <!-- CustomSelect.svelte -->
<script>
import { onMount } from 'svelte'
let options = $state([])
onMount(() => {
// Read options from light DOM
const select = $host().querySelector('select')
if (select) {
options = [...select.options].map((opt) => ({
value: opt.value,
label: opt.textContent
}))
select.hidden = true
}
})
</script> Quick Reference
Basic $host Usage
// Get the host element
const host = $host()
// Dispatch events
host.dispatchEvent(
new CustomEvent('myevent', {
detail: { data: 'value' },
bubbles: true,
composed: true
})
)
// Access attributes
host.getAttribute('data-id')
host.setAttribute('data-ready', 'true')
// Manipulate classes
host.classList.add('active')
host.classList.remove('loading')
// Access dimensions
host.getBoundingClientRect() Custom Element Configuration
<svelte:options
customElement={{
tag: 'my-element',
shadow: 'open', // 'open', 'none'
props: {
propName: {
attribute: 'attr-name', // HTML attribute name
reflect: true, // Reflect to attribute
type: 'String' // 'String', 'Number', 'Boolean', 'Array', 'Object'
}
},
extend: (ctor) =>
class extends ctor {
// Custom element class extensions
}
}}
/> Form-Associated Custom Element
<svelte:options
customElement={{
tag: 'form-input',
extend: (ctor) =>
class extends ctor {
static formAssociated = true
constructor() {
super()
this.internals = this.attachInternals()
}
formResetCallback() {
/* ... */
}
formStateRestoreCallback(state) {
/* ... */
}
formDisabledCallback(disabled) {
/* ... */
}
}
}}
/>
<script>
let { internals } = $props()
$effect(() => {
internals.setFormValue(value)
internals.setValidity(validityState, message)
})
</script> Key Takeaways
The $host rune unlocks the full power of custom elements in Svelte:
$host()returns the host element — Direct access to the DOM node for standard web APIs.Dispatch real DOM events — Use
CustomEventwithbubblesandcomposedfor proper event propagation.Form integration with
ElementInternals— Create custom form elements that fully participate in HTML forms.Access host properties and attributes — Read, write, and observe changes to the custom element’s attributes.
Shadow DOM encapsulation — Styles and DOM are encapsulated by default, with CSS custom properties for theming.
Framework-agnostic output — Your Svelte components become standard web components usable anywhere.
Why Use Custom Elements?Custom elements are a powerful way to create reusable, encapsulated components that work across frameworks and in plain HTML. With Svelte’s
$hostand custom element support, you can build components that:
- Are framework-agnostic and can be used in any web environment
- Have encapsulated styles and behavior with Shadow DOM
- Integrate seamlessly with HTML forms using
ElementInternals- Provide a great developer experience with Svelte’s reactivity and compilation
- Can be published as standalone packages on npm or used directly in the browser
- Custom elements with Shadow DOM have isolated styles, preventing CSS conflicts and reducing style recalculation costs
Custom elements are perfect for design systems, embeddable widgets, and any component that needs to work across different frameworks or in plain HTML. With $host and Svelte’s excellent compilation, you get the best of both worlds: a great developer experience and truly portable components.
See Also
Official Documentation
- Svelte 5: $host - Official reference
- Svelte: Custom Elements - Custom elements guide
- MDN: Custom Elements - Web Components API
- Svelte REPL - Try code online
External Resources
- MDN: Shadow DOM - Shadow DOM encapsulation
- MDN: ElementInternals - Form integration
- Svelte Society - Community resources
- Svelte Discord - Get help