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

$host throws a runtime error if called inside a regular Svelte component. It requires both <svelte:options customElement="tag-name"/> in the file and the customElement: true compiler option in your build config.

$host only works when:

  1. Your component has <svelte:options customElement="tag-name"/> set
  2. The component is compiled with the customElement: true compiler 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 Elements

The <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:

OptionDefaultDescription
bubblesfalseIf true, event bubbles up through ancestor elements
composedfalseIf true, event can cross shadow DOM boundaries
cancelablefalseIf 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 Events

Custom events dispatched inside a Shadow DOM do not cross the boundary into the document by default. Set both bubbles: true and composed: true on 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 Default

Svelte 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:

  1. Slotted content always renders (even inside {#if false})
  2. 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:

  1. $host() returns the host element — Direct access to the DOM node for standard web APIs.

  2. Dispatch real DOM events — Use CustomEvent with bubbles and composed for proper event propagation.

  3. Form integration with ElementInternals — Create custom form elements that fully participate in HTML forms.

  4. Access host properties and attributes — Read, write, and observe changes to the custom element’s attributes.

  5. Shadow DOM encapsulation — Styles and DOM are encapsulated by default, with CSS custom properties for theming.

  6. 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 $host and 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

External Resources