CSS That Stays Where You Put It

Every frontend developer has lived this nightmare: you write a perfectly reasonable CSS rule like p { color: red }, and suddenly paragraphs on the other side of your application turn red. You didn’t mean all paragraphs. You meant the ones right here, in this component. But CSS doesn’t care about your intentions — it’s global by nature, and every rule you write has the potential to reach into places you never expected.

Frameworks have tried to solve this in dozens of ways: BEM naming conventions, CSS Modules, CSS-in-JS libraries, utility-first frameworks. Each approach has trade-offs — some add verbosity, some add runtime cost, some require build tools, and all of them require discipline.

Svelte takes a fundamentally different approach. When you write CSS inside a Svelte component’s <style> block, that CSS is automatically scoped to the component. No naming conventions to memorize. No extra libraries to install. No runtime overhead. The compiler handles everything at build time, generating unique class names that guarantee your styles can’t accidentally affect other components.

This article covers exactly how that mechanism works, what the rules and boundaries are, and the few places where the scoping system behaves differently from raw CSS. The follow-up article covers what to do when you need styles to reach beyond those boundaries.


How the Compiler Scopes Your Styles

Svelte’s CSS scoping is a compile-time transformation. The compiler generates a unique hash based on the component’s styles, adds that hash as a class to every element targeted by your CSS, and rewrites the selectors to include the hash. The result is a component that is styled by rules that can only match its own elements.

A Concrete Example

Here is what you write:

<p>Hello!</p>
<span>World</span>

<style>
	p {
		color: blue;
	}
</style>

And here is what Svelte generates (simplified):

<p class="svelte-abc123">Hello!</p>
<span>World</span>

<style>
	p.svelte-abc123 {
		color: blue;
	}
</style>

Three things to notice in the output. First, the hash (svelte-abc123) is added as a class to <p> because the CSS targets it. Second, <span> gets no hash — nothing in this component’s CSS targets span, so there’s no reason to mark it. Third, the CSS selector p becomes p.svelte-abc123, which only matches a <p> with exactly that hash class. No other component’s <p> elements will have it.

This transformation is deterministic and happens entirely at build time with zero runtime overhead.

Two Components, No Collision

The practical benefit shows up immediately when two components both style the same element type:

<!-- Greeting.svelte -->
<p>Hello!</p>

<style>
	p {
		color: blue;
		font-weight: bold;
	}
</style>
<!-- Warning.svelte -->
<p>Watch out!</p>

<style>
	p {
		color: red;
		font-style: italic;
	}
</style>

Both compile to selectors with their own unique hash. p.svelte-abc123 only matches Greeting’s paragraph. p.svelte-xyz789 only matches Warning’s. You can use simple, semantic selectors — p, h1, button, .card — without worrying about collisions.


The Three Scoping Rules

Understanding scoping precisely requires knowing what “belongs to a component” means. There are three rules that define the boundary.

Rule 1: Styles apply to elements in the component’s own template

A component’s CSS rules reach the HTML elements written directly in its <script> + template:

<!-- Parent.svelte -->
<div class="wrapper">
	<p>This paragraph IS styled by Parent</p>
	<Child />
</div>

<style>
	.wrapper {
		padding: 1rem;
	}
	p {
		color: green;
	}
</style>

The p rule styles the paragraph written in Parent’s template. It does not reach <p> elements inside <Child />.

Rule 2: A parent’s styles do not penetrate child components

Even descendant selectors won’t cross the component boundary:

<!-- Parent.svelte -->
<div class="wrapper">
	<Child />
</div>

<style>
	/* This does NOT style <p> elements inside Child */
	.wrapper p {
		color: green;
	}
</style>

The compiled selector .wrapper.svelte-abc p.svelte-abc requires both elements to carry the same scoping hash. The <p> inside <Child> has a different hash (Child’s own hash), so it won’t match. The boundary is firm.

Rule 3: Content authored in the parent is styled by the parent

When you pass content into a child component via snippets, that content is still written in the parent’s template — so the parent’s styles apply to it:

<!-- Parent.svelte -->
<Child>
	<!-- This <p> is written in Parent's template -->
	<p>Snippet paragraph</p>
</Child>

<style>
	/* This DOES style the paragraph above */
	p {
		color: green;
	}
</style>

The styles follow where the markup is authored, not where it is rendered. The snippet travels into Child, but Parent’s scoping hash travels with it. This is intuitive once you think about it: from the compiler’s perspective, the <p> is Parent’s element.


Unused Style Warnings

Svelte keeps your stylesheets lean by warning you when a CSS rule doesn’t match any element in the component’s template:

<p>Hello</p>

<style>
	p {
		color: blue;
	}

	/* ⚠️ Warning: Unused CSS selector ".sidebar" */
	.sidebar {
		width: 200px;
	}
