4 min read
On this page

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 from svelte/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 time t (preferred, runs on the GPU).
  • tick: a function called at each frame with t (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 a class prop 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 css function runs on the GPU compositor thread. The tick function runs on the main thread and can cause jank on complex pages. Prefer css when 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:flip handles smooth reordering animations in keyed {#each} blocks.