Scoped CSS & Transitions
Svelte gives you scoped styles and built-in transitions without any configuration. CSS written in a component only applies to that component. Transitions and animations are first-class features with a declarative API. No external libraries required.
Scoped CSS by Default
Every <style> block in a Svelte component is scoped automatically. Svelte achieves this by adding a unique class to the component's elements at compile time:
<h1>Hello</h1>
<p>This is scoped.</p>
<style>
h1 {
color: #ff3e00;
font-size: 2rem;
}
p {
color: #666;
}
</style>
The compiled output adds a hash class like .svelte-abc123 to both the elements and the selectors. The h1 style only affects the <h1> in this component, not any other <h1> on the page.
This means:
- No naming collisions between components.
- No need for BEM, CSS modules, or CSS-in-JS libraries.
- You write plain CSS and it just works.
Nested Elements & Child Components
Scoped styles only apply to elements directly in the component's template. They do not pierce into child components:
<script>
import Button from './Button.svelte';
</script>
<!-- This h1 gets the scoped style -->
<h1>Title</h1>
<!-- The button inside Button.svelte does NOT get styled by this component -->
<Button />
<style>
h1 { color: blue; }
/* This will NOT style elements inside Button.svelte */
button { color: red; }
</style>
This is intentional. Components own their own styles. If you need to style a child component's elements from the parent, use :global() or pass styles through props.
:global() for Escaping Scope
:global() marks a selector (or part of a selector) as unscoped:
<style>
/* Scoped: only affects this component */
p { margin: 1rem 0; }
/* Global: affects all elements matching this selector */
:global(body) {
margin: 0;
font-family: system-ui;
}
/* Partially global: scoped parent, global child */
.wrapper :global(a) {
color: #ff3e00;
text-decoration: none;
}
</style>
The partial global pattern (.wrapper :global(a)) is useful for styling elements rendered by child components or {@html} content. The .wrapper part is still scoped to this component, but the a selector inside it is unscoped.
Common :global() Patterns
<style>
/* Style HTML content from a CMS or markdown */
.content :global(h2) { font-size: 1.5rem; margin-top: 2rem; }
.content :global(pre) { background: #f4f4f4; padding: 1rem; }
.content :global(code) { font-size: 0.9em; }
/* Override a third-party component */
.chart-container :global(.tooltip) {
background: #333;
color: white;
}
</style>
Use :global() sparingly. Overusing it defeats the purpose of scoped CSS.
CSS Custom Properties for Theming
Svelte supports passing CSS custom properties to components:
<!-- ThemedButton.svelte -->
<button>
<slot />
</button>
<style>
button {
background: var(--btn-bg, #0066cc);
color: var(--btn-color, white);
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
}
</style>
<!-- Parent.svelte -->
<script>
import ThemedButton from './ThemedButton.svelte';
</script>
<ThemedButton>Default</ThemedButton>
<ThemedButton --btn-bg="#e53e3e" --btn-color="white">Danger</ThemedButton>
<ThemedButton --btn-bg="transparent" --btn-color="#333">Ghost</ThemedButton>
The --prop-name="value" syntax on components sets CSS custom properties on a wrapper element. This is the recommended way to make components themeable without exposing internal CSS classes.
Built-in Transitions
Svelte ships transitions that you can apply declaratively with the transition:, in:, and out: directives.
fade
<script>
import { fade } from 'svelte/transition';
let visible = $state(true);
</script>
<button onclick={() => visible = !visible}>Toggle</button>
{#if visible}
<p transition:fade>This fades in and out.</p>
{/if}
slide
<script>
import { slide } from 'svelte/transition';
let expanded = $state(false);
</script>
<button onclick={() => expanded = !expanded}>
{expanded ? 'Collapse' : 'Expand'}
</button>
{#if expanded}
<div transition:slide>
<p>This content slides down when shown and slides up when hidden.</p>
</div>
{/if}
fly
<script>
import { fly } from 'svelte/transition';
let items = $state(['Apple', 'Banana', 'Cherry']);
</script>
{#each items as item (item)}
<div transition:fly={{ y: 20, duration: 300 }}>
{item}
</div>
{/each}
<button onclick={() => items.push('Date')}>Add item</button>
scale
<script>
import { scale } from 'svelte/transition';
let showModal = $state(false);
</script>
{#if showModal}
<div class="modal" transition:scale={{ start: 0.8, duration: 200 }}>
<p>Modal content</p>
</div>
{/if}
Separate in & out Transitions
Use in: and out: for different enter and exit animations:
<script>
import { fly, fade } from 'svelte/transition';
let visible = $state(true);
</script>
{#if visible}
<div in:fly={{ y: -20, duration: 300 }} out:fade={{ duration: 200 }}>
Flies in from above, fades out.
</div>
{/if}
Transition Parameters
All built-in transitions accept common parameters:
<div transition:fade={{ delay: 100, duration: 400, easing: cubicOut }}>
delay: milliseconds before the transition starts.duration: how long the transition takes.easing: an easing function fromsvelte/easing.
<script>
import { fly } from 'svelte/transition';
import { quintOut } from 'svelte/easing';
</script>
<div transition:fly={{ x: -200, duration: 500, easing: quintOut }}>
Slides in from the left with easing.
</div>
Custom Transitions
A transition is a function that returns an object describing the CSS animation:
<script>
function typewriter(node, { speed = 1 }) {
const text = node.textContent;
const duration = text.length / (speed * 0.01);
return {
duration,
tick: (t) => {
const i = Math.trunc(text.length * t);
node.textContent = text.slice(0, i);
}
};
}
let visible = $state(false);
</script>
<button onclick={() => visible = !visible}>Toggle</button>
{#if visible}
<p transition:typewriter={{ speed: 2 }}>
This text appears one character at a time.
</p>
{/if}
The transition function receives the DOM node and parameters. It returns:
delay: ms before starting.duration: total ms.easing: easing function.css: a function returning CSS string at timet(preferred, runs on the GPU).tick: a function called at each frame witht(fallback, runs on main thread).
CSS-based Custom Transition
<script>
function spin(node, { duration = 400 }) {
return {
duration,
css: (t) => `
transform: rotate(${t * 360}deg) scale(${t});
opacity: ${t};
`
};
}
</script>
{#if visible}
<div transition:spin={{ duration: 600 }}>Spinning in!</div>
{/if}
CSS-based transitions are more performant because the browser can optimize them on the compositor thread.
Animate Directive for FLIP
The animate: directive applies FLIP (First, Last, Invert, Play) animations to elements inside an {#each} block. When items are reordered, each element smoothly animates to its new position:
<script>
import { flip } from 'svelte/animate';
import { fade } from 'svelte/transition';
let items = $state([
{ id: 1, name: 'First' },
{ id: 2, name: 'Second' },
{ id: 3, name: 'Third' },
{ id: 4, name: 'Fourth' }
]);
function shuffle() {
items = items.sort(() => Math.random() - 0.5);
}
</script>
<button onclick={shuffle}>Shuffle</button>
<div class="list">
{#each items as item (item.id)}
<div animate:flip={{ duration: 300 }} transition:fade>
{item.name}
</div>
{/each}
</div>
<style>
.list { display: flex; flex-direction: column; gap: 0.5rem; }
.list div {
padding: 1rem; background: #f0f0f0;
border-radius: 4px; text-align: center;
}
</style>
animate:flip requires a keyed {#each} block. The key tells Svelte which elements correspond to which items, enabling smooth position animations.
Common Pitfalls
- Expecting scoped styles to reach into child components: Scoped CSS does not cross component boundaries. Use
:global(), CSS custom properties, or pass aclassprop to the child. - Overusing :global(): Every
:global()selector risks style conflicts, which is exactly what scoped CSS prevents. Prefer CSS custom properties for theming and restrict:global()to cases like styling{@html}content. - Transitions on non-conditional elements:
transition:only works on elements that are conditionally rendered ({#if},{#each},{#key}). It does nothing on an element that is always in the DOM. - Using tick instead of css for transitions: The
cssfunction runs on the GPU compositor thread. Thetickfunction runs on the main thread and can cause jank on complex pages. Prefercsswhen possible. - Forgetting keys with animate:flip: The
animate:directive requires a keyed{#each}block. Without keys, Svelte cannot track which elements moved and the animation will not work. - Heavy transitions on mobile: Complex transitions with many elements can cause frame drops on low-powered devices. Test on real hardware and simplify when needed.
Key Takeaways
- CSS in Svelte is scoped to the component automatically. No configuration, no naming conventions, no extra tools.
:global()escapes the scope when you need to style elements outside the component boundary. Use it sparingly.- CSS custom properties (
--prop) are the clean way to make components themeable from the outside. - Built-in transitions (fade, slide, fly, scale) cover most animation needs with a simple declarative API.
- Custom transitions are functions that return CSS or tick-based animations. Prefer the CSS approach for performance.
animate:fliphandles smooth reordering animations in keyed{#each}blocks.