</style>

This catches typos and leftover rules from refactoring — both common sources of stylesheet bloat. The compiler knows exactly which elements exist in the template and can verify each selector has a target.

When the Warning Is a False Positive

The compiler can’t see classes added at runtime by JavaScript or third-party libraries:

<script>
	let el = $state(null)

	$effect(() => {
		if (el) el.classList.add('active') // added programmatically
	})
</script>

<div bind:this={el}>Content</div>

<style>
	/* ⚠️ Unused CSS selector — but it IS used at runtime */
	.active {
		background: yellow;
	}
</style>

The solution is to use :global(.active) for styles targeting dynamically added classes. That tells both the compiler and the browser to apply the rule regardless of scoping — covered in the next article.


Scoped Keyframe Animations

@keyframes are scoped to the component using the same hashing approach:

<div class="spinner"></div>

<style>
	.spinner {
		animation: spin 1s linear infinite;
	}

	/* "spin" is scoped — it won't clash with a "spin" keyframe in another component */
	@keyframes spin {
		from {
			transform: rotate(0deg);
		}
		to {
			transform: rotate(360deg);
		}
	}
</style>

Svelte renames the keyframe internally (to something like spin-abc123) and adjusts the animation property to match. Two components can both define @keyframes spin without either interfering with the other. If you need a keyframe to be globally referenceable — for example by a third-party library — you prefix the name with -global-, which is covered in the Global Styles article.


CSS Nesting

Modern CSS nesting (now supported in all major browsers) works naturally within Svelte’s scoping:

<nav class="sidebar">
	<a href="/home">Home</a>
	<a href="/docs">Docs</a>
</nav>

<style>
	.sidebar {
		display: flex;
		flex-direction: column;
		gap: 0.25rem;

		a {
			/* Scoped: only <a> elements inside .sidebar in THIS component */
			padding: 0.5rem 1rem;
			border-radius: 4px;
			color: #374151;
			text-decoration: none;

			&:hover {
				background: #f3f4f6;
			}
		}
	}
</style>

The scoping hash is applied to both .sidebar and the nested a selector, keeping everything within the component boundary. Nesting that targets elements outside the component boundary (using :global()) is covered in the Global Styles article.


Specificity: Slightly Higher Than It Looks

Because Svelte appends a class to every scoped selector, your compiled rules are slightly more specific than the selectors you wrote:

/* What you write — specificity: 0,0,1 (one element) */
p {
	color: blue;
}

/* What Svelte generates — specificity: 0,1,1 (one class + one element) */
p.svelte-abc123 {
	color: blue;
}

This is almost always harmless. But it matters if you’re mixing scoped component styles with global stylesheets — a scoped component rule will beat a plain-element rule in a global stylesheet even if the global rule loads later.

There’s a further nuance worth knowing: when the same scoping class needs to appear more than once in a complex selector, Svelte uses :where(.svelte-abc123) for subsequent occurrences. :where() always has zero specificity, which means the selector’s specificity doesn’t keep stacking as the selector grows longer. This is a deliberate compiler choice — it keeps specificity predictable no matter how complex your selectors are.


Common Mistakes

Expecting parent styles to reach into children

<!-- Avoid: won't style <h1> or <p> inside Header or Content -->
<div class="page">
	<Header />
	<Content />
</div>

<style>
	.page h1 {
		font-size: 2rem;
	}
	.page p {
		line-height: 1.7;
	}
</style>

The compiled selector .page.svelte-abc h1.svelte-abc requires both elements to have the same hash. Elements inside child components have different hashes. The selectors won’t match.

The fix depends on what you’re trying to accomplish. If you want to style elements you control, style them inside their own components. If you need to reach into uncontrolled DOM (third-party libraries, {@html} content), use :global(). If you need a parent to influence a child’s appearance without breaking encapsulation, use CSS custom properties. All three approaches are covered in the following articles.


Quick Reference

<!-- Scoped to this component — can use simple element selectors freely -->
<style>
	p {
		color: blue;
	}
	.card {
		padding: 1rem;
	}
	h1 {
		font-size: 2rem;
	}

	/* CSS nesting works — still scoped */
	.sidebar {
		a {
			color: #374151;
		}
		a:hover {
			background: #f3f4f6;
		}
	}

	/* Keyframes are scoped too — no clash with same name in other components */
	@keyframes spin {
		from {
			transform: rotate(0deg);
		}
		to {
			transform: rotate(360deg);
		}
	}
</style>

What’s Next?

Scoping is the foundation, but it’s only half the picture. The next article covers what happens when you need CSS to reach beyond the component boundary:

  • Global Styles:global(), :global {} blocks, -global- keyframes, external file imports, and when to use each approach
  • Dynamic Class Binding — Making class assignment reactive with the class attribute and class: directive