Understanding the Need for Dynamic Element Types

In traditional web development, the HTML elements you write in your templates are static—a <div> will always be a <div>, and a <button> will always be a <button>. However, real-world applications frequently encounter scenarios where the appropriate HTML element cannot be determined until runtime.

Consider Content Management Systems that store semantic heading levels alongside article content, component libraries that need to render as different base elements depending on context, or accessibility-focused components that must adapt their element type based on user configuration.

Svelte 5 addresses this fundamental challenge through the <svelte:element> special element, a powerful construct that enables developers to defer element type decisions to runtime while maintaining the full expressiveness of Svelte’s template syntax.

Unlike approaches that require conditional rendering with multiple element declarations or resorting to the potentially dangerous {@html} directive, <svelte:element> provides a type-safe, declarative mechanism for dynamic tag resolution.

This tutorial explores <svelte:element> in depth, examining not only its basic usage patterns but also the architectural considerations, performance implications, and edge cases that intermediate and advanced developers encounter when building production applications.

By the end of this guide, you will understand how to leverage dynamic elements effectively while avoiding the subtle pitfalls that can lead to runtime errors, accessibility issues, or unexpected behavior.

The Fundamental Syntax and Mechanics

The <svelte:element> special element uses a this attribute to specify which HTML element should be rendered. This attribute accepts any JavaScript expression that evaluates to a valid HTML tag name string:

<script>
	let tag = $state('div')
</script>

<svelte:element this={tag}> Content rendered inside a dynamically chosen element </svelte:element>

When Svelte compiles this component, it generates code that evaluates the this expression at runtime and creates the appropriate DOM element. The resulting element behaves identically to a statically declared element of the same type—it receives all specified attributes, responds to event handlers, and participates in the normal DOM lifecycle.

The expression passed to this undergoes reactive tracking, meaning that if the expression’s value changes, Svelte will replace the existing DOM element with a new element of the updated type. This replacement is a complete teardown and recreation, not a simple attribute change, which has important implications for state management that we will explore later.

Valid Tag Names and Runtime Constraints

The this attribute must resolve to a valid DOM element tag name. This includes all standard HTML elements (div, span, button, article, etc.), SVG elements (when properly namespaced), and custom elements. However, certain values are explicitly prohibited:

<script>
	// These will NOT work and may cause runtime errors
	let invalidTags = [
		'#text', // Text nodes are not elements
		'#comment', // Comments are not elements
		'svelte:head', // Svelte special elements are not valid
		'svelte:window', // Special elements require their own syntax
		'MyComponent' // Component names are not valid element tags
	]
</script>

Attempting to use these invalid values will either produce runtime errors or simply fail to render meaningful content. The key principle to remember is that <svelte:element> creates actual DOM elements, not Svelte constructs or text nodes.

Attributes, Events, and Property Binding

One of the most powerful aspects of <svelte:element> is that it supports the full range of Svelte’s attribute and event binding syntax. You can apply attributes exactly as you would on a static element:

<script>
	let headingLevel = $state(2)
	let headingId = $state('main-title')
	let headingClass = $state('text-2xl font-bold')

	function getTagName(level) {
		return `h${Math.min(Math.max(level, 1), 6)}`
	}
</script>

<svelte:element
	this={getTagName(headingLevel)}
	id={headingId}
	class={headingClass}
	aria-level={headingLevel}
	role="heading"
>
	Dynamic Heading Content
</svelte:element>

Event handlers work identically to static elements, using Svelte 5’s modern attribute-style event syntax:

<script>
	let elementType = $state('button')
	let clickCount = $state(0)

	function handleClick(event) {
		clickCount++
		console.log(`Clicked on a ${event.currentTarget.tagName}`)
	}

	function handleMouseEnter() {
		console.log('Mouse entered the dynamic element')
	}
</script>

<svelte:element
	this={elementType}
	onclick={handleClick}
	onmouseenter={handleMouseEnter}
	class="interactive-element"
>
	Click me ({clickCount} clicks)
</svelte:element>

Spread Attributes for Maximum Flexibility

When building reusable components that wrap <svelte:element>, spread attributes become particularly valuable. You can forward an arbitrary collection of attributes to the dynamic element, enabling truly polymorphic component APIs:

<script>
	let { as = 'div', children, ...restProps } = $props()
</script>

<svelte:element this={as} {...restProps}>
	{@render children?.()}
</svelte:element>

This pattern allows consumers of your component to specify both the element type and any attributes appropriate for that element type:

<script>
	import Box from './Box.svelte'
</script>

<!-- Renders as a semantic <section> with ARIA attributes -->
<Box as="section" aria-labelledby="section-heading" class="content-section">
	<h2 id="section-heading">Section Title</h2>
	<p>Section content goes here.</p>
</Box>

<!-- Renders as an interactive <button> -->
<Box as="button" type="submit" onclick={() => console.log('Submitted!')}>Submit Form</Box>

The Critical Limitation: Binding Constraints

While <svelte:element> supports most of Svelte’s directive syntax, there is one crucial limitation that developers must understand: the only supported binding is bind:this. Svelte’s built-in bindings like bind:value, bind:checked, bind:group, and dimension bindings (bind:clientWidth, etc.) do not work with dynamic elements.

This limitation exists because Svelte’s bindings are compiled with specific knowledge of the target element type. The bind:value directive, for instance, compiles differently for <input>, <select>, and <textarea> elements, and the compiler cannot make these determinations when the element type is unknown at compile time.

<script>
	let elementRef = $state(null)
	let inputValue = $state('')
	let inputType = $state('input')

	// This WORKS - bind:this is supported
	$effect(() => {
		if (elementRef) {
			console.log('Element mounted:', elementRef.tagName)
		}
	})
</script>

<!-- PREFERRED:bind:this works correctly -->
<svelte:element this={inputType} bind:this={elementRef} />

<!-- AVOID: This will NOT work as expected -->
<!-- <svelte:element this={inputType} bind:value={inputValue} /> -->

Workaround: Manual Value Synchronization

When you need bidirectional data flow with dynamic elements, you must implement manual synchronization using event handlers and the element reference:

<script>
	let elementRef = $state(null)
	let value = $state('')
	let inputType = $state('input')

	function handleInput(event) {
		value = event.target.value
	}

	// Synchronize value to element when it changes externally
	$effect(() => {
		if (elementRef && 'value' in elementRef) {
			elementRef.value = value
		}
	})
</script>

<svelte:element this={inputType} bind:this={elementRef} {value} oninput={handleInput} type="text" />

<p>Current value: {value}</p>

This approach requires more code but provides explicit control over the synchronization behavior, which can actually be advantageous when you need custom validation, transformation, or debouncing logic.

Handling Nullish Values: Conditional Rendering

When the this attribute evaluates to a nullish value (null or undefined), <svelte:element> and all its children will not be rendered. This behavior provides a built-in mechanism for conditional rendering without requiring an explicit {#if} block:

<script>
	let showElement = $state(true)
	let elementTag = $derived(showElement ? 'div' : null)
</script>

<svelte:element this={elementTag} class="conditional-content">
	This content appears and disappears based on the tag value
</svelte:element>

<button onclick={() => (showElement = !showElement)}> Toggle Element </button>

While this implicit conditional rendering is convenient, it’s important to understand that it differs from using an explicit {#if} block in terms of code clarity. When reading a template, an {#if} block makes the conditional nature of the content immediately apparent, whereas the nullish-based hiding with <svelte:element> requires understanding the value flowing into the this attribute. Consider which approach better communicates intent in your specific context.

Intentional Usage Patterns

The nullish rendering behavior becomes particularly useful when building components that may or may not require a wrapper element:

<script>
	let {
		wrapper = null, // No wrapper by default
		children,
		...wrapperProps
	} = $props()
</script>

{#if wrapper}
	<svelte:element this={wrapper} {...wrapperProps}>
		{@render children?.()}
	</svelte:element>
{:else}
	{@render children?.()}
{/if}

This pattern allows consumers to optionally wrap content in a semantic element when needed, without forcing an unnecessary <div> or other element into the DOM.

Void Elements and Runtime Validation

HTML includes a category of elements called “void elements” that cannot have children. These include <br>, <hr>, <img>, <input>, <meta>, and others. When using <svelte:element> with void element tags, attempting to include children will trigger a runtime error in development mode:

<script>
	let tag = $state('hr')
</script>

<!-- AVOID: This will throw a runtime error in development -->
<svelte:element this={tag}> This text cannot appear inside an hr element </svelte:element>

The error message clearly indicates the problem, but this validation only occurs at runtime since the compiler cannot know which element type will be rendered. This makes thorough testing essential when working with dynamic elements that might receive void element tags.

Defensive Programming for Void Elements

When building components that accept arbitrary element types, consider implementing guards against void element misuse:

<script>
	const VOID_ELEMENTS = new Set([
		'area',
		'base',
		'br',
		'col',
		'embed',
		'hr',
		'img',
		'input',
		'link',
		'meta',
		'param',
		'source',
		'track',
		'wbr'
	])

	let { as = 'div', children, ...rest } = $props()

	let isVoidElement = $derived(VOID_ELEMENTS.has(as.toLowerCase()))

	// Log warning during development for debugging purposes
	$effect(() => {
		if (isVoidElement && children) {
			console.warn(
				`Warning: <${as}> is a void element and cannot have children. ` +
					`Children will be ignored.`
			)
		}
	})
</script>

<svelte:element this={as} {...rest}>
	{#if !isVoidElement}
		{@render children?.()}
	{/if}
</svelte:element>

This pattern prevents runtime errors while providing helpful development-time warnings that guide correct usage.

Namespace Handling for SVG and Other XML Vocabularies

When rendering SVG elements dynamically, Svelte must know to use the SVG namespace rather than the HTML namespace. While Svelte attempts to infer the correct namespace from context, explicit namespace declaration ensures correct behavior:

<script>
	let shape = $state('circle')
	let circleProps = $state({ cx: 50, cy: 50, r: 40 })
	let rectProps = $state({ x: 10, y: 10, width: 80, height: 60 })

	let currentProps = $derived(shape === 'circle' ? circleProps : rectProps)
</script>

<svg width="200" height="100" viewBox="0 0 100 100">
	<!-- Svelte infers SVG namespace from parent context -->
	<svelte:element this={shape} {...currentProps} fill="steelblue" stroke="navy" stroke-width="2" />
</svg>

<button onclick={() => (shape = shape === 'circle' ? 'rect' : 'circle')}> Toggle Shape </button>

When the context is ambiguous or when rendering SVG elements outside an <svg> parent, use the xmlns attribute explicitly:

<script>
	let svgElement = $state('path')
	let pathData = $state('M 10 80 Q 95 10 180 80')
</script>

<!-- Explicit namespace when context is unclear -->
<svelte:element
	this={svgElement}
	xmlns="http://www.w3.org/2000/svg"
	d={pathData}
	fill="none"
	stroke="currentColor"
	stroke-width="3"
/>

The xmlns attribute ensures Svelte creates the element in the correct namespace, preventing the subtle bugs that occur when SVG elements are accidentally created in the HTML namespace.

Integrating with Svelte 5’s $props Rune

The $props rune in Svelte 5 provides an elegant foundation for building polymorphic components that leverage <svelte:element>. By combining destructuring, default values, and rest properties, you can create highly flexible component APIs:

<script>
	/**
	 * @typedef {Object} TextProps
	 * @property {'p' | 'span' | 'div' | 'label' | 'strong' | 'em'} [as='p']
	 * @property {'xs' | 'sm' | 'base' | 'lg' | 'xl' | '2xl'} [size='base']
	 * @property {'normal' | 'medium' | 'semibold' | 'bold'} [weight='normal']
	 * @property {import('svelte').Snippet} [children]
	 */

	/** @type {TextProps & Record<string, unknown>} */
	let {
		as = 'p',
		size = 'base',
		weight = 'normal',
		children,
		class: className = '',
		...rest
	} = $props()

	const sizeClasses = {
		xs: 'text-xs',
		sm: 'text-sm',
		base: 'text-base',
		lg: 'text-lg',
		xl: 'text-xl',
		'2xl': 'text-2xl'
	}

	const weightClasses = {
		normal: 'font-normal',
		medium: 'font-medium',
		semibold: 'font-semibold',
		bold: 'font-bold'
	}

	let computedClass = $derived(
		[sizeClasses[size], weightClasses[weight], className].filter(Boolean).join(' ')
	)
</script>

<svelte:element this={as} class={computedClass} {...rest}>
	{@render children?.()}
</svelte:element>

This Text component can now be used throughout an application with full flexibility:

<script>
	import Text from './Text.svelte'
</script>

<Text as="h1" size="2xl" weight="bold" id="page-title">Welcome to the Application</Text>

<Text as="p" size="base">This is a standard paragraph with default styling.</Text>

<Text as="label" size="sm" weight="medium" for="email-input">Email Address</Text>

<Text as="strong" weight="bold">Important: Please read carefully.</Text>

TypeScript Integration for Type-Safe Dynamic Elements

When using TypeScript, you can leverage Svelte’s type system to provide compile-time safety for your polymorphic components. This requires careful type construction to ensure that the allowed attributes match the specified element type:

<script lang="ts">
	import type { HTMLAttributes, HTMLButtonAttributes, HTMLAnchorAttributes } from 'svelte/elements'
	import type { Snippet } from 'svelte'

	// Define the allowed element types and their corresponding attribute interfaces
	type ElementType = 'button' | 'a' | 'div' | 'span'

	type ElementAttributeMap = {
		button: HTMLButtonAttributes
		a: HTMLAnchorAttributes
		div: HTMLAttributes<HTMLDivElement>
		span: HTMLAttributes<HTMLSpanElement>
	}

	interface BaseProps<T extends ElementType> {
		as?: T
		variant?: 'primary' | 'secondary' | 'ghost'
		children?: Snippet
	}

	// This type combines our base props with the appropriate HTML attributes
	type Props<T extends ElementType> = BaseProps<T> & ElementAttributeMap[T]

	// For simplicity in this example, we'll use a union approach
	let {
		as = 'button' as ElementType,
		variant = 'primary',
		children,
		class: className,
		...rest
	}: Props<ElementType> = $props()

	const variantStyles = {
		primary: 'bg-blue-600 text-white hover:bg-blue-700',
		secondary: 'bg-gray-200 text-gray-800 hover:bg-gray-300',
		ghost: 'bg-transparent text-gray-600 hover:bg-gray-100'
	}

	let computedClass = $derived(`${variantStyles[variant]} px-4 py-2 rounded ${className ?? ''}`)
</script>

<svelte:element this={as} class={computedClass} {...rest}>
	{@render children?.()}
</svelte:element>

Real-World Use Cases and Architectural Patterns

1. CMS-Driven Semantic Headings

Content management systems often store heading levels as data, requiring dynamic heading elements that maintain proper document outline:

<script>
	/**
	 * Renders a heading element with the appropriate level from CMS data.
	 * Ensures heading levels stay within valid HTML range (h1-h6).
	 */
	let { level = 2, children, ...rest } = $props()

	// Clamp level to valid heading range
	let safeLevel = $derived(Math.min(Math.max(Math.round(level), 1), 6))
	let tagName = $derived(`h${safeLevel}`)

	// Warn if clamping occurred
	$effect(() => {
		if (level !== safeLevel) {
			console.warn(`Heading level ${level} was clamped to ${safeLevel}. ` + `Valid levels are 1-6.`)
		}
	})
</script>

<svelte:element this={tagName} {...rest}>
	{@render children?.()}
</svelte:element>

Usage Example:

This component safely handles arbitrary heading levels from a CMS, ensuring the document structure remains valid even if the data is malformed.

<script>
	import DynamicHeading from './DynamicHeading.svelte'

	let post = {
		title: 'Understanding Svelte',
		sections: [
			{ title: 'Introduction', level: 2 },
			{ title: 'Core Concepts', level: 2 },
			{ title: 'Reactivity', level: 3 }
		]
	}
</script>

<DynamicHeading level={1}>{post.title}</DynamicHeading>

{#each post.sections as section}
	<DynamicHeading level={section.level}>
		{section.title}
	</DynamicHeading>
{/each}

Navigation components often need to render as either <a> elements for external links or SvelteKit’s enhanced links, or as buttons for actions:

<script>
	let { href = null, onclick = null, disabled = false, children, ...rest } = $props()

	// Determine element type based on provided props
	let elementType = $derived(href ? 'a' : 'button')

	// Build appropriate attributes based on element type
	let elementProps = $derived.by(() => {
		if (href) {
			return {
				href: disabled ? undefined : href,
				'aria-disabled': disabled || undefined,
				tabindex: disabled ? -1 : undefined,
				...rest
			}
		}
		return {
			type: 'button',
			disabled,
			onclick,
			...rest
		}
	})
</script>

<svelte:element this={elementType} {...elementProps} class={['nav-item', disabled && 'disabled']}>
	{@render children?.()}
</svelte:element>

<style>
	.nav-item {
		display: inline-flex;
		align-items: center;
		padding: 0.5rem 1rem;
		text-decoration: none;
		color: inherit;
		border: none;
		background: transparent;
		cursor: pointer;
	}

	.nav-item.disabled {
		opacity: 0.5;
		cursor: not-allowed;
		pointer-events: none;
	}
</style>

Usage Example:

This single component unifies navigation UI, automatically rendering the correct semantic element based on whether an href is provided.

<script>
	import NavItem from './NavItem.svelte'

	function handleLogout() {
		console.log('Logging out...')
	}
</script>

<nav>
	<!-- Renders as <a href="/dashboard"> -->
	<NavItem href="/dashboard">Dashboard</NavItem>

	<!-- Renders as <a href="/settings"> -->
	<NavItem href="/settings">Settings</NavItem>

	<!-- Renders as <button type="button"> -->
	<NavItem onclick={handleLogout}>Logout</NavItem>

	<!-- Renders as <a aria-disabled="true"> (visually disabled) -->
	<NavItem href="/premium" disabled>Premium Features</NavItem>
</nav>

3. Responsive List Rendering

Sometimes the appropriate list element depends on the semantic meaning of the content:

<script>
	/**
	 * @typedef {'ul' | 'ol' | 'menu' | 'div'} ListType
	 */

	let { type = 'ul', items = [], itemElement = 'li', renderItem, ...rest } = $props()

	// Validate item element matches list type
	let validItemElement = $derived.by(() => {
		if (type === 'div') return 'div'
		if (['ul', 'ol', 'menu'].includes(type)) return 'li'
		return itemElement
	})
</script>

<svelte:element this={type} {...rest} role={type === 'div' ? 'list' : undefined}>
	{#each items as item, index (item.id ?? index)}
		<svelte:element this={validItemElement} role={type === 'div' ? 'listitem' : undefined}>
			{@render renderItem?.(item, index)}
		</svelte:element>
	{/each}
</svelte:element>

Usage Example:

This component abstracts list rendering logic, allowing you to switch between ordered, unordered, or even div-based lists (with ARIA roles) without changing the implementation.

<script>
	import SmartList from './SmartList.svelte'

	let todos = [
		{ id: 1, text: 'Buy milk' },
		{ id: 2, text: 'Walk dog' }
	]

	let steps = [
		{ id: 's1', text: 'Install dependencies' },
		{ id: 's2', text: 'Run build' }
	]
</script>

<!-- Renders as <ul> with <li> items -->
<SmartList type="ul" items={todos}>
	{#snippet renderItem(item)}
		<span class="todo">{item.text}</span>
	{/snippet}
</SmartList>

<!-- Renders as <ol> with <li> items -->
<SmartList type="ol" items={steps}>
	{#snippet renderItem(item)}
		<strong>{item.text}</strong>
	{/snippet}
</SmartList>

4. Semantic Container Components

Building a container component that adapts to its semantic role:

<script>
	const SEMANTIC_ELEMENTS = {
		main: 'main',
		header: 'header',
		footer: 'footer',
		nav: 'nav',
		aside: 'aside',
		section: 'section',
		article: 'article',
		generic: 'div'
	}

	let { semantic = 'generic', children, ...rest } = $props()

	let elementTag = $derived(SEMANTIC_ELEMENTS[semantic] ?? 'div')
</script>

<svelte:element this={elementTag} {...rest}>
	{@render children?.()}
</svelte:element>

Usage Example:

This component enforces semantic HTML structure by restricting the allowed element types to a predefined set of semantic containers, defaulting to div if an unknown type is passed.

<script>
	import Container from './Container.svelte'
</script>

<Container semantic="header" class="site-header">
	<h1>My Website</h1>
</Container>

<Container semantic="main" class="content">
	<Container semantic="article">
		<h2>Article Title</h2>
		<p>Content...</p>
	</Container>

	<Container semantic="aside">
		<h3>Related Links</h3>
	</Container>
</Container>

<Container semantic="footer">&copy; 2025</Container>

Performance Considerations and Optimization

Element Recreation on Tag Change

When the this expression changes, Svelte completely removes the old element and creates a new one. This means any transient DOM state—focus, scroll position, selection, animation state—is lost. Understanding this behavior is crucial when building interactive components:

<script>
	let elementType = $state('input')
	let elementRef = $state(null)

	function toggleType() {
		// Warning: This will cause the element to lose focus!
		elementType = elementType === 'input' ? 'textarea' : 'input'
	}

	// Attempt to restore focus after element recreation
	$effect(() => {
		if (elementRef) {
			// Small delay to ensure element is in DOM
			requestAnimationFrame(() => {
				elementRef.focus()
			})
		}
	})
</script>

<svelte:element this={elementType} bind:this={elementRef} class="form-input" />

<button onclick={toggleType}>Toggle Input Type</button>

Avoiding Unnecessary Re-renders

When the expression passed to this is derived from reactive state, ensure that it doesn’t change more often than necessary:

<script>
	let config = $state({
		useSection: true,
		otherSettings: {
			/* ... */
		}
	})

	// AVOID: Creates new object on every access, potentially triggering updates
	// let element = $derived(config.useSection ? 'section' : 'div');

	// PREFERRED: Only updates when useSection actually changes
	let useSection = $derived(config.useSection)
	let element = $derived(useSection ? 'section' : 'div')
</script>

<svelte:element this={element}> Content </svelte:element>

Common Pitfalls and How to Avoid Them

1. Forgetting Binding Limitations

Problem: Attempting to use standard bindings like bind:value, bind:checked, or bind:group on <svelte:element>.

Consequences: Svelte’s compiler cannot generate the correct binding code because it doesn’t know the element type at compile time (e.g., <input> handles values differently than <textarea>). This results in compilation errors or non-functional bindings.

How to Avoid: Use bind:this to get a reference to the element, and manually synchronize state using event handlers (like oninput) and effects.

<script>
	let inputValue = $state('')
	let inputType = $state('input')
</script>

<!-- AVOID: This will NOT work -->
<!-- <svelte:element this={inputType} bind:value={inputValue} /> -->

<!-- PREFERRED: Use manual event handling instead -->
<svelte:element
	this={inputType}
	value={inputValue}
	oninput={(e) => (inputValue = e.target.value)}
/>

2. Invalid Tag Names

Problem: Passing an empty string, invalid characters, or component names to the this attribute.

Consequences: If the value is an empty string, the element simply won’t render. If it’s an invalid tag name (like MyComponent), the browser will render a non-functional <mycomponent> element (treated as an unknown HTML element) rather than mounting a Svelte component.

How to Avoid: Ensure the this expression always resolves to a valid HTML tag string. Use fallbacks (e.g., tag || 'div') to handle potential empty values.

<script>
	let userSelectedTag = $state('')

	// AVOID: Empty string is not a valid tag
	// let tag = userSelectedTag;

	// PREFERRED: Provide a fallback for invalid values
	let tag = $derived(userSelectedTag || 'div')
</script>

<svelte:element this={tag}>Content</svelte:element>

3. Case Sensitivity with Custom Elements

Problem: Using CamelCase or PascalCase names (e.g., MyCustomElement) for custom elements in the this attribute.

Consequences: The HTML specification requires custom elements to be kebab-case (lowercase with a hyphen). Browsers will fail to recognize or upgrade custom elements that don’t follow this naming convention, treating them as generic inline elements.

How to Avoid: Always use lowercase, hyphenated names for custom elements.

<script>
	// AVOID: Potential issue with custom elements
	let tag = $state('MyCustomElement') // Should be lowercase with hyphen

	// PREFERRED: Custom elements must contain a hyphen and be lowercase
	let validTag = $state('my-custom-element')
</script>

<svelte:element this={validTag}>Content</svelte:element>

4. Transitions and Dynamic Elements

Problem: Expecting Svelte transitions to play automatically when the this tag changes (e.g., swapping from div to section).

Consequences: Changing the this attribute causes an immediate destruction of the old element and creation of the new one. Svelte does not play the out transition of the old element or the in transition of the new one during this direct swap, leading to jarring UI updates.

How to Avoid: To force transitions during a tag swap, wrap the element in a {#key} block keyed to the tag name.

<script>
	import { fade } from 'svelte/transition'

	let visible = $state(true)
	let tag = $state('div')
</script>

{#if visible}
	<!-- Transition works normally -->
	<svelte:element this={tag} transition:fade={{ duration: 300 }}>
		Transitioning content
	</svelte:element>
{/if}

<!-- AVOID: Changing 'tag' won't trigger the transition -->
<!-- It will instantly replace the element instead -->

5. SSR and Hydration Considerations

Problem: Using browser-only variables (like window.innerWidth or browser checks) to determine the initial tag name.

Consequences: The server renders one tag (e.g., a), but the client renders another (e.g., button) during the initial hydration pass. This causes a hydration mismatch error, forcing the browser to discard the server-rendered DOM and rebuild it, which hurts performance and causes layout shifts.

How to Avoid: Ensure the logic for determining the tag name is deterministic and identical on both server and client. Avoid using browser checks for the initial tag value.

<script>
	import { browser } from '$app/environment'

	// AVOID: Potential hydration mismatch
	// let tag = browser ? 'button' : 'a';

	// PREFERRED: Ensure consistent tag between server and client
	let isInteractive = $state(true) // Use a prop or consistent logic
	let tag = $derived(isInteractive ? 'button' : 'span')
</script>

<svelte:element this={tag}>Interactive Element</svelte:element>

Best Practices Summary

  1. Always validate tag names: Implement runtime checks for user-provided or data-driven tag values to prevent errors.

  2. Handle void elements explicitly: Check for void elements when your component accepts children to avoid runtime errors.

  3. Use TypeScript for complex polymorphic components: Type safety helps catch attribute mismatches at compile time.

  4. Consider accessibility implications: Dynamic elements must still meet accessibility requirements. Ensure ARIA attributes and roles are appropriate for the rendered element type.

  5. Document expected element types: When building reusable components, clearly document which element types are supported and their implications.

  6. Test element transitions: Verify that your application handles element type changes gracefully, especially regarding focus management and transitions.

  7. Prefer explicit conditionals for clarity: While nullish this values provide implicit conditional rendering, explicit {#if} blocks often communicate intent more clearly.

  8. Use spread attributes judiciously: While spreading props provides flexibility, ensure you’re not inadvertently passing invalid attributes to specific element types.

Conclusion

The <svelte:element> special element represents a powerful tool in Svelte 5’s arsenal for building flexible, semantic, and maintainable user interfaces. By understanding its mechanics, constraints, and best practices, you can create polymorphic components that adapt to their context while maintaining type safety and accessibility.

The key insights to remember are the binding limitation (only bind:this is supported), the complete element recreation when tags change, and the importance of namespace handling for SVG elements. With these considerations in mind, <svelte:element> enables architectural patterns that would otherwise require verbose conditional rendering or potentially unsafe HTML string manipulation.

As you integrate dynamic elements into your Svelte 5 applications, prioritize clarity of intent, robust error handling, and comprehensive testing across all supported element types. The flexibility that <svelte:element> provides is most valuable when wielded with understanding of its behavioral nuances and a commitment to maintaining the semantic integrity of your document structure.

Key Takeaways

  • <svelte:element this={tag}> renders dynamic HTML elements determined at runtime, with the tag name specified via the this attribute accepting any valid HTML element string or null/undefined for conditional rendering
  • Only bind:this is supported for bindings - directive bindings like bind:value or bind:checked don’t work with dynamic elements due to compile-time type checking requirements
  • Element recreation occurs on tag changes - when the this value changes, Svelte destroys the old element completely (including DOM state like focus and scroll position) and creates a new one from scratch
  • Namespace handling is automatic for SVG when <svelte:element> appears within an <svg> context, correctly creating SVG elements without requiring manual svgns attributes
  • Use polymorphic components for flexible APIs that render as appropriate semantic elements (<button>, <a>, <RouterLink>) based on props while maintaining consistent styling and behavior
  • Type-safe implementations require TypeScript guards to ensure attribute spreading only applies valid attributes for each element type (e.g., href only for <a>, type only for <button>)
  • Nullish this values enable conditional rendering where this={null} or this={undefined} renders nothing, providing an alternative to wrapping in {#if} blocks
  • Accessibility must be preserved across element types - dynamic elements need appropriate ARIA roles, labels, and keyboard handling regardless of which tag is rendered

See Also