The Component-Level Compiler Configuration Problem
When building sophisticated Svelte applications, you inevitably encounter situations where global compiler settings don’t quite fit every component’s needs. Perhaps you’re building a component library that must work regardless of how consuming projects are configured. Maybe you need to compile a specific component as a Web Component for consumption in a vanilla JavaScript application. Or you might be working with SVG graphics that require a different rendering namespace than your HTML components.
The <svelte:options> special element exists precisely for these scenarios. It provides a declarative mechanism for specifying per-component compiler options directly within your .svelte files, overriding whatever global settings you’ve configured in your svelte.config.js or bundler configuration. This granular control becomes indispensable as applications grow in complexity and must accommodate diverse requirements across different components.
Unlike the other special elements in Svelte that primarily affect runtime behavior (like <svelte:window> for window event binding or <svelte:head> for document head manipulation), <svelte:options> operates entirely at compile time. The options you specify influence how the Svelte compiler transforms your component source code into JavaScript, but they leave no trace in the final runtime output. This distinction is crucial for understanding when and how to leverage this powerful element.
Anatomy of the <svelte:options> Element
The <svelte:options> element uses a straightforward attribute-based syntax for configuration:
<svelte:options option={value} /> This self-closing element must appear at the top level of your component file—it cannot be nested inside other elements or control flow blocks. While there’s no strict requirement about its position relative to <script> or <style> tags, conventional placement at the very beginning of the file (before the script tag) enhances discoverability and makes the component’s special compilation requirements immediately apparent to anyone reading the code.
Important: Content inside <svelte:options> is forbidden. Unlike other elements, you cannot place any content between opening and closing tags—always use the self-closing form.
The element accepts several attributes, each controlling a different aspect of compilation:
runes={true}— forces a component into runes moderunes={false}— forces a component into legacy modenamespace="..."— the namespace where this component will be used:"html"(default),"svg", or"mathml"customElement={...}— options for compiling as a custom element (string for tag name, or object for detailed configuration)css="injected"— injects styles inline rather than extracting them
Let’s examine each option in depth, understanding not just what it does but why and when you’d want to use it.
Controlling Runes Mode: Ensuring Predictable Behavior
The runes option gives you explicit control over which reactivity system a component uses:
<svelte:options runes={true} />
<script>
// This component will always use runes regardless of project configuration
let count = $state(0)
let doubled = $derived(count * 2)
function increment() {
count++
}
</script>
<button onclick={increment}>
Count: {count} (Doubled: {doubled})
</button> When you set runes={true}, you’re instructing the compiler to parse and transform this component using Svelte 5’s runes system, regardless of your project’s global configuration.
The Default Inference Behavior
When you omit the runes option entirely, the compiler employs intelligent inference. It examines your component’s source code for telltale signs of runes usage—$state, $derived, $effect, $props, and similar constructs. If it finds any rune declarations, it compiles the component in runes mode; otherwise, it falls back to legacy mode. This auto-detection works remarkably well for most scenarios but can occasionally produce surprising results when a component is ambiguous.
Critical Note for Library Authors: Setting runes: true in your svelte.config.js forces runes mode for your entire project, including components in node_modules. This can break third-party libraries. Instead, explicitly set <svelte:options runes={true} /> in each of your library components to ensure they behave consistently regardless of how consuming applications are configured.
Namespace Configuration: Beyond HTML
While most Svelte components render HTML elements, the framework also supports SVG and MathML content. The namespace option tells the compiler which XML namespace governs the component’s markup, affecting how elements are parsed and validated.
Working with SVG Components
SVG (Scalable Vector Graphics) operates under different rules than HTML. Element names are case-sensitive, certain attributes use different naming conventions (like viewBox instead of viewbox), and the DOM API for creating elements differs. When building a component that renders pure SVG content, you should declare the namespace explicitly:
<svelte:options namespace="svg" />
<script>
let { width = 100, height = 100, color = '#3498db' } = $props()
</script>
<!-- These are SVG elements, not HTML elements with the same names -->
<svg {width} {height} viewBox="0 0 100 100">
<circle cx="50" cy="50" r="40" fill={color} stroke="black" stroke-width="2" />
<text x="50" y="55" text-anchor="middle" fill="white" font-size="16"> SVG </text>
</svg> Without the namespace declaration, the compiler might misinterpret certain elements or generate incorrect DOM manipulation code. For instance, SVG elements must be created using document.createElementNS() with the SVG namespace URI rather than document.createElement().
Pure SVG Fragment Components
When creating reusable SVG components that will be composed within a parent <svg> element, you can create components that render SVG fragments without their own root <svg> tag:
<svelte:options namespace="svg" />
<script>
let { x = 0, y = 0, size = 20, fill = 'currentColor' } = $props()
</script>
<!-- This component renders a star shape as SVG path elements -->
<g transform="translate({x}, {y})">
<polygon
points="10,0 13,7 20,7 14,12 16,20 10,15 4,20 6,12 0,7 7,7"
{fill}
transform="scale({size / 20})"
/>
</g> This component can then be composed within a parent SVG:
<script>
import Star from './Star.svelte'
</script>
<svg width="200" height="100" viewBox="0 0 200 100">
<Star x={20} y={40} fill="gold" />
<Star x={80} y={40} fill="silver" />
<Star x={140} y={40} fill="bronze" />
</svg> MathML Support
For mathematical notation, Svelte supports the MathML namespace:
<svelte:options namespace="mathml" />
<script>
let { a = 1, b = 2, c = 1 } = $props()
let discriminant = $derived(b * b - 4 * a * c)
</script>
<!-- The quadratic formula -->
<math display="block">
<mrow>
<mi>x</mi>
<mo>=</mo>
<mfrac>
<mrow>
<mo>-</mo>
<mi>b</mi>
<mo>±</mo>
<msqrt>
<mrow>
<msup><mi>b</mi><mn>2</mn></msup>
<mo>-</mo>
<mn>4</mn>
<mi>a</mi>
<mi>c</mi>
</mrow>
</msqrt>
</mrow>
<mrow>
<mn>2</mn>
<mi>a</mi>
</mrow>
</mfrac>
</mrow>
</math>
<p>With a={a}, b={b}, c={c}: discriminant = {discriminant}</p> MathML components are less common but invaluable for educational applications, scientific documentation, or any context requiring proper mathematical typesetting that CSS alone cannot achieve.
CSS Injection Strategy: The css="injected" Option
Svelte typically extracts component styles into separate CSS files during compilation—your bundler then handles combining and loading these styles. However, certain scenarios benefit from a different approach where styles are embedded directly into the JavaScript bundle and injected at runtime.
<svelte:options css="injected" />
<script>
let { variant = 'primary', children } = $props()
</script>
<button class="custom-button {variant}">
{@render children()}
</button>
<style>
.custom-button {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 4px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.custom-button.primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.custom-button.secondary {
background: #e2e8f0;
color: #475569;
}
.custom-button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
</style> How Injected CSS Works
With css="injected", the behavior differs between server-side rendering and client-side rendering:
During SSR: The component’s styles are rendered as a <style> tag in the document’s <head> section, ensuring styles are available immediately when the HTML is parsed.
During CSR: The styles are included in the JavaScript bundle and injected into the document dynamically when the component mounts. Svelte is smart enough to check whether the styles have already been injected (preventing duplicate style blocks when multiple instances of the same component exist).
When to Use Injected CSS
This option proves particularly useful in several scenarios:
Component Libraries: When distributing a component library, injected CSS means consumers don’t need to configure their build tools to handle your CSS. The styles “just work” when they import your component.
Dynamically Loaded Components: For code-split components loaded on demand, injected CSS ensures styles arrive with the component code rather than requiring a separate CSS chunk to be loaded.
Micro-Frontend Architectures: When embedding Svelte components into larger applications built with different frameworks, injected CSS eliminates coordination challenges around style loading.
Custom Elements: When compiling components as web components (custom elements), css="injected" is automatically enabled since styles must be injected into the shadow DOM.
Trade-offs to Consider
Injected CSS isn’t universally superior. Consider these implications:
Bundle Size: Styles become part of your JavaScript bundle, increasing its size. For large applications, extracted CSS that can be cached separately often performs better.
Flash of Unstyled Content (FOUC): In client-only rendering scenarios, there may be a brief moment where content appears before styles are injected. SSR mitigates this concern.
Caching Efficiency: Extracted CSS files can be cached independently from JavaScript, potentially improving repeat visit performance.
Building Custom Elements: Web Components with Svelte
Perhaps the most powerful and complex use of <svelte:options> is compiling Svelte components as custom elements (web components). This capability allows you to create reusable UI elements that work anywhere—vanilla JavaScript applications, React projects, Vue codebases, or even static HTML pages.
Basic Custom Element Creation
The simplest form uses a string value for the customElement option:
<svelte:options customElement="user-avatar" />
<script>
let { name = 'Anonymous', imageUrl = null, size = 'medium' } = $props()
let initials = $derived(
name
.split(' ')
.map((part) => part[0])
.join('')
.toUpperCase()
.slice(0, 2)
)
let sizeClass = $derived(
{
small: 'w-8 h-8 text-xs',
medium: 'w-12 h-12 text-sm',
large: 'w-16 h-16 text-lg'
}[size] || 'w-12 h-12 text-sm'
)
</script>
<div class="avatar {sizeClass}">
{#if imageUrl}
<img src={imageUrl} alt={name} class="avatar-image" />
{:else}
<span class="avatar-initials">{initials}</span>
{/if}
</div>
<style>
.avatar {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
font-weight: 600;
overflow: hidden;
}
.avatar-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.avatar-initials {
line-height: 1;
}
</style> When this component is compiled with the customElement: true compiler option, it produces a custom element that can be used in any HTML context:
<!-- In any HTML file or framework -->
<user-avatar name="John Doe" size="large"></user-avatar>
<user-avatar name="Jane Smith" image-url="/avatars/jane.jpg"></user-avatar> Deferred Tag Definition
You can leave out the tag name for any of your inner components which you don’t want to expose and use them like regular Svelte components. Consumers of the component can still name it afterwards if needed, using the static element property which contains the custom element constructor:
import MyElement from './MyElement.svelte'
customElements.define('my-element', MyElement.element) This pattern is useful when building component libraries where the end user should choose the tag names.
Advanced Custom Element Configuration
For sophisticated use cases, the customElement option accepts an object with extensive configuration capabilities:
<svelte:options
customElement={{
tag: 'data-table',
shadow: 'open',
props: {
columns: {
type: 'Array',
reflect: false
},
data: {
type: 'Array',
reflect: false
},
pageSize: {
type: 'Number',
attribute: 'page-size',
reflect: true
},
sortable: {
type: 'Boolean',
reflect: true
},
selectedIds: {
type: 'Array',
attribute: 'selected-ids',
reflect: true
}
}
}}
/>
<script>
let { columns = [], data = [], pageSize = 10, sortable = false, selectedIds = [] } = $props()
let currentPage = $state(0)
let sortColumn = $state(null)
let sortDirection = $state('asc')
let sortedData = $derived.by(() => {
if (!sortColumn || !sortable) return data
return [...data].sort((a, b) => {
const aVal = a[sortColumn]
const bVal = b[sortColumn]
const modifier = sortDirection === 'asc' ? 1 : -1
if (typeof aVal === 'string') {
return aVal.localeCompare(bVal) * modifier
}
return (aVal - bVal) * modifier
})
})
let paginatedData = $derived.by(() => {
const start = currentPage * pageSize
return sortedData.slice(start, start + pageSize)
})
let totalPages = $derived(Math.ceil(data.length / pageSize))
function handleSort(column) {
if (!sortable) return
if (sortColumn === column) {
sortDirection = sortDirection === 'asc' ? 'desc' : 'asc'
} else {
sortColumn = column
sortDirection = 'asc'
}
}
function toggleSelection(id) {
const index = selectedIds.indexOf(id)
if (index === -1) {
selectedIds = [...selectedIds, id]
} else {
selectedIds = selectedIds.filter((sid) => sid !== id)
}
// Dispatch custom event for parent components
const event = new CustomEvent('selection-change', {
detail: { selectedIds },
bubbles: true,
composed: true
})
dispatchEvent(event)
}
</script>
<div class="table-container">
<table>
<thead>
<tr>
<th class="checkbox-column">
<input type="checkbox" />
</th>
{#each columns as column}
<th class={{ sortable }} onclick={() => handleSort(column.key)}>
{column.label}
{#if sortColumn === column.key}
<span class="sort-indicator">
{sortDirection === 'asc' ? '↑' : '↓'}
</span>
{/if}
</th>
{/each}
</tr>
</thead>
<tbody>
{#each paginatedData as row}
<tr class={{ selected: selectedIds.includes(row.id) }}>
<td class="checkbox-column">
<input
type="checkbox"
checked={selectedIds.includes(row.id)}
onchange={() => toggleSelection(row.id)}
/>
</td>
{#each columns as column}
<td>{row[column.key]}</td>
{/each}
</tr>
{/each}
</tbody>
</table>
<div class="pagination">
<button disabled={currentPage === 0} onclick={() => currentPage--}> Previous </button>
<span>Page {currentPage + 1} of {totalPages}</span>
<button disabled={currentPage >= totalPages - 1} onclick={() => currentPage++}> Next </button>
</div>
</div>
<style>
.table-container {
font-family: system-ui, sans-serif;
border: 1px solid #e2e8f0;
border-radius: 8px;
overflow: hidden;
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
padding: 12px 16px;
text-align: left;
border-bottom: 1px solid #e2e8f0;
}
th {
background: #f8fafc;
font-weight: 600;
}
th.sortable {
cursor: pointer;
user-select: none;
}
th.sortable:hover {
background: #f1f5f9;
}
.checkbox-column {
width: 40px;
text-align: center;
}
tr.selected {
background: #eff6ff;
}
tr:hover {
background: #f8fafc;
}
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
padding: 12px;
background: #f8fafc;
}
.pagination button {
padding: 8px 16px;
border: 1px solid #cbd5e1;
border-radius: 4px;
background: white;
cursor: pointer;
}
.pagination button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.sort-indicator {
margin-left: 4px;
}
</style> Let’s dissect each configuration option:
tag: The HTML tag name for your custom element. Must contain a hyphen per the Web Components specification (e.g., my-component, app-header, user-profile). If omitted, you can define the tag programmatically later.
shadow: Controls shadow DOM creation. Options are 'open' (default, allows external JavaScript to access the shadow root) or 'none' (no shadow DOM, styles aren’t encapsulated). Shadow DOM provides style isolation but prevents external CSS from styling internal elements.
props: Detailed configuration for each property, including:
type: How to parse attribute values ('String','Boolean','Number','Array','Object'). Critical because HTML attributes are always strings.attribute: Custom attribute name mapping. Svelte converts camelCase to kebab-case by default, but you can override this.reflect: Whether property changes should update the corresponding attribute. Useful for CSS attribute selectors and debugging.
The extend Option: Deep Custom Element Customization
The extend option provides the ultimate flexibility by letting you enhance or completely customize the generated custom element class. This is particularly powerful for HTML form integration using the ElementInternals API:
<svelte:options
customElement={{
tag: 'validated-input',
props: {
value: { type: 'String', reflect: true },
name: { type: 'String', reflect: true },
required: { type: 'Boolean', reflect: true },
minLength: { type: 'Number', attribute: 'min-length', reflect: true },
maxLength: { type: 'Number', attribute: 'max-length', reflect: true },
pattern: { type: 'String', reflect: true }
},
extend: (customElementConstructor) => {
return class extends customElementConstructor {
// Mark this element as form-associated
static formAssociated = true
// Store reference to internals for form integration
#internals
constructor() {
super()
this.#internals = this.attachInternals()
}
// Form integration: provides value to form data
get form() {
return this.#internals.form
}
get validity() {
return this.#internals.validity
}
get validationMessage() {
return this.#internals.validationMessage
}
checkValidity() {
return this.#internals.checkValidity()
}
reportValidity() {
return this.#internals.reportValidity()
}
// Called by the Svelte component to update form value
setFormValue(value) {
this.#internals.setFormValue(value)
}
// Called by the Svelte component to update validation state
setValidity(flags, message, anchor) {
this.#internals.setValidity(flags, message, anchor)
}
// Expose internals to the Svelte component
get internals() {
return this.#internals
}
}
}
}}
/>
<script>
let {
value = $bindable(''),
name = '',
required = false,
minLength = 0,
maxLength = Infinity,
pattern = ''
} = $props()
let inputElement
let touched = $state(false)
let errorMessage = $state('')
// Get the custom element host for form integration
const host = $host()
function validate() {
if (!inputElement) return
const validity = inputElement.validity
let flags = {}
let message = ''
if (validity.valueMissing) {
flags.valueMissing = true
message = 'This field is required'
} else if (validity.tooShort) {
flags.tooShort = true
message = `Minimum ${minLength} characters required`
} else if (validity.tooLong) {
flags.tooLong = true
message = `Maximum ${maxLength} characters allowed`
} else if (validity.patternMismatch) {
flags.patternMismatch = true
message = 'Please match the requested format'
}
errorMessage = message
// Update custom element's form validity
if (host?.setValidity) {
if (Object.keys(flags).length > 0) {
host.setValidity(flags, message, inputElement)
} else {
host.setValidity({})
}
}
// Update form value
if (host?.setFormValue) {
host.setFormValue(value)
}
}
// Validate whenever value changes
$effect(() => {
validate()
})
function handleInput(event) {
value = event.target.value
}
function handleBlur() {
touched = true
}
</script>
<div class={['input-wrapper', touched && errorMessage && 'has-error']}>
<input
bind:this={inputElement}
type="text"
{name}
{value}
{required}
minlength={minLength || undefined}
maxlength={maxLength === Infinity ? undefined : maxLength}
{pattern}
oninput={handleInput}
onblur={handleBlur}
/>
{#if touched && errorMessage}
<span class="error-message">{errorMessage}</span>
{/if}
</div>
<style>
.input-wrapper {
display: flex;
flex-direction: column;
gap: 4px;
}
input {
padding: 8px 12px;
border: 2px solid #cbd5e1;
border-radius: 6px;
font-size: 16px;
transition: border-color 0.2s;
}
input:focus {
outline: none;
border-color: #3b82f6;
}
.has-error input {
border-color: #ef4444;
}
.error-message {
color: #ef4444;
font-size: 12px;
}
</style> This extended custom element participates in HTML forms just like native form controls, enabling features like form submission, validation, and the :invalid CSS pseudo-class.
Understanding Custom Element Lifecycle Nuances
Custom elements follow their own lifecycle model that differs subtly from standard Svelte components. Understanding these nuances prevents confusing bugs:
Deferred Component Creation
When a custom element is instantiated, the inner Svelte component isn’t created immediately. Creation happens in the next tick after connectedCallback fires (i.e., after the element is attached to the DOM). This means:
// This might not work as expected immediately
const myElement = document.createElement('my-custom-element')
myElement.someMethod() // Error! Component not mounted yet
// Instead, wait for connection
document.body.appendChild(myElement)
// Or use requestAnimationFrame/setTimeout
requestAnimationFrame(() => {
myElement.someMethod() // Now it works
}) Properties set before DOM attachment are preserved and applied when the component finally mounts, but exported methods aren’t available until then. If you need to invoke functions before component creation, you can work around it by using the extend option to add the methods directly to the custom element class.
DOM Moves and Temporary Detachment
If a custom element is temporarily removed from the DOM and reattached (a common occurrence during certain DOM operations), the inner Svelte component survives. Destruction only happens in the next tick after disconnectedCallback fires, giving time for synchronous move operations to complete without destroying the component.
Batched Shadow DOM Updates
Updates to the shadow DOM are batched and applied in the next tick, not synchronously. This prevents excessive re-renders but means you can’t inspect updated DOM immediately after changing a property:
myElement.count = 5
console.log(myElement.shadowRoot.querySelector('.count').textContent)
// Might still show old value
// Use awaiting a tick or watching for mutations if immediate inspection is needed Real-World Problems and Solutions
2. Building a Framework-Agnostic Design System
Scenario: Your company uses multiple frontend frameworks across different teams—React for the main application, Vue for internal tools, and vanilla JavaScript for legacy systems. You need to build a unified design system that works everywhere.
Solution: Custom Element Library with Svelte
Create a component library that compiles to custom elements.
!!! Important Note on Slots in Custom Elements !!!
When compiling Svelte components as custom elements, you must use the native HTML <slot> element rather than Svelte 5’s snippet syntax ({@render children()}). This is because custom elements operate in the browser’s native shadow DOM, which uses the standard Web Components slot mechanism. The <slot> element in custom elements behaves differently from regular Svelte components—slotted content renders eagerly (immediately) rather than lazily.
<!-- lib/Button.svelte -->
<svelte:options
customElement={{
tag: 'ds-button',
shadow: 'open',
props: {
variant: { type: 'String', reflect: true },
size: { type: 'String', reflect: true },
disabled: { type: 'Boolean', reflect: true },
loading: { type: 'Boolean', reflect: true }
}
}}
/>
<script>
let { variant = 'primary', size = 'medium', disabled = false, loading = false } = $props()
</script>
<button class="ds-button {variant} {size}" {disabled} aria-busy={loading}>
{#if loading}
<span class="spinner" aria-hidden="true"></span>
{/if}
<slot />
</button>
<style>
.ds-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
font-family: inherit;
font-weight: 500;
border: none;
border-radius: 6px;
cursor: pointer;
transition: all 0.15s ease;
}
/* Size variants */
.ds-button.small {
padding: 6px 12px;
font-size: 14px;
}
.ds-button.medium {
padding: 10px 20px;
font-size: 16px;
}
.ds-button.large {
padding: 14px 28px;
font-size: 18px;
}
/* Color variants */
.ds-button.primary {
background: var(--ds-primary, #3b82f6);
color: white;
}
.ds-button.primary:hover:not(:disabled) {
background: var(--ds-primary-hover, #2563eb);
}
.ds-button.secondary {
background: var(--ds-secondary, #e2e8f0);
color: var(--ds-secondary-text, #1e293b);
}
.ds-button.danger {
background: var(--ds-danger, #ef4444);
color: white;
}
.ds-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.spinner {
width: 16px;
height: 16px;
border: 2px solid currentColor;
border-top-color: transparent;
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style> Using across frameworks:
<!-- Vanilla HTML -->
<ds-button variant="primary" size="large">Click Me</ds-button>
<!-- React (with wrapper for better DX) -->
<DsButton variant="primary" onClick="{handleClick}"> Click Me </DsButton>
<!-- Vue -->
<ds-button :variant="buttonVariant" @click="handleClick"> Click Me </ds-button> Key insight: Use CSS custom properties (like --ds-primary) for theming, allowing each consuming application to customize colors without modifying the component code.
2. Slot Content Renders Eagerly in Custom Elements
Scenario: You have a tabs component where each tab’s content should only render when that tab is active. But with custom elements, all slotted content renders immediately regardless of {#if} blocks.
Understanding the Issue: In standard Svelte, slotted content renders lazily—it’s only created when the slot is actually rendered. In custom elements (DOM), slotted content renders eagerly. Including a <slot> in an {#if ...} block doesn’t prevent the content from being created.
Solution: Named Slots with Display Control
<svelte:options customElement="tab-panel" />
<script>
let { activeTab = 0 } = $props()
</script>
<div class="tabs">
<div class="tab-headers">
<slot name="headers" />
</div>
<div class="tab-panels">
<!-- Instead of conditionally rendering slots, control visibility -->
<div class="panel" class:active={activeTab === 0}>
<slot name="panel-0" />
</div>
<div class="panel" class:active={activeTab === 1}>
<slot name="panel-1" />
</div>
<div class="panel" class:active={activeTab === 2}>
<slot name="panel-2" />
</div>
</div>
</div>
<style>
.panel {
display: none;
}
.panel.active {
display: block;
}
</style> Alternative Solution: If you need true lazy rendering, use callback props or dynamic content loading rather than slots:
<svelte:options
customElement={{
tag: 'lazy-tabs',
props: {
tabs: { type: 'Array' },
activeTab: { type: 'Number', reflect: true }
}
}}
/>
<script>
let { tabs = [], activeTab = 0 } = $props()
</script>
<div class="tabs">
{#each tabs as tab, index}
<button class:active={activeTab === index} onclick={() => (activeTab = index)}>
{tab.label}
</button>
{/each}
</div>
<!-- Only the active content is in the DOM -->
<div class="content">
{@html tabs[activeTab]?.content || ''}
</div> 3. Custom Elements Not Working with SSR
Scenario: Your SvelteKit application uses custom elements, but they appear as empty boxes during server-side rendering before JavaScript loads.
Understanding the Issue: Custom elements are not generally suitable for server-side rendering because the shadow DOM is invisible until JavaScript loads. The browser doesn’t know how to render the component’s internal structure without the JavaScript definition.
Solution 1: Progressive Enhancement Pattern
Design your custom elements to show meaningful content even before JavaScript loads:
<svelte:options customElement="product-card" />
<script>
let { name, price, imageUrl } = $props()
</script>
<!-- The slot acts as fallback content visible during SSR -->
<article class="card">
<slot name="fallback">
<!-- This content appears before JS loads -->
<noscript>
<p>Enable JavaScript to view this product</p>
</noscript>
</slot>
<img src={imageUrl} alt={name} />
<h3>{name}</h3>
<p class="price">${price}</p>
</article> Usage with fallback content:
<product-card name="Widget" price="29.99" image-url="/widget.jpg">
<div slot="fallback">
<!-- Server-rendered fallback -->
<img src="/widget.jpg" alt="Widget" />
<h3>Widget</h3>
<p>$29.99</p>
</div>
</product-card> Solution 2: Hydration-Aware Architecture
Keep custom elements for truly interactive widgets and use regular Svelte components for SSR-critical content:
<!-- Regular Svelte component for SSR -->
<script>
import { browser } from '$app/environment'
let { productData } = $props()
</script>
<!-- SSR-friendly static content -->
<article class="product">
<h2>{productData.name}</h2>
<p>{productData.description}</p>
<!-- Custom element only for interactive parts -->
{#if browser}
<add-to-cart product-id={productData.id} price={productData.price}></add-to-cart>
{:else}
<button disabled>Add to Cart (Loading...)</button>
{/if}
</article> 4. Styling Custom Elements from Outside
Scenario: You’re using a custom element component, but you need to apply global styles or theme customizations from the parent application.
Understanding the Issue: Shadow DOM provides style encapsulation—external styles can’t penetrate the shadow boundary. This is usually desirable but can be frustrating when you need theming.
Solution 1: CSS Custom Properties (Recommended)
CSS custom properties pierce the shadow DOM:
<svelte:options customElement="themed-card" />
<script>
let { title } = $props()
</script>
<div class="card">
<h2>{title}</h2>
<slot />
</div>
<style>
.card {
/* Use CSS custom properties with fallbacks */
background: var(--card-bg, white);
color: var(--card-text, #1e293b);
border: 1px solid var(--card-border, #e2e8f0);
border-radius: var(--card-radius, 8px);
padding: var(--card-padding, 20px);
box-shadow: var(--card-shadow, 0 1px 3px rgba(0, 0, 0, 0.1));
}
h2 {
color: var(--card-heading-color, inherit);
font-size: var(--card-heading-size, 1.25rem);
margin: 0 0 12px;
}
</style> External theming:
/* Global theme */
:root {
--card-bg: #1e293b;
--card-text: #f1f5f9;
--card-border: #334155;
--card-heading-color: #60a5fa;
}
/* Or scope to specific instances */
themed-card.dark-mode {
--card-bg: #0f172a;
} Solution 2: Disable Shadow DOM
For components that need full external styling control:
<svelte:options
customElement={{
tag: 'styleable-card',
shadow: 'none'
}}
/>
<script>
let { title } = $props()
</script>
<!-- No shadow DOM - styles from parent document apply -->
<div class="card">
<h2>{title}</h2>
<slot />
</div> Trade-off: Without shadow DOM, you lose style encapsulation. Your component’s styles might clash with the parent application’s styles, and vice versa.
Solution 3: Part-Based Styling
Use the ::part() CSS pseudo-element for selective external styling:
<svelte:options customElement="styled-button" />
<script>
let { variant = 'primary' } = $props()
</script>
<button part="button base" class={variant}>
<span part="icon">
<slot name="icon" />
</span>
<span part="label">
<slot />
</span>
</button>
<style>
button {
/* Default styles */
display: inline-flex;
align-items: center;
gap: 8px;
}
</style> External styling with ::part():
styled-button::part(button) {
border-radius: 9999px;
}
styled-button::part(icon) {
color: var(--accent-color);
} 5. Context Not Working Across Custom Element Boundaries
Scenario: You’re using Svelte’s context API (setContext/getContext) in your application, but it doesn’t work when crossing custom element boundaries.
Understanding the Issue: Svelte’s context feature works between regular Svelte components within a custom element, but you can’t use context across custom elements. Setting context in a parent custom element won’t be accessible via getContext in a child custom element.
Solution 1: Custom Events for Communication
<!-- Parent custom element -->
<svelte:options customElement="data-provider" />
<script>
let { data } = $props()
function handleRequestData(event) {
// Respond to child's request
event.target.dispatchEvent(
new CustomEvent('data-response', {
detail: data,
bubbles: false
})
)
}
</script>
<div onrequest-data={handleRequestData}>
<slot />
</div> <!-- Child custom element -->
<svelte:options customElement="data-consumer" />
<script>
import { $host } from 'svelte'
import { onMount } from 'svelte'
let data = $state(null)
const host = $host()
onMount(() => {
// Listen for response
host.addEventListener('data-response', (e) => {
data = e.detail
})
// Request data from parent
host.dispatchEvent(
new CustomEvent('request-data', {
bubbles: true,
composed: true
})
)
})
</script>
{#if data}
<div>{JSON.stringify(data)}</div>
{:else}
<div>Loading...</div>
{/if} Solution 2: Shared State via Window or Module
// lib/shared-state.svelte.js
export const sharedState = $state({
theme: 'light',
user: null,
notifications: []
})
export function updateTheme(theme) {
sharedState.theme = theme
} <!-- Any custom element can access shared state -->
<svelte:options customElement="theme-toggle" />
<script>
import { sharedState, updateTheme } from './shared-state.svelte.js'
</script>
<button onclick={() => updateTheme(sharedState.theme === 'light' ? 'dark' : 'light')}>
Current: {sharedState.theme}
</button> 6. SVG Icon System with Dynamic Colors
Scenario: You’re building an icon system where icons need to inherit colors from their context and support multiple color customizations.
Solution: SVG Components with currentColor
<!-- lib/icons/Icon.svelte -->
<svelte:options namespace="svg" />
<script>
let { name, size = 24, strokeWidth = 2, class: className = '' } = $props()
// Icon path definitions
const icons = {
home: 'M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z M9 22V12h6v10',
settings:
'M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z',
user: 'M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2 M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z',
alert:
'M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z M12 9v4 M12 17h.01'
}
</script>
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width={strokeWidth}
stroke-linecap="round"
stroke-linejoin="round"
class="icon {className}"
aria-hidden="true"
>
<path d={icons[name]} />
</svg>
<style>
.icon {
display: inline-block;
vertical-align: middle;
flex-shrink: 0;
}
</style> Usage with inherited colors:
<script>
import Icon from '$lib/icons/Icon.svelte'
</script>
<!-- Icon inherits text color -->
<button class="primary">
<Icon name="home" />
Home
</button>
<button class="danger">
<Icon name="alert" />
Warning
</button>
<style>
.primary {
color: #3b82f6;
}
.danger {
color: #ef4444;
}
</style> 7. Library Components Behaving Differently Based on Consumer Configuration
Scenario: You’re publishing a Svelte component library, but components behave unpredictably depending on whether the consuming project uses runes: true globally.
Solution: Explicit Mode Declaration
Always declare runes mode explicitly in library components:
<!-- Always at the top of every library component -->
<svelte:options runes={true} />
<script>
// Now this component behaves consistently regardless of consumer's config
let { items = [], onSelect } = $props()
let selectedIndex = $state(-1)
function handleKeyDown(event) {
if (event.key === 'ArrowDown') {
selectedIndex = Math.min(selectedIndex + 1, items.length - 1)
} else if (event.key === 'ArrowUp') {
selectedIndex = Math.max(selectedIndex - 1, 0)
} else if (event.key === 'Enter' && selectedIndex >= 0) {
onSelect?.(items[selectedIndex])
}
}
</script>
<ul role="listbox" onkeydown={handleKeyDown} tabindex="0">
{#each items as item, index}
<li
role="option"
aria-selected={selectedIndex === index}
class:selected={selectedIndex === index}
>
{item.label}
</li>
{/each}
</ul> Documentation tip: Include in your library’s README:
## Compatibility
This library is built with Svelte 5 runes. It works with any Svelte 5 project regardless of your `runes` configuration setting. 8. Embedding Svelte Widgets in Non-Svelte Applications
Scenario: You need to embed a complex Svelte-powered widget (like a chat widget or analytics dashboard) into websites built with various technologies—WordPress, static HTML, or other frameworks.
Solution: Self-Contained Widget with Auto-Registration
<!-- ChatWidget.svelte -->
<svelte:options
customElement={{
tag: 'chat-widget',
shadow: 'open',
props: {
apiKey: { type: 'String', attribute: 'api-key' },
position: { type: 'String', reflect: true },
theme: { type: 'String', reflect: true },
greeting: { type: 'String' }
}
}}
css="injected"
/>
<script>
let {
apiKey,
position = 'bottom-right',
theme = 'light',
greeting = 'How can we help?'
} = $props()
let isOpen = $state(false)
let messages = $state([])
let inputValue = $state('')
async function sendMessage() {
if (!inputValue.trim()) return
messages = [...messages, { role: 'user', content: inputValue }]
const userMessage = inputValue
inputValue = ''
// Simulate API call
const response = await fetch('/api/chat', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`
},
body: JSON.stringify({ message: userMessage })
})
const data = await response.json()
messages = [...messages, { role: 'assistant', content: data.reply }]
}
</script>
<div class="widget {position} {theme}">
{#if isOpen}
<div class="chat-window">
<header>
<h3>Support Chat</h3>
<button onclick={() => (isOpen = false)}>×</button>
</header>
<div class="messages">
<div class="message assistant">{greeting}</div>
{#each messages as message}
<div class="message {message.role}">{message.content}</div>
{/each}
</div>
<form
onsubmit={(e) => {
e.preventDefault()
sendMessage()
}}
>
<input bind:value={inputValue} placeholder="Type a message..." />
<button type="submit">Send</button>
</form>
</div>
{:else}
<button class="trigger" onclick={() => (isOpen = true)}>
<svg viewBox="0 0 24 24" width="24" height="24">
<path
fill="currentColor"
d="M12 3c5.5 0 10 3.58 10 8s-4.5 8-10 8c-1.24 0-2.43-.18-3.53-.5C5.55 21 2 21 2 21c2.33-2.33 2.7-3.9 2.75-4.5C3.05 15.07 2 13.13 2 11c0-4.42 4.5-8 10-8z"
/>
</svg>
</button>
{/if}
</div>
<style>
.widget {
position: fixed;
z-index: 9999;
font-family: system-ui, sans-serif;
}
.widget.bottom-right {
bottom: 20px;
right: 20px;
}
.widget.bottom-left {
bottom: 20px;
left: 20px;
}
.widget.light {
--bg: white;
--text: #1e293b;
--border: #e2e8f0;
--primary: #3b82f6;
}
.widget.dark {
--bg: #1e293b;
--text: #f1f5f9;
--border: #334155;
--primary: #60a5fa;
}
.trigger {
width: 56px;
height: 56px;
border-radius: 50%;
background: var(--primary);
color: white;
border: none;
cursor: pointer;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.chat-window {
width: 350px;
height: 500px;
background: var(--bg);
border-radius: 12px;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.12);
display: flex;
flex-direction: column;
overflow: hidden;
}
header {
padding: 16px;
background: var(--primary);
color: white;
display: flex;
justify-content: space-between;
align-items: center;
}
header h3 {
margin: 0;
font-size: 16px;
}
header button {
background: none;
border: none;
color: white;
font-size: 24px;
cursor: pointer;
}
.messages {
flex: 1;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.message {
padding: 10px 14px;
border-radius: 18px;
max-width: 80%;
}
.message.user {
background: var(--primary);
color: white;
align-self: flex-end;
}
.message.assistant {
background: var(--border);
color: var(--text);
align-self: flex-start;
}
form {
display: flex;
padding: 12px;
gap: 8px;
border-top: 1px solid var(--border);
}
input {
flex: 1;
padding: 10px;
border: 1px solid var(--border);
border-radius: 20px;
background: var(--bg);
color: var(--text);
}
form button {
padding: 10px 20px;
background: var(--primary);
color: white;
border: none;
border-radius: 20px;
cursor: pointer;
}
</style> Embedding in any website:
<!-- Just include the script and use the element -->
<script src="https://cdn.yourcompany.com/chat-widget.js"></script>
<chat-widget
api-key="your-api-key"
position="bottom-right"
theme="light"
greeting="Hi! How can we help you today?"
></chat-widget> Best Practices and Common Pitfalls
Placement and Visibility
Always place <svelte:options> at the very beginning of your component file, before <script> tags:
<!-- PREFERRED: Options first -->
<svelte:options runes={true} />
<script>
// Component logic
</script>
<!-- AVOID: Options buried in the file -->
<script>
// Component logic
</script>
<svelte:options runes={true} /> While technically valid anywhere at the top level, leading placement makes compiler configuration immediately apparent.
Explicit Props Declaration for Custom Elements
When creating custom elements, you must explicitly destructure props rather than using $props() without destructuring. The compiler needs to know which properties to expose on the DOM element:
<svelte:options customElement="my-element" />
<script>
// PREFERRED: Explicit destructuring tells Svelte which props to expose
let { name, count, items = [] } = $props()
// AVOID: Svelte doesn't know which props to expose as element properties
// let props = $props();
</script> Avoiding Property Name Conflicts
Don’t name custom element properties starting with on—Svelte interprets these as event listeners:
<svelte:options customElement="my-toggle" />
<script>
// AVOID: "onclick" gets interpreted as an event listener
// let { onclick } = $props();
// PREFERRED: Use a different name
let { handleClick, clickCallback } = $props()
</script> This means <custom-element oneworld={true}> is treated as customElement.addEventListener('eworld', true), not as customElement.oneworld = true.
Preserving Slots in Declarative Shadow Roots
When using <template shadowrootmode="..."> for declarative shadow DOM, Svelte 5 preserves <slot /> elements as actual DOM slots rather than transforming them:
<template shadowrootmode="open">
<!-- This slot is preserved as a real DOM slot element -->
<slot></slot>
</template> Combining Options for Complex Scenarios
Options can be combined for sophisticated requirements:
<svelte:options
runes={true}
namespace="svg"
customElement={{
tag: 'icon-chart',
shadow: 'open',
props: {
data: { type: 'Array' },
width: { type: 'Number', reflect: true },
height: { type: 'Number', reflect: true }
}
}}
/>
<script>
let { data = [], width = 200, height = 100 } = $props()
let maxValue = $derived(Math.max(...data.map((d) => d.value), 1))
let points = $derived(
data
.map((d, i) => {
const x = (i / (data.length - 1)) * width
const y = height - (d.value / maxValue) * height
return `${x},${y}`
})
.join(' ')
)
</script>
<svg {width} {height} viewBox="0 0 {width} {height}">
<polyline {points} fill="none" stroke="currentColor" stroke-width="2" />
{#each data as point, i}
<circle
cx={(i / (data.length - 1)) * width}
cy={height - (point.value / maxValue) * height}
r="4"
fill="currentColor"
/>
{/each}
</svg>
<style>
svg {
overflow: visible;
}
polyline {
stroke-linecap: round;
stroke-linejoin: round;
}
circle {
transition: r 0.2s;
}
circle:hover {
r: 6;
}
</style> This creates a custom element that renders an SVG chart, uses modern runes reactivity, and properly exposes typed properties.
Testing Custom Elements
When testing custom elements, remember the deferred mounting behavior:
import { describe, it, expect } from 'vitest'
import { tick } from 'svelte'
import './MyElement.svelte' // This registers the custom element
describe('my-element', () => {
it('renders with default props', async () => {
const element = document.createElement('my-element')
document.body.appendChild(element)
// Wait for Svelte to mount the inner component
await tick()
expect(element.shadowRoot.querySelector('h1').textContent).toBe('Default Title')
// Cleanup
element.remove()
})
it('reflects property changes', async () => {
const element = document.createElement('my-element')
document.body.appendChild(element)
await tick()
element.title = 'New Title'
await tick() // Wait for batched update
expect(element.shadowRoot.querySelector('h1').textContent).toBe('New Title')
element.remove()
})
}) Debugging Custom Elements
Use the browser’s DevTools to inspect custom element shadow roots:
// In browser console
const element = document.querySelector('my-element')
console.log(element.shadowRoot) // Inspect shadow DOM
// Check current property values
console.log(element.name, element.count)
// Manually trigger updates for testing
element.count = 42 Deprecated Options Reference
Svelte 5 deprecates several options that are non-functional in runes mode:
The immutable Option (Deprecated)
<!-- DEPRECATED - Has no effect in runes mode -->
<svelte:options immutable={true} /> This option told the compiler it could use reference equality checks (===) instead of deep comparisons for change detection. In runes mode, the fine-grained reactivity system makes this optimization irrelevant—the compiler tracks exactly which properties are accessed and updated.
The accessors Option (Deprecated)
<!-- DEPRECATED - Has no effect in runes mode -->
<svelte:options accessors={true} /> This generated getter/setter pairs for component props, allowing external code to read and write props programmatically. Runes mode handles property access differently. For custom elements, property access is handled automatically through the custom element’s property definitions.
Quick Reference
When to Use Each Option
| Option | Use When |
|---|---|
runes={true} | Publishing libraries that need consistent behavior regardless of consumer config |
runes={false} | Maintaining components that intentionally use legacy patterns |
namespace="svg" | Building pure SVG components or icon systems |
namespace="mathml" | Creating mathematical notation components |
css="injected" | Distributing standalone components, building widgets, or compiling custom elements |
customElement="tag-name" | Creating framework-agnostic web components |
customElement={{ ... }} | Advanced custom element configuration with prop typing, shadow DOM control, or form integration |
Conclusion
The <svelte:options> element provides surgical control over how individual components are compiled, enabling scenarios that would otherwise require restructuring your entire build configuration or abandoning Svelte’s compilation model entirely. This component-level configurability is particularly valuable when building libraries, maintaining legacy codebases during migration, or creating framework-agnostic web components that need to integrate with diverse environments.
Mastering <svelte:options> means understanding not just the syntax of each option, but the strategic decisions behind when to use them. The runes option ensures library components behave predictably regardless of consuming applications’ configurations. The namespace option is essential for SVG and MathML components, acknowledging that not all content is HTML. The css="injected" option simplifies distribution and dynamic loading scenarios, though with trade-offs worth understanding. The customElement configuration unlocks Svelte for building truly portable web components with sophisticated prop typing, shadow DOM control, and framework interoperability.
With <svelte:options> in your toolkit, you can build Svelte applications that gracefully handle diverse requirements—from cutting-edge runes-based components to portable web components deployable anywhere modern browsers run. The key is understanding not just what each option does, but when to reach for it and what architectural implications it carries.
Key Takeaways
<svelte:options>configures per-component compiler behavior overriding project-level defaults for runes mode, namespace, CSS injection, custom elements, immutability, and accessibility warnings- The
runes={true/false}option enables explicit mode control allowing legacy components in runes projects or opting specific components out of runes in transitional codebases - The
namespaceoption is critical for SVG and MathML - usenamespace="svg"for SVG components ornamespace="mathml"for mathematical notation to ensure proper element creation - The
css="injected"option bundles styles in JavaScript enabling dynamic component loading without separate CSS files, ideal for code-splitting and web components but increasing JS bundle size - Custom element configuration via
customElementobject transforms Svelte components into framework-agnostic web components with tag names, shadow DOM options, and prop/event mappings - Shadow DOM
"open"vs"none"trades encapsulation - open mode isolates styles but blocks global CSS, while none mode enables styling but loses encapsulation - Type-aware props require TypeScript interfaces in
svelte:options tag="...">declarations, usingSvelteComponent.elementfor proper HTMLElement extension - Custom elements have lifecycle and context limitations - no SSR support, separate context trees from parent Svelte apps, and slot content rendering requires understanding projection mechanics
See Also
- Official Svelte 5 Documentation -
<svelte:options> - Web Components Specification - Browser-native custom elements API
- Shadow DOM - Understanding style encapsulation and slot projection
- Custom Elements Everywhere - Framework compatibility testing for web components
- Svelte Runes Documentation - Understanding the new reactivity model
- SVG in HTML - Namespace handling for SVG content
- MathML Core - Mathematical notation in web browsers
- CSS Cascade and Inheritance - How shadow DOM affects styling
- TypeScript Generics - Type-safe custom element props