Theming Components Without Breaking Boundaries
Here’s a tension at the heart of component design: you want components to be self-contained (their styles don’t leak out), but you also want them to be customizable (a parent can adjust their appearance). These goals seem contradictory—how can a component be both sealed off and open to styling?
CSS custom properties solve this beautifully. They’re the one CSS mechanism that naturally crosses component boundaries. When a parent sets --accent: red, every child component can read that value with var(--accent) without any scoping magic, because custom properties participate in the CSS cascade—they inherit down the DOM tree just like color or font-family.
Svelte takes this a step further with --style-props: a first-class syntax for passing CSS custom properties directly to components, just like you pass regular props. You write <Button --color="red" /> and the component receives it as a CSS variable. No JavaScript involved, no store needed, no context API—just the cascade doing what it does best.
This article covers everything: how --style-props work, what Svelte generates behind the scenes, how to use fallback values, dynamic expressions, theming systems, and the patterns that make component libraries genuinely customizable.
CSS Custom Properties: A Quick Primer
If you’re already familiar with CSS custom properties (CSS variables), skip to the next section. This primer is for developers who haven’t used them before.
Defining and Using Variables
CSS custom properties are declared with a -- prefix and consumed with the var() function:
/* Define a variable */
:root {
--brand-color: #3b82f6;
--spacing-md: 1rem;
--radius: 8px;
}
/* Use it */
.button {
background: var(--brand-color);
padding: var(--spacing-md);
border-radius: var(--radius);
} Inheritance: The Key Concept
Custom properties inherit down the DOM tree, just like color or font-size. If you set --accent: red on a <div>, every element inside that <div> can read --accent:
<div style="--accent: red"> ← defines --accent
<p>I can use var(--accent)</p> ← inherits red
<section>
<span>Me too!</span> ← inherits red
<div style="--accent: blue"> ← overrides for this subtree
<span>I get blue</span> ← inherits blue
</div>
</section>
</div> This inheritance is what makes custom properties perfect for component theming in Svelte. Even though Svelte scopes CSS selectors, custom properties flow through the cascade unimpeded.
Fallback Values
The var() function accepts a second argument—a fallback value used when the property isn’t defined:
.button {
/* If --color isn't set by any ancestor, use #3b82f6 */
background: var(--color, #3b82f6);
/* Fallbacks can reference other variables */
color: var(--text-color, var(--color-white, white));
} This is essential for building robust components that work with or without custom properties being passed.
Passing --style-props to Components
The Syntax
Svelte lets you pass CSS custom properties to components using the -- prefix, just like regular props:
<!-- Parent.svelte -->
<script>
import Slider from './Slider.svelte'
</script>
<Slider --track-color="black" --thumb-color="orange" /> Inside the child component, you consume them with var():
<!-- Slider.svelte -->
<div class="slider">
<div class="track"></div>
<div class="thumb"></div>
</div>
<style>
.track {
background: var(--track-color, #e5e7eb);
height: 4px;
border-radius: 2px;
}
.thumb {
background: var(--thumb-color, #3b82f6);
width: 20px;
height: 20px;
border-radius: 50%;
}
</style> The parent passes --track-color and --thumb-color. The child reads them via var() with sensible defaults. If the parent doesn’t pass them, the fallback values kick in.
Dynamic Values
Style props can be dynamic—bound to reactive state:
<script>
import Slider from './Slider.svelte'
let r = $state(59)
let g = $state(130)
let b = $state(246)
</script>
<Slider --track-color="black" --thumb-color="rgb({r} {g} {b})" />
<div class="controls">
<label>R: <input type="range" min="0" max="255" bind:value={r} /></label>
<label>G: <input type="range" min="0" max="255" bind:value={g} /></label>
<label>B: <input type="range" min="0" max="255" bind:value={b} /></label>
</div> When r, g, or b change, the --thumb-color property updates automatically, and the slider’s thumb re-renders with the new color. No event handlers, no callbacks, no imperative DOM updates.
How Svelte Desugars --style-props
Understanding the compiled output helps you debug and reason about behavior. When you write:
<Slider bind:value min={0} max={100} --track-color="black" --thumb-color="rgb({r} {g} {b})" /> Svelte generates something like this:
<svelte-css-wrapper
style="display: contents; --track-color: black; --thumb-color: rgb({r} {g} {b})"
>
<Slider bind:value min={0} max={100} />
</svelte-css-wrapper> Key things to notice: a wrapper element is created — <svelte-css-wrapper> is a custom HTML element that Svelte generates. It uses display: contents, which makes the wrapper invisible to layout (it doesn’t create a box, doesn’t affect flex/grid, doesn’t add spacing — children render as if the wrapper didn’t exist). CSS properties are set as inline styles on the wrapper, while regular props pass through to the component normally.
The wrapper exists because CSS custom properties need a DOM element to live on. The component itself might render multiple root elements, so Svelte can’t always attach the properties directly to a single element.
InfoThe
display: contentswrapper is harmless for almost all layouts. The only edge case is if you’re using CSS selectors that depend on direct parent-child relationships (like> .child), where the extra element could interfere. In practice, this almost never matters.
SVG Context
When a component is used inside an SVG context, Svelte uses <g> instead of <svelte-css-wrapper>, since custom HTML elements aren’t valid in SVG:
<svg>
<DataPoint --color="red" cx={50} cy={50} />
</svg>
<!-- Compiles to: -->
<svg>
<g style="--color: red;">
<!-- DataPoint contents -->
</g>
</svg> Passing --style-props to Elements
You can also use --style-props syntax directly on HTML elements:
<div --color="red">This div has --color set</div> This is equivalent to writing:
<div style:--color="red">This div has --color set</div> Both set the custom property --color on the element. When used on regular elements (not components), there’s no wrapper—the property goes directly on the element as an inline style.
This is useful for setting custom properties on container elements that children will inherit:
<script>
import Card from './Card.svelte'
import Badge from './Badge.svelte'
let theme = $state('blue')
</script>
<div class="dashboard" style:--accent={theme === 'blue' ? '#3b82f6' : '#ef4444'}>
<Card />
<Card />
<Badge />
</div> Every component inside .dashboard can now read --accent with var(--accent).
This also works in the other direction: you don’t have to pass custom properties per-component at all. If you define them on :root in a global stylesheet, every component in your application inherits them automatically:
/* app.css */
:root {
--color-primary: #3b82f6;
--color-danger: #ef4444;
--radius-md: 8px;
} Any component that reads var(--color-primary) will pick up the value without needing a --color-primary prop passed to it. This is the foundation of the design token approach shown later in this article.
Building Themeable Components
Pattern 1: Button with Customizable Colors
A button component that has sensible defaults but can be fully restyled:
<!-- Button.svelte -->
<script>
let { children, onclick, disabled = false } = $props()
</script>
<button class="btn" {onclick} {disabled}>
{@render children()}
</button>
<style>
.btn {
/* Every visual property reads from a custom property with a fallback */
background: var(--btn-bg, #3b82f6);
color: var(--btn-color, white);
border: var(--btn-border, none);
padding: var(--btn-padding-y, 0.625rem) var(--btn-padding-x, 1.25rem);
border-radius: var(--btn-radius, 6px);
font-size: var(--btn-font-size, 0.875rem);
font-weight: var(--btn-font-weight, 500);
cursor: pointer;
transition:
opacity 0.15s ease,
transform 0.1s ease;
}
.btn:hover:not(:disabled) {
opacity: 0.9;
}
.btn:active:not(:disabled) {
transform: scale(0.98);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style> Now the parent has full control over appearance without touching the component’s internals:
<script>
import Button from './Button.svelte'
</script>
<!-- Default appearance -->
<Button onclick={() => alert('hi')}>Default</Button>
<!-- Danger variant -->
<Button --btn-bg="#ef4444" --btn-color="white" onclick={() => alert('danger!')}>Delete</Button>
<!-- Outline variant -->
<Button
--btn-bg="transparent"
--btn-color="#3b82f6"
--btn-border="2px solid #3b82f6"
onclick={() => {}}
>
Outline
</Button>
<!-- Large variant -->
<Button --btn-padding-y="0.875rem" --btn-padding-x="2rem" --btn-font-size="1rem" onclick={() => {}}>
Large Button
</Button> Each variant is just a different set of CSS variables. No extra CSS classes, no variant props with a switch statement, no style overrides. The component’s internal structure and behavior are completely encapsulated—only the visual surface is customizable.
Pattern 2: Card Component with Slot Theming
A card that themes its entire content area:
<!-- ThemedCard.svelte -->
<script>
let { title, children } = $props()
</script>
<article class="card">
{#if title}
<header class="card-header">
<h3>{title}</h3>
</header>
{/if}
<div class="card-body">
{@render children()}
</div>
</article>
<style>
.card {
border: 1px solid var(--card-border, #e5e7eb);
border-radius: var(--card-radius, 12px);
overflow: hidden;
background: var(--card-bg, white);
box-shadow: var(--card-shadow, 0 1px 3px rgba(0, 0, 0, 0.1));
}
.card-header {
padding: var(--card-header-padding, 1rem 1.5rem);
background: var(--card-header-bg, #f9fafb);
border-bottom: 1px solid var(--card-border, #e5e7eb);
}
h3 {
margin: 0;
font-size: var(--card-title-size, 1.125rem);
color: var(--card-title-color, #111827);
}
.card-body {
padding: var(--card-padding, 1.5rem);
color: var(--card-text-color, #374151);
line-height: 1.6;
}
</style> Usage with different themes:
<script>
import ThemedCard from './ThemedCard.svelte'
</script>
<!-- Default -->
<ThemedCard title="Standard Card">
<p>Default appearance with system-like styling.</p>
</ThemedCard>
<!-- Dark theme -->
<ThemedCard
title="Dark Card"
--card-bg="#1f2937"
--card-border="#374151"
--card-header-bg="#111827"
--card-title-color="#f9fafb"
--card-text-color="#d1d5db"
--card-shadow="0 4px 12px rgba(0, 0, 0, 0.3)"
>
<p>A card with a dark color scheme.</p>
</ThemedCard>
<!-- Branded -->
<ThemedCard
title="Brand Card"
--card-border="#bfdbfe"
--card-header-bg="#eff6ff"
--card-title-color="#1e40af"
--card-text-color="#1e3a5f"
>
<p>Styled with brand colors.</p>
</ThemedCard> Pattern 3: Design Token System
Build a complete design system by defining tokens at the layout level:
<!-- +layout.svelte -->
<script>
let { children } = $props()
let theme = $state('light')
function toggleTheme() {
theme = theme === 'light' ? 'dark' : 'light'
}
</script>
<div class="app" class:dark={theme === 'dark'}>
<nav>
<button onclick={toggleTheme}>
{theme === 'light' ? '🌙' : '☀️'}
</button>
</nav>
{@render children()}
</div>
<style>
.app {
/* Color tokens */
--color-bg: #ffffff;
--color-surface: #f9fafb;
--color-border: #e5e7eb;
--color-text: #111827;
--color-text-muted: #6b7280;
--color-primary: #3b82f6;
--color-primary-text: white;
--color-danger: #ef4444;
--color-success: #22c55e;
/* Spacing tokens */
--space-xs: 0.25rem;
--space-sm: 0.5rem;
--space-md: 1rem;
--space-lg: 1.5rem;
--space-xl: 2rem;
/* Typography tokens */
--font-sans: system-ui, -apple-system, sans-serif;
--font-mono: 'JetBrains Mono', 'Fira Code', monospace;
--text-sm: 0.875rem;
--text-base: 1rem;
--text-lg: 1.125rem;
--text-xl: 1.25rem;
/* Shape tokens */
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-full: 9999px;
/* Shadow tokens */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1);
/* Apply base styles */
min-height: 100dvh;
background: var(--color-bg);
color: var(--color-text);
font-family: var(--font-sans);
}
/* Dark theme overrides — just swap the token values */
.dark {
--color-bg: #0f172a;
--color-surface: #1e293b;
--color-border: #334155;
--color-text: #f1f5f9;
--color-text-muted: #94a3b8;
--color-primary: #60a5fa;
--color-primary-text: #0f172a;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.5);
}
nav {
padding: var(--space-md);
display: flex;
justify-content: flex-end;
}
nav button {
font-size: 1.5rem;
background: none;
border: none;
cursor: pointer;
}
</style> Now every component in your application can reference these tokens:
<!-- SomeComponent.svelte -->
<div class="card">
<h2>Dashboard</h2>
<p>All components automatically respond to theme changes.</p>
</div>
<style>
.card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-lg);
box-shadow: var(--shadow-md);
}
h2 {
color: var(--color-text);
font-size: var(--text-xl);
margin: 0 0 var(--space-sm);
}
p {
color: var(--color-text-muted);
font-size: var(--text-base);
margin: 0;
}
</style> When the .dark class toggles on the layout, every component instantly updates—no props to pass, no stores to subscribe to, no context to provide. The cascade handles everything.
Pattern 4: Data-Driven Color Scales
Custom properties excel when visualization data drives styling:
<script>
let categories = $state([
{ name: 'Revenue', value: 84000, color: '#3b82f6' },
{ name: 'Expenses', value: 62000, color: '#ef4444' },
{ name: 'Profit', value: 22000, color: '#22c55e' },
{ name: 'Forecast', value: 95000, color: '#a855f7' }
])
let maxValue = $derived(Math.max(...categories.map((c) => c.value)))
</script>
<div class="chart">
{#each categories as category}
{@const percentage = (category.value / maxValue) * 100}
<div class="chart-row" style:--bar-color={category.color}>
<span class="chart-label">{category.name}</span>
<div class="chart-bar-track">
<div class="chart-bar" style:width="{percentage}%">
${(category.value / 1000).toFixed(0)}k
</div>
</div>
</div>
{/each}
</div>
<style>
.chart {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.chart-row {
display: flex;
align-items: center;
gap: 1rem;
}
.chart-label {
width: 6rem;
text-align: right;
font-size: 0.875rem;
color: var(--color-text-muted, #6b7280);
}
.chart-bar-track {
flex: 1;
height: 2rem;
background: var(--color-surface, #f3f4f6);
border-radius: 4px;
overflow: hidden;
}
.chart-bar {
height: 100%;
/* Reads from the per-row custom property */
background: var(--bar-color);
border-radius: 4px;
display: flex;
align-items: center;
justify-content: flex-end;
padding-right: 0.5rem;
color: white;
font-size: 0.75rem;
font-weight: 600;
transition: width 0.3s ease;
}
</style> Each row sets --bar-color on the container element, and the bar reads it via var(--bar-color). This keeps the CSS clean—one rule for all bars—while allowing per-item colors.
The Relationship Between Approaches
Let’s map out how Svelte’s three styling approaches relate:
┌─────────────────────────────────────────────────────────┐
│ │
│ <style> block → Static, build-time CSS │
│ Scoped by default Zero runtime cost │
│ │
│ class: directive → Discrete state toggling │
│ Reactive class binding Runtime class updates │
│ │
│ style: directive → Dynamic, per-property │
│ Reactive inline styles Runtime style updates │
│ │
│ --style-props → Cross-component theming │
│ CSS custom properties Cascade-based, no JS │
│ │
└─────────────────────────────────────────────────────────┘
When do I use each?
"This element is always blue" → <style> .el { color: blue }
"This element is in an error state" → class:error={isError}
"This element's color comes from data" → style:color={dataColor}
"A parent should control my color" → var(--color, blue) + <Me --color="red" /> All four can be combined on the same component:
<!-- A component that uses all approaches -->
<div class="card" class:featured={isFeatured} style:opacity={isLoading ? 0.5 : 1}>
{@render children()}
</div>
<style>
.card {
/* Static scoped styles for structure */
padding: 1.5rem;
border-radius: 8px;
/* Custom property with fallback for theming */
background: var(--card-bg, white);
border: 1px solid var(--card-border, #e5e7eb);
/* Transition for the dynamic opacity */
transition: opacity 0.2s ease;
}
/* State class for featured appearance */
.card.featured {
border-color: var(--color-primary, #3b82f6);
box-shadow: 0 0 0 2px var(--color-primary, #3b82f6);
}
</style> The parent uses it like:
<Card --card-bg="#1e293b" --card-border="#334155" /> Naming Conventions
Good naming makes your component API predictable. Prefix custom properties with the component name to avoid collisions across a larger project:
<!-- Good: clear ownership -->
<Button --btn-bg="red" --btn-color="white" --btn-radius="4px" />
<!-- Bad: too generic, could collide with other components -->
<Button --bg="red" --color="white" --radius="4px" /> Name properties by purpose, not value, and always document the custom properties your component accepts. A comment block at the top of the style section works well:
<style>
/*
* Custom Properties API:
* --card-bg Background color (default: white)
* --card-border Border color (default: #e5e7eb)
* --card-radius Border radius (default: 12px)
* --card-padding Content padding (default: 1.5rem)
* --card-shadow Box shadow (default: 0 1px 3px rgba(0,0,0,0.1))
*/
.card {
background: var(--card-bg, white);
border: 1px solid var(--card-border, #e5e7eb);
border-radius: var(--card-radius, 12px);
padding: var(--card-padding, 1.5rem);
box-shadow: var(--card-shadow, 0 1px 3px rgba(0, 0, 0, 0.1));
}
</style> Common Pitfalls
Pitfall 1: Forgetting Fallback Values
Without fallbacks, your component breaks when custom properties aren’t provided:
<style>
/* Avoid: No fallback — transparent/invisible if --btn-bg isn't set */
.btn {
background: var(--btn-bg);
}
/* Preferred: Fallback ensures the component works standalone */
.btn {
background: var(--btn-bg, #3b82f6);
}
</style> WarningAlways provide fallback values in
var(). A component should look correct by default, even when no custom properties are passed. Think of--style-propsas optional overrides, not required configuration.
Pitfall 2: Custom Properties Are Strings
CSS custom properties are always strings. You can’t do math on them directly:
<style>
/* Avoid: Can't do arithmetic directly */
.box {
padding: var(--size) * 2;
}
/* Preferred: Use calc() for math */
.box {
padding: calc(var(--size) * 2);
}
</style> Pitfall 3: Assuming Direct DOM Attachment
Remember that Svelte wraps components in <svelte-css-wrapper>. This matters if you’re using CSS selectors that assume a specific parent-child relationship:
<!-- Parent -->
<div class="grid">
<!-- Each Card gets wrapped in <svelte-css-wrapper> -->
<Card --card-bg="white" />
<Card --card-bg="white" />
<Card --card-bg="white" />
</div>
<style>
.grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
}
</style> This works fine because display: contents on the wrapper makes it transparent to grid layout. But if you had > .card as a direct-child selector, the wrapper would break the relationship.
Pitfall 4: Overcomplicating with Too Many Properties
Exposing every possible CSS property as a custom property creates an overwhelming API. Expose the most useful customization points and derive the rest:
<style>
.btn {
/* Preferred: Primary customization points */
background: var(--btn-bg, #3b82f6);
color: var(--btn-color, white);
border-radius: var(--btn-radius, 6px);
font-size: var(--btn-font-size, 0.875rem);
/* Derived from primary — hover is just slightly transparent.
No custom property needed for this. */
transition: opacity 0.15s ease;
}
.btn:hover {
opacity: 0.9;
}
</style> Fewer knobs means a cleaner API and happier developers consuming your components.
Quick Reference
Passing to Components
<Component --color="red" --size="2rem" />
<Component --color={dynamicValue} />
<Component --color="rgb({r} {g} {b})" /> Receiving in Components (via CSS)
<style>
.element {
color: var(--color, fallback);
font-size: var(--size, 1rem);
}
</style> Setting on Elements
<div style:--accent="red">Children inherit --accent</div> What Svelte Generates
<!-- You write: -->
<Component --color="red" />
<!-- Svelte generates: -->
<svelte-css-wrapper style="display: contents; --color: red;">
<!-- Component contents -->
</svelte-css-wrapper>
<!-- In SVG context: -->
<g style="--color: red;">
<!-- Component contents -->
</g> What’s Next?
With all five styling articles under your belt, you have Svelte’s complete styling system covered. Scoped CSS gives you automatic encapsulation. Global Styles gives you controlled escape hatches and file import strategies. Dynamic Class Binding bridges reactive state and CSS state classes. Dynamic Inline Styles handles continuous computed values. And CSS Custom Properties enable cross-component theming through the cascade.
Together, these tools handle every styling scenario in production: static appearance lives in the scoped <style> block, discrete state is expressed with class:, continuous dynamic values go through style:, and any style that a parent should control flows via var() with a fallback.