Building a Theme Switcher UI in Svelte

Introduction to Theme Switching UI

Welcome to the fifth lesson of our Advanced UI Features and Theming course! In our previous lesson, we built a comprehensive theme system that allows our Kanban application to support light, dark, and sepia themes. We created a theme configuration, implemented state management with Svelte runes, and set up theme persistence using localStorage.

However, our users currently have no way to interact with this theme system. While we've implemented the underlying functionality, we need to create a user interface that allows users to switch between themes easily. That's what we'll focus on in this lesson.

We'll build two main components:

  1. ThemeToggle: A button that, when clicked, displays a dropdown menu with available themes. This component will allow users to select their preferred theme.

  2. Header: A component that displays the application title and the current theme, along with the ThemeToggle component.

These components will work with our existing theme system, using the activeTheme state and setTheme function we created in the previous lesson. By the end of this lesson, users will be able to switch between themes with a simple click, and they'll receive visual feedback about the current theme.

Let's start by building the ThemeToggle component, which will be the primary interface for theme switching.

Building the ThemeToggle Component

The ThemeToggle component will be a button that, when clicked, displays a dropdown menu with available themes. Each theme option in the dropdown will show a color sample and the theme name. When a theme is selected, the dropdown will close, and the theme will be applied.

Let's create a new file called ThemeToggle.svelte in the src/components directory:

<script>
  import { getActiveTheme, setTheme, themes } from '$lib/themeStore.svelte.js';
  import { slide } from 'svelte/transition';
  
  let showThemeMenu = $state(false);
  
  function toggleThemeMenu() {
    showThemeMenu = !showThemeMenu;
  }
  
  function selectTheme(themeName) {
    setTheme(themeName);
    showThemeMenu = false;
  }
  
  // Get theme icon based on current theme
  const themeIcon = $derived(() => {
    switch (getActiveTheme()) {
      case 'light': return '☀️';
      case 'dark': return '🌙';
      case 'sepia': return '📜';
      default: return '☀️';
    }
  });
</script>

<div class="theme-toggle">
  <button class="theme-button" onclick={toggleThemeMenu} aria-label="Change Theme">
    <span class="icon">{themeIcon()}</span>
  </button>
  
  {#if showThemeMenu}
    <div 
      class="theme-menu" 
      in:slide|local={{ duration: 150, y: -5 }}
      out:slide|local={{ duration: 100, y: -5 }}
    >
      {#each Object.keys(themes) as themeName}
        <button 
          class="theme-option {getActiveTheme() === themeName ? 'active' : ''}"
          onclick={() => selectTheme(themeName)}
        >
          <span class="theme-color" style="background: var(--color-columnBg);"></span>
          <span class="theme-name">{themeName}</span>
        </button>
      {/each}
    </div>
  {/if}
</div>

<style>
  .theme-toggle {
    position: relative;
  }
  
  .theme-button {
    background: var(--color-muted);
    color: var(--color-text);
    border: none;
    border-radius: 9999px;
    width: 2.5rem;
    height: 2.5rem;
    display: flex;
    align-items: center;
    justify-content: center;
    cursor: pointer;
    transition: background-color 0.2s;
  }
  
  .theme-button:hover {
    background: var(--color-primary);
    color: white;
  }
  
  .icon {
    font-size: 1.2rem;
  }
  
  .theme-menu {
    position: absolute;
    top: 100%;
    right: 0;
    margin-top: 0.5rem;
    background: var(--color-cardBg);
    box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
    border-radius: 0.5rem;
    padding: 0.5rem;
    min-width: 150px;
    z-index: 10;
  }
  
  .theme-option {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    padding: 0.5rem;
    background: transparent;
    border: none;
    border-radius: 0.25rem;
    width: 100%;
    text-align: left;
    color: var(--color-text);
    cursor: pointer;
    transition: background-color 0.2s;
  }
  
  .theme-option:hover {
    background: var(--color-muted);
  }
  
  .theme-option.active {
    background: var(--color-primary);
    color: white;
  }
  
  .theme-color {
    display: block;
    width: 1rem;
    height: 1rem;
    border-radius: 9999px;
    border: 1px solid var(--color-secondary);
  }
  
  .theme-name {
    font-size: 0.875rem;
    text-transform: capitalize;
  }
</style>

Let's break down this component:

In the script section, we import the necessary functions and state from our theme store: activeTheme, setTheme, and themes. We also import the slide transition from Svelte for smooth animations.

We create a local state variable, showThemeMenu, using the $state rune to track whether the dropdown menu is open or closed. The toggleThemeMenu function toggles this state when the button is clicked.

The selectTheme function calls the setTheme function from our theme store with the selected theme name and closes the dropdown menu.

We use the $derived rune to create a reactive variable, themeIcon, that returns an appropriate emoji based on the current theme: sun for light, moon for dark, and scroll for sepia.

In the template section, we create a button with the theme icon. When clicked, this button toggles the dropdown menu. If the menu is open, we display a list of theme options using the #each directive to iterate over the keys of the themes object.

Each theme option is a button that, when clicked, calls the selectTheme function with the theme name. The button includes a color sample using the --color-columnBg CSS variable, which will reflect the color of that theme, and the theme name. The currently active theme is highlighted with an active class.

We use the slide transition to animate the dropdown menu when it appears and disappears, creating a smoother user experience.

In the style section, we define the appearance of the theme toggle button and dropdown menu. The button is a circular button with the theme icon, and the dropdown menu is a card with a list of theme options. We use CSS variables from our theme system to ensure that the component adapts to the current theme.

Now that we have our ThemeToggle component, let's create the Header component that will display the application title and the current theme.

Creating the Header Component with Theme Indicator

The Header component will display the application title and the current theme, along with the ThemeToggle component we just created. It will provide visual feedback about the current theme by showing a badge with the theme name for non-default themes.

Let's create a new file called Header.svelte in the src/components directory:

<script>
  import ThemeToggle from './ThemeToggle.svelte';
  import { getCurrentTheme } from '$lib/themeStore.svelte.js';
  
  // Derived properties for the theme name
  const themeName = $derived(getCurrentTheme().name);
</script>

<header class="app-header">
  <div class="title-container">
    <h1>📝 My Kanban Board</h1>
    {#if themeName !== 'light'}
      <span class="theme-badge">{themeName} mode</span>
    {/if}
  </div>
  
  <div class="controls">
    <ThemeToggle />
  </div>
</header>

<style>
  .app-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: 1rem 0;
    margin-bottom: 1rem;
  }
  
  .title-container {
    display: flex;
    align-items: center;
    gap: 0.75rem;
  }
  
  h1 {
    margin: 0;
    font-size: 1.5rem;
    color: var(--color-text);
  }
  
  .theme-badge {
    background: var(--color-primary);
    color: white;
    padding: 0.25rem 0.5rem;
    border-radius: 9999px;
    font-size: 0.75rem;
    text-transform: capitalize;
  }
  
  .controls {
    display: flex;
    gap: 1rem;
  }
</style>

Let's examine this component:

In the script section, we import the ThemeToggle component we created earlier and the getCurrentTheme function from our theme store. We use the $derived rune to create a reactive variable, themeName, that returns the name of the current theme.

In the template section, we create a header with two main parts:

  1. The title container, which includes the application title and a theme badge. The theme badge is only shown if the current theme is not the default light theme, using conditional rendering with the #if directive.

  2. The controls container, which includes the ThemeToggle component.

In the style section, we define the appearance of the header, title, and theme badge. The header is a flex container that places the title on the left and the controls on the right. The theme badge is a small pill-shaped element with the theme name, styled using the primary color from our theme system.

With our Header component complete, let's update the main application layout to use it.

Updating the Main Application Layout

Now that we have our ThemeToggle and Header components, we need to update the main application layout to use them. We'll replace the existing title with our new Header component, maintaining the rest of the application's structure and functionality.

Let's update the +page.svelte file in the src/routes directory:

<script>
  import Board from '../components/Board.svelte';
  import TaskForm from '../components/TaskForm.svelte';
  import StatsBar from '../components/StatsBar.svelte';
  import NotificationManager from '../components/NotificationManager.svelte';
  import Header from '../components/Header.svelte';
  import { getTotalTasks } from '$lib/taskStore.svelte.js';
</script>

<div class="container">
  <Header />
  
  <div class="stats-header">
    <StatsBar totalTasks={getTotalTasks()} />
  </div>
  
  <TaskForm />
  <Board />
  <NotificationManager />
</div>

<style>
  .container {
    max-width: 1200px;
    margin: 0 auto;
    padding: 1rem;
  }
  
  .stats-header {
    display: flex;
    justify-content: center;
    margin-bottom: 1rem;
  }
</style>

In this updated file, we import the Header component we created and use it at the top of the container, replacing the previous <h1> element. The rest of the application structure remains the same, with the StatsBar, TaskForm, Board, and NotificationManager components.

The styles for the container and stats header also remain unchanged, ensuring that the application maintains its overall layout and appearance.

With this update, our application now has a complete theme-switching interface. Users can click the theme toggle button to open a dropdown menu, select their preferred theme, and see visual feedback about the current theme in the header.

Let's see how this works in practice:

  1. When the application loads, it initializes the theme system and applies the user's preferred theme or the default light theme.

  2. The Header component displays the application title and, if the current theme is not the default light theme, a badge with the theme name.

  3. The ThemeToggle component displays a button with an icon representing the current theme: sun for light, moon for dark, and scroll for sepia.

  4. When the user clicks the theme toggle button, a dropdown menu appears with options for light, dark, and sepia themes. Each option shows a color sample and the theme name.

  5. When the user selects a theme, the dropdown menu closes, and the theme is applied to the application. The theme toggle button icon and the header badge update to reflect the new theme.

  6. The theme preference is saved to localStorage, ensuring that it persists across sessions.

This theme-switching interface provides a seamless and intuitive way for users to customize their experience, enhancing the overall usability and accessibility of our Kanban application.

Summary and Practice Preview

In this lesson, we've built a complete theme-switching interface for our Kanban application. Let's recap what we've learned:

  1. We created a ThemeToggle component that allows users to switch between light, dark, and sepia themes with a dropdown menu.

  2. We built a Header component that displays the application title and provides visual feedback about the current theme.

  3. We updated the main application layout to use these components, creating a cohesive and user-friendly interface.

These components work with the theme system we built in the previous lesson, using the activeTheme state, setTheme function, and CSS variables to apply themes consistently across the application.

The theme switcher enhances our application in several ways:

  • It improves accessibility by allowing users to choose a theme that works best for their visual needs.
  • It provides a more personalized experience, increasing user satisfaction and engagement.
  • It demonstrates how to build interactive UI components that work with a centralized state management system.

In the upcoming practice exercises, you'll have the opportunity to reinforce these concepts by:

  1. Extending the theme system with additional themes or color options.
  2. Adding animations to theme transitions for a more polished user experience.
  3. Implementing keyboard navigation for the theme dropdown menu to improve accessibility.
  4. Creating a theme preview feature that shows a sample of each theme before selection.

These exercises will help you solidify your understanding of theme switching in Svelte and prepare you to implement similar features in your own applications.

With the completion of this lesson, you now have a fully functional Kanban application with advanced UI features, including notifications, loading indicators, empty states, and a comprehensive theme system with user-friendly theme switching. These features elevate your application from merely functional to truly professional, providing a polished and engaging user experience.

Sign up

Join the 1M+ learners on CodeSignal

Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal