Build a Pomodoro Focus App with Tauri

Build a native Windows Pomodoro timer with ambient sounds using Tauri and React.

Introduction

30 Second Summary

Deep focus is hard to come by. Between notifications, open tabs, and background noise, staying in the zone for even 25 minutes can feel impossible. Many people rely on Pomodoro timers paired with ambient sounds to create a bubble of concentration.

In this project, you will build a native Windows desktop app that combines a Pomodoro timer, a task tracker, and an ambient soundboard into one sleek focus tool. You will package it into a real installer that you can share with anyone.

What You'll Build

A dark-themed Pomodoro focus app built with React and TypeScript, wrapped in a native Windows shell using Tauri.

By the end of this project, you'll have:

  • A working Pomodoro timer with customizable work, short break, and long break durations, plus a task list to track what you are working on.
  • An ambient soundboard mixing three layered sounds (rain, fire, wind) with independent volume controls and a dynamic rain animation that responds to volume intensity.
  • A packaged Windows installer (.msi or .exe) ready to share or install on any Windows machine.
  • Secret Mission: Add a tree growth focus mode where an animated tree grows taller the longer you stay focused without pausing.

Are there any prerequisites?

You need a Windows 10 or 11 machine with VS Code and Node.js already installed. Step 1 walks you through installing the Rust toolchain and everything else you need.

Install Rust and Scaffold the Tauri Project

To build a desktop app, you need more than just web technologies. Tauri wraps your React frontend in a native window powered by Rust, producing a lightweight installer instead of a browser tab.

Before you can write any app code, your machine needs the Rust compiler and Microsoft's C++ build tools. In this step you will install those prerequisites, scaffold the Tauri project, and confirm that a native window opens on your desktop.

In this step, get ready to:
  • Install the Rust toolchain and Microsoft C++ Build Tools.
  • Scaffold a Tauri project with the React + TypeScript template.
  • Install Howler.js and verify the dev environment opens a native window.
Install the Rust toolchain and Microsoft C++ Build Tools

Tauri compiles your app's native shell using Rust, and Rust on Windows depends on the Microsoft C++ linker. You need both installed before Tauri can build anything.

  • Open your browser and go to the Microsoft C++ Build Tools page:
  • https://visualstudio.microsoft.com/visual-cpp-build-tools/
  • Click Download Build Tools and run the installer once the download finishes.
  • In the installer window, check the Desktop development with C++ workload.
  • Click Install and wait for the installation to complete.

Why do I need C++ Build Tools for a Rust project?

Rust on Windows uses the MSVC toolchain to link native code. Without the C++ build tools, the Rust compiler cannot produce a working executable. Tauri's official prerequisites list this as a hard requirement.

  • Next, open your browser and go to the Rust install page:
  • https://www.rust-lang.org/tools/install
  • Download the 64-bit rustup-init.exe and run it.
  • The installer will ask how you want to proceed. Press Enter to accept the default installation options.
  • Once installation completes, close and reopen your terminal so the new PATH takes effect.
  • Verify Rust is installed by running this command:
rustc --version

✔️ I see a version number

Rust is ready. You should see output like rustc 1.x.x. Move on to the next substep.

ⓧ Command not found

Your terminal cannot find the Rust compiler. Try these fixes:

  • Make sure you closed and reopened your terminal after the rustup installer finished.
  • If you still see the error, run the installer again from https://www.rust-lang.org/tools/install and choose the default options.
  • After reinstalling, open a brand new PowerShell or Command Prompt window and try rustc --version again.

help me troubleshoot why rustc --version is not working on Windows after running rustup-init.exe

Scaffold the Tauri project

With Rust and C++ tools installed, you can now create the project skeleton. The create-tauri-app utility generates a full Tauri + React + TypeScript setup powered by Vite.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Scaffold the project by running:
npm create tauri-app@latest

What prompts will I see?

The scaffold wizard asks a series of questions. Answer them exactly as listed below so your project matches the rest of this guide.

  • When asked for a project name, type a name for your app (for example focusflow) and press Enter.
  • Select TypeScript / JavaScript for the frontend language.
  • Select npm as the package manager.
  • Select React as the UI template.
  • Select TypeScript as the variant.
  • Navigate into the new project folder and install dependencies by running these commands:
cd focusflow
npm install

What does this create?

The scaffold produces a Vite-based React app at the top level and a Rust project inside src-tauri/. The Rust backend is what turns your web page into a native desktop window.

Running npm install downloads all the JavaScript dependencies listed in package.json.

  • Open the project in VS Code by running:
code .

Seeing 'code' is not recognized?

If the code command is not found, open VS Code manually, click File then Open Folder, and select your project folder from the Desktop.

help me get the code command working in my terminal on Windows so I can open VS Code from the command line

Install audio dependencies and verify the dev environment

The app uses Howler.js to play and mix ambient sounds. You need the library and its TypeScript types before writing any audio code.

  • Install Howler.js and its type definitions by running:
npm install howler
npm install -D @types/howler

What are these packages?

The howler package handles audio playback with seamless looping and volume control. The @types/howler package adds TypeScript type definitions so your editor can autocomplete Howler's API.

  • Verify that everything works by starting the Tauri dev server:
npm run tauri dev

What happens when I run this?

This command does two things. First, it starts the Vite dev server for your React frontend at http://localhost:5173. Then it compiles the Rust backend and opens a native desktop window that loads your React app.

The first run takes a few minutes because Rust compiles all its dependencies from scratch. Subsequent runs are much faster.

A native window should appear showing the default Tauri + React starter page with a Vite and Tauri logo.

Window not appearing?

  • Check that the C++ Build Tools installation completed successfully. If you skipped the Desktop development with C++ workload, the Rust linker will fail.
  • Make sure you ran npm install before npm run tauri dev.
  • If you see a WebView2 error, your system may need the WebView2 Runtime. Windows 10 and 11 typically have it pre-installed, but you can download the Evergreen Bootstrapper from Microsoft's WebView2 download page if needed.

help me debug why npm run tauri dev is failing on Windows. I installed Rust and the C++ Build Tools.

Your development environment is locked and loaded. Next up, you will replace the starter template with a working Pomodoro timer complete with a circular progress ring.

Build the Pomodoro Timer

Your Tauri project is scaffolded and running. Now it is time to build the core feature: a Pomodoro timer that helps you focus in structured work sessions.

The Pomodoro technique splits your work into timed intervals (usually 25 minutes of focus, then a short break). You will build a timer component with a circular progress ring, customizable durations, and automatic session transitions.

In this step, get ready to:
  • Build a Timer component with an SVG progress ring and countdown logic.
  • Add a settings panel for customizing work, short break, and long break durations.
  • Play a chime sound when the timer reaches zero using Howler.js.
Create the Timer component with a circular SVG progress ring

React components live in a components folder by convention. You will create a dedicated Timer component that handles all the countdown state, session transitions, and visual rendering.

  • In the VS Code Explorer sidebar, right-click the src folder and select New Folder.
  • Name the folder components.
  • Right-click the new components folder and select New File.
  • Name the file Timer.tsx.

The Timer component is large, so you will build it in two chunks. The first chunk covers all the imports, state, and logic. The second chunk covers the JSX that renders the visual timer.

  • Paste the following code into Timer.tsx to set up the imports, state declarations, and all the timer logic:
import { useState, useEffect, useRef, useCallback } from "react";
import { Howl } from "howler";

// Create a chime sound that plays when the timer ends
const chimeSound = new Howl({
  src: ["/sounds/chime.ogg", "/sounds/chime.mp3"],
  volume: 0.7,
});

function Timer() {
  // Load saved settings from localStorage or use defaults
  const [settings, setSettings] = useState(() => {
    const saved = localStorage.getItem("timerSettings");
    return saved
      ? JSON.parse(saved)
      : { work: 25, shortBreak: 5, longBreak: 15 };
  });
  const [timeLeft, setTimeLeft] = useState(settings.work * 60);
  const [isRunning, setIsRunning] = useState(false);
  const [sessionType, setSessionType] = useState<
    "work" | "shortBreak" | "longBreak"
  >("work");
  const [sessionCount, setSessionCount] = useState(0);
  const [showSettings, setShowSettings] = useState(false);
  const intervalRef = useRef<number | null>(null);

  // Calculate the total time for the current session type
  const totalTime =
    sessionType === "work"
      ? settings.work * 60
      : sessionType === "shortBreak"
        ? settings.shortBreak * 60
        : settings.longBreak * 60;

  // Progress as a 0-1 value for the SVG ring
  const progress = (totalTime - timeLeft) / totalTime;

  // Handle what happens when a session ends
  const handleSessionEnd = useCallback(() => {
    chimeSound.play();
    setIsRunning(false);
    if (sessionType === "work") {
      const newCount = sessionCount + 1;
      setSessionCount(newCount);
      // Every 4 work sessions, take a long break
      if (newCount % 4 === 0) {
        setSessionType("longBreak");
        setTimeLeft(settings.longBreak * 60);
      } else {
        setSessionType("shortBreak");
        setTimeLeft(settings.shortBreak * 60);
      }
    } else {
      setSessionType("work");
      setTimeLeft(settings.work * 60);
    }
  }, [sessionType, sessionCount, settings]);

  // Countdown interval that ticks every second
  useEffect(() => {
    if (isRunning) {
      intervalRef.current = window.setInterval(() => {
        setTimeLeft((prev) => {
          if (prev <= 1) {
            handleSessionEnd();
            return 0;
          }
          return prev - 1;
        });
      }, 1000);
    }
    return () => {
      if (intervalRef.current) clearInterval(intervalRef.current);
    };
  }, [isRunning, handleSessionEnd]);

  const toggleTimer = () => setIsRunning(!isRunning);

  const resetTimer = () => {
    setIsRunning(false);
    setTimeLeft(
      sessionType === "work"
        ? settings.work * 60
        : sessionType === "shortBreak"
          ? settings.shortBreak * 60
          : settings.longBreak * 60
    );
  };

  // Update a setting and persist to localStorage
  const updateSettings = (key: string, value: number) => {
    const newSettings = { ...settings, [key]: value };
    setSettings(newSettings);
    localStorage.setItem("timerSettings", JSON.stringify(newSettings));
    if (
      (key === "work" && sessionType === "work") ||
      (key === "shortBreak" && sessionType === "shortBreak") ||
      (key === "longBreak" && sessionType === "longBreak")
    ) {
      setTimeLeft(value * 60);
      setIsRunning(false);
    }
  };

  // Format time for display
  const minutes = Math.floor(timeLeft / 60);
  const seconds = timeLeft % 60;
  // SVG circle math for the progress ring
  const circumference = 2 * Math.PI * 90;
  const strokeDashoffset = circumference * (1 - progress);

What does this code do?

This first half of the component handles all the logic:

  • The Howl instance at module level creates a reusable chime sound that plays when any session ends.
  • State is initialized from localStorage so your settings persist between app restarts.
  • The useEffect sets up a 1-second interval that decrements timeLeft. When it hits zero, handleSessionEnd fires.
  • The handleSessionEnd function auto-transitions between work and break sessions. Every 4 completed work sessions, it triggers a long break.
  • The circumference and strokeDashoffset values drive the SVG progress ring animation.
  • Now paste the JSX return and export directly below the logic you just added:
  return (
    <div className="timer-container">
      <div className="timer-ring-wrapper">
        <svg width="220" height="220" viewBox="0 0 220 220">
          <circle cx="110" cy="110" r="90" fill="none" stroke="var(--bg-secondary)" strokeWidth="8" />
          <circle cx="110" cy="110" r="90" fill="none" stroke="var(--accent)" strokeWidth="8" strokeLinecap="round" strokeDasharray={circumference} strokeDashoffset={strokeDashoffset} transform="rotate(-90 110 110)" style={{ transition: "stroke-dashoffset 0.5s ease" }} />
        </svg>
        <div className="timer-display">
          <span className="timer-time">{String(minutes).padStart(2, "0")}:{String(seconds).padStart(2, "0")}</span>
          <span className="timer-label">{sessionType === "work" ? "Focus" : sessionType === "shortBreak" ? "Short Break" : "Long Break"}</span>
        </div>
      </div>
      <div className="timer-controls">
        <button className="btn btn-primary" onClick={toggleTimer}>{isRunning ? "Pause" : "Start"}</button>
        <button className="btn btn-secondary" onClick={resetTimer}>Reset</button>
        <button className="btn btn-secondary" onClick={() => setShowSettings(!showSettings)}>Settings</button>
      </div>
      {showSettings && (
        <div className="timer-settings">
          <label>Work: {settings.work} min<input type="range" min="1" max="60" value={settings.work} onChange={(e) => updateSettings("work", Number(e.target.value))} /></label>
          <label>Short Break: {settings.shortBreak} min<input type="range" min="1" max="30" value={settings.shortBreak} onChange={(e) => updateSettings("shortBreak", Number(e.target.value))} /></label>
          <label>Long Break: {settings.longBreak} min<input type="range" min="1" max="60" value={settings.longBreak} onChange={(e) => updateSettings("longBreak", Number(e.target.value))} /></label>
        </div>
      )}
      <div className="session-info">Session {sessionCount + 1}</div>
    </div>
  );
}

export default Timer;

What does the JSX render?

The visual structure consists of:

  • Two SVG circles layered on top of each other. The background circle is a static track. The foreground circle uses strokeDashoffset to animate the ring as time passes.
  • A timer-display div positioned inside the ring shows the formatted minutes and seconds plus the current session label.
  • Three control buttons: Start/Pause toggles the countdown, Reset brings the timer back to the full duration, and Settings reveals the customization panel.
  • The settings panel renders range sliders for work, short break, and long break durations. It only appears when showSettings is true.
  • Save Timer.tsx.

You will not see visual changes yet because the Timer component is not connected to App.tsx. The next step wires everything together with styling.

Seeing red squiggly lines in VS Code?

If TypeScript shows errors on the Howl import, confirm that you installed @types/howler in Step 1. You can verify by checking your package.json for a "@types/howler" entry under devDependencies.

Help me fix TypeScript errors in my Timer.tsx file

Add timer customization

The settings panel you just coded lets users fine-tune their focus sessions. It stores preferences in the browser's localStorage so they survive app restarts.

How does the auto-transition work?

When handleSessionEnd fires, it checks the current sessionType. If you just finished a work session, it increments sessionCount and checks whether it is divisible by 4. If so, a long break starts. Otherwise, a short break begins.

After any break finishes, the timer automatically resets to a work session. This cycle repeats indefinitely: work, short break, work, short break, work, short break, work, long break.

  • Run the dev server to confirm the file compiles without errors:
npm run tauri dev

The app window opens with the default template content. Your Timer component exists but is not rendered yet. The important thing is that the build succeeds without TypeScript or compilation errors.

Build failing with module errors?

Make sure you are in the project root folder (where package.json lives) when running the command. Also confirm that howler appears under dependencies in package.json.

Help me fix build errors in my Tauri project

Add a timer completion sound using Howler.js

The chimeSound instance in your code references audio files at /sounds/chime.ogg and /sounds/chime.mp3. These paths resolve to your project's public folder. You need to place actual audio files there for the chime to play.

Why OGG and MP3?

OGG is preferred for seamless looping in Web Audio mode because MP3 has known padding issues that cause audible gaps. Howler.js tries the first format in the array and falls back to the next if the browser does not support it.

  • Create the sounds directory by running:
mkdir public/sounds
  • Download a short chime or bell sound effect from a royalty-free source. A good option is freesound.org (free account required, filter by CC0 license for no-attribution sounds).
  • Save the file as chime.ogg inside public/sounds/. If you can only find an MP3, save it as chime.mp3 instead.
  • For best compatibility, provide both formats. You can convert between them using any free online audio converter.
  • Confirm the files exist by running:
ls public/sounds

You should see chime.ogg and/or chime.mp3 in the output.

Testing the chime quickly

In a later step when the Timer is wired into the app, you can set the work duration to 1 minute using the Settings slider. This lets you hear the chime without waiting a full 25 minutes.

No sound when the timer ends?

Double-check that your audio files are in public/sounds/ (not src/sounds/). Vite serves static files from the public folder at the root path. Also ensure the filename matches exactly what the code expects: chime.ogg or chime.mp3.

Help me troubleshoot why my chime sound is not playing

✔️ Awesome, I've got everything!

Great. Double check you have saved Timer.tsx and placed your chime audio file in public/sounds/.

ⓧ I'd like to double check the full code

Here is the complete Timer.tsx file:

import { useState, useEffect, useRef, useCallback } from "react";
import { Howl } from "howler";

const chimeSound = new Howl({
  src: ["/sounds/chime.ogg", "/sounds/chime.mp3"],
  volume: 0.7,
});

function Timer() {
  const [settings, setSettings] = useState(() => {
    const saved = localStorage.getItem("timerSettings");
    return saved
      ? JSON.parse(saved)
      : { work: 25, shortBreak: 5, longBreak: 15 };
  });
  const [timeLeft, setTimeLeft] = useState(settings.work * 60);
  const [isRunning, setIsRunning] = useState(false);
  const [sessionType, setSessionType] = useState<
    "work" | "shortBreak" | "longBreak"
  >("work");
  const [sessionCount, setSessionCount] = useState(0);
  const [showSettings, setShowSettings] = useState(false);
  const intervalRef = useRef<number | null>(null);

  const totalTime =
    sessionType === "work"
      ? settings.work * 60
      : sessionType === "shortBreak"
        ? settings.shortBreak * 60
        : settings.longBreak * 60;

  const progress = (totalTime - timeLeft) / totalTime;

  const handleSessionEnd = useCallback(() => {
    chimeSound.play();
    setIsRunning(false);
    if (sessionType === "work") {
      const newCount = sessionCount + 1;
      setSessionCount(newCount);
      if (newCount % 4 === 0) {
        setSessionType("longBreak");
        setTimeLeft(settings.longBreak * 60);
      } else {
        setSessionType("shortBreak");
        setTimeLeft(settings.shortBreak * 60);
      }
    } else {
      setSessionType("work");
      setTimeLeft(settings.work * 60);
    }
  }, [sessionType, sessionCount, settings]);

  useEffect(() => {
    if (isRunning) {
      intervalRef.current = window.setInterval(() => {
        setTimeLeft((prev) => {
          if (prev <= 1) {
            handleSessionEnd();
            return 0;
          }
          return prev - 1;
        });
      }, 1000);
    }
    return () => {
      if (intervalRef.current) clearInterval(intervalRef.current);
    };
  }, [isRunning, handleSessionEnd]);

  const toggleTimer = () => setIsRunning(!isRunning);

  const resetTimer = () => {
    setIsRunning(false);
    setTimeLeft(
      sessionType === "work"
        ? settings.work * 60
        : sessionType === "shortBreak"
          ? settings.shortBreak * 60
          : settings.longBreak * 60
    );
  };

  const updateSettings = (key: string, value: number) => {
    const newSettings = { ...settings, [key]: value };
    setSettings(newSettings);
    localStorage.setItem("timerSettings", JSON.stringify(newSettings));
    if (
      (key === "work" && sessionType === "work") ||
      (key === "shortBreak" && sessionType === "shortBreak") ||
      (key === "longBreak" && sessionType === "longBreak")
    ) {
      setTimeLeft(value * 60);
      setIsRunning(false);
    }
  };

  const minutes = Math.floor(timeLeft / 60);
  const seconds = timeLeft % 60;
  const circumference = 2 * Math.PI * 90;
  const strokeDashoffset = circumference * (1 - progress);

  return (
    <div className="timer-container">
      <div className="timer-ring-wrapper">
        <svg width="220" height="220" viewBox="0 0 220 220">
          <circle cx="110" cy="110" r="90" fill="none" stroke="var(--bg-secondary)" strokeWidth="8" />
          <circle cx="110" cy="110" r="90" fill="none" stroke="var(--accent)" strokeWidth="8" strokeLinecap="round" strokeDasharray={circumference} strokeDashoffset={strokeDashoffset} transform="rotate(-90 110 110)" style={{ transition: "stroke-dashoffset 0.5s ease" }} />
        </svg>
        <div className="timer-display">
          <span className="timer-time">{String(minutes).padStart(2, "0")}:{String(seconds).padStart(2, "0")}</span>
          <span className="timer-label">{sessionType === "work" ? "Focus" : sessionType === "shortBreak" ? "Short Break" : "Long Break"}</span>
        </div>
      </div>
      <div className="timer-controls">
        <button className="btn btn-primary" onClick={toggleTimer}>{isRunning ? "Pause" : "Start"}</button>
        <button className="btn btn-secondary" onClick={resetTimer}>Reset</button>
        <button className="btn btn-secondary" onClick={() => setShowSettings(!showSettings)}>Settings</button>
      </div>
      {showSettings && (
        <div className="timer-settings">
          <label>Work: {settings.work} min<input type="range" min="1" max="60" value={settings.work} onChange={(e) => updateSettings("work", Number(e.target.value))} /></label>
          <label>Short Break: {settings.shortBreak} min<input type="range" min="1" max="30" value={settings.shortBreak} onChange={(e) => updateSettings("shortBreak", Number(e.target.value))} /></label>
          <label>Long Break: {settings.longBreak} min<input type="range" min="1" max="60" value={settings.longBreak} onChange={(e) => updateSettings("longBreak", Number(e.target.value))} /></label>
        </div>
      )}
      <div className="session-info">Session {sessionCount + 1}</div>
    </div>
  );
}

export default Timer;

Your Timer component is ready with countdown logic, session transitions, and a chime sound. Next up, you will build the task list that pairs with the timer to track what you are working on.

Add the Task List

Your timer is running and counting down sessions. Now you need a way to track what you are actually working on during those focus sessions.

In this step, you will build a task management component that lets you add, complete, and delete tasks. You will also wire it into the app alongside the timer so you can see both at once.

In this step, get ready to:
  • Create a Tasks component with add, complete, and delete functionality.
  • Display the current active task above the timer and persist tasks in localStorage.
  • Update the app layout to show the timer and task list side by side.
Create the Tasks component

Each task needs a unique ID, a text label, and a completed status. You will define a TypeScript interface to enforce this shape, then set up state that loads from localStorage on startup.

  • Create a new file called Tasks.tsx inside the src/components/ folder (the same folder where Timer.tsx lives).
  • Add the following code to define the Task interface, set up state, and sync with localStorage:
import { useState, useEffect } from "react";

// Define the shape of a single task
interface Task {
  id: string;
  text: string;
  completed: boolean;
}

function Tasks() {
  // Load tasks from localStorage on first render
  const [tasks, setTasks] = useState<Task[]>(() => {
    const saved = localStorage.getItem("tasks");
    return saved ? JSON.parse(saved) : [];
  });
  const [input, setInput] = useState("");

  // Save tasks to localStorage whenever they change
  useEffect(() => {
    localStorage.setItem("tasks", JSON.stringify(tasks));
  }, [tasks]);
}

What does this code do?

The Task interface defines the three properties every task must have: a unique id, a text label, and a completed boolean.

The useState initializer function reads from localStorage so tasks survive app restarts. The useEffect writes back to localStorage every time the tasks array changes.

Now you need functions that let the user add new tasks, toggle them complete, and delete them.

  • Add the following three functions inside the Tasks function, below the useEffect block:
  // Add a new task to the top of the list
  const addTask = () => {
    if (!input.trim()) return;
    const newTask: Task = { id: Date.now().toString(), text: input.trim(), completed: false };
    setTasks([newTask, ...tasks]);
    setInput("");
  };

  // Toggle a task's completed status
  const toggleTask = (id: string) => {
    setTasks(tasks.map((t) => (t.id === id ? { ...t, completed: !t.completed } : t)));
  };

  // Remove a task entirely
  const deleteTask = (id: string) => {
    setTasks(tasks.filter((t) => t.id !== id));
  };

What does this code do?

The addTask function creates a new task with a timestamp ID and prepends it to the list. It also clears the input field.

The toggleTask function flips a task's completed property. The deleteTask function removes a task by filtering it out of the array.

The final piece is the JSX that renders the active task, the input field, and the scrollable list. Completed tasks sort to the bottom with a strikethrough style.

  • Add the following code below the deleteTask function, still inside Tasks:
  // Sort completed tasks to the bottom
  const sortedTasks = [...tasks].sort((a, b) => Number(a.completed) - Number(b.completed));
  // Find the first incomplete task as the active focus
  const activeTask = tasks.find((t) => !t.completed);

  return (
    <div className="tasks-container">
      {activeTask && (
        <div className="active-task">
          <span className="active-task-label">Current Focus:</span>
          <span className="active-task-text">{activeTask.text}</span>
        </div>
      )}
      <div className="task-input-row">
        <input type="text" className="task-input" placeholder="Add a task..." value={input} onChange={(e) => setInput(e.target.value)} onKeyDown={(e) => e.key === "Enter" && addTask()} />
        <button className="btn btn-primary btn-sm" onClick={addTask}>Add</button>
      </div>
      <ul className="task-list">
        {sortedTasks.map((task) => (
          <li key={task.id} className={`task-item ${task.completed ? "completed" : ""}`}>
            <input type="checkbox" checked={task.completed} onChange={() => toggleTask(task.id)} />
            <span className="task-text">{task.text}</span>
            <button className="task-delete" onClick={() => deleteTask(task.id)}>x</button>
          </li>
        ))}
      </ul>
    </div>
  );
}

export default Tasks;

What does this code do?

The sortedTasks array puts incomplete tasks at the top and completed tasks at the bottom. The activeTask variable grabs the first incomplete task to display as the current focus.

The input field responds to both the Add button click and the Enter key. Each task in the list gets a checkbox for completion and an x button for deletion.

  • Save Tasks.tsx.

Seeing red underlines in your editor?

If TypeScript shows errors about the Task type, make sure you defined the interface above the function. If useState or useEffect are underlined, confirm the import at the top of the file includes both.

help me debug TypeScript errors in my Tasks component

✔️ Awesome, I've got everything!

Great. Double check that you have saved Tasks.tsx before moving on.

ⓧ I'd like to double check the full code

import { useState, useEffect } from "react";

interface Task {
  id: string;
  text: string;
  completed: boolean;
}

function Tasks() {
  const [tasks, setTasks] = useState<Task[]>(() => {
    const saved = localStorage.getItem("tasks");
    return saved ? JSON.parse(saved) : [];
  });
  const [input, setInput] = useState("");

  useEffect(() => {
    localStorage.setItem("tasks", JSON.stringify(tasks));
  }, [tasks]);

  const addTask = () => {
    if (!input.trim()) return;
    const newTask: Task = { id: Date.now().toString(), text: input.trim(), completed: false };
    setTasks([newTask, ...tasks]);
    setInput("");
  };

  const toggleTask = (id: string) => {
    setTasks(tasks.map((t) => (t.id === id ? { ...t, completed: !t.completed } : t)));
  };

  const deleteTask = (id: string) => {
    setTasks(tasks.filter((t) => t.id !== id));
  };

  const sortedTasks = [...tasks].sort((a, b) => Number(a.completed) - Number(b.completed));
  const activeTask = tasks.find((t) => !t.completed);

  return (
    <div className="tasks-container">
      {activeTask && (
        <div className="active-task">
          <span className="active-task-label">Current Focus:</span>
          <span className="active-task-text">{activeTask.text}</span>
        </div>
      )}
      <div className="task-input-row">
        <input type="text" className="task-input" placeholder="Add a task..." value={input} onChange={(e) => setInput(e.target.value)} onKeyDown={(e) => e.key === "Enter" && addTask()} />
        <button className="btn btn-primary btn-sm" onClick={addTask}>Add</button>
      </div>
      <ul className="task-list">
        {sortedTasks.map((task) => (
          <li key={task.id} className={`task-item ${task.completed ? "completed" : ""}`}>
            <input type="checkbox" checked={task.completed} onChange={() => toggleTask(task.id)} />
            <span className="task-text">{task.text}</span>
            <button className="task-delete" onClick={() => deleteTask(task.id)}>x</button>
          </li>
        ))}
      </ul>
    </div>
  );
}

export default Tasks;
Wire the tasks into your app layout

The Tasks component exists but the app still shows the default Tauri template. You need to replace it with a proper layout that renders the timer and task list together, styled with a dark focus theme.

  • Create a new file called App.css in the src/ folder (at the same level as App.tsx).
  • Add the following CSS to define the app's dark color scheme and layout structure:
:root {
  --bg-primary: #1a1d2e;
  --bg-secondary: #232738;
  --bg-card: #2a2e42;
  --text-primary: #e8eaf6;
  --text-secondary: #9fa8b7;
  --accent: #7c8cf8;
  --accent-hover: #6b7bf7;
  --success: #66bb6a;
  --danger: #ef5350;
  --radius: 12px;
  --shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}

* { margin: 0; padding: 0; box-sizing: border-box; }

body {
  background-color: var(--bg-primary);
  color: var(--text-primary);
  font-family: "Segoe UI", system-ui, sans-serif;
  overflow: hidden;
}

.app { width: 100vw; height: 100vh; position: relative; }

.app-content {
  position: relative;
  z-index: 1;
  height: 100%;
  display: flex;
  flex-direction: column;
  padding: 24px;
}

.app-header { text-align: center; margin-bottom: 24px; }
.app-header h1 { font-size: 1.5rem; font-weight: 600; color: var(--accent); letter-spacing: 0.5px; }

.app-main { display: flex; gap: 24px; flex: 1; overflow: hidden; }
.left-panel { flex: 1; display: flex; flex-direction: column; gap: 20px; }
.right-panel { width: 280px; display: flex; flex-direction: column; }

What does this code do?

The :root block defines CSS custom properties for colors, spacing, and shadows. Every component in the app can reference these variables for a consistent dark theme.

The layout uses flexbox to create a two-column structure. The .left-panel holds the timer and tasks. The .right-panel is reserved for the soundboard you will build in the next step.

  • Save App.css.

Now replace the default template in App.tsx with the new layout that imports your Timer and Tasks components.

  • Open src/App.tsx in the editor.
  • Select all the existing code and replace it with the following:
import { useState } from "react";
import Timer from "./components/Timer";
import Tasks from "./components/Tasks";
import "./App.css";

function App() {
  return (
    <div className="app">
      <div className="app-content">
        <header className="app-header">
          <h1>FocusFlow</h1>
        </header>
        <main className="app-main">
          <div className="left-panel">
            <Timer />
            <Tasks />
          </div>
        </main>
      </div>
    </div>
  );
}

export default App;

What does this code do?

This replaces the default Tauri template with your app shell. It imports both Timer and Tasks components and renders them inside a .left-panel div.

The App.css import applies the dark theme and flexbox layout globally. The FocusFlow heading sits in a centered header above the main content area.

  • Save App.tsx.
  • In your terminal, run the dev server to see the result:
npm run tauri dev
  • Wait for the Tauri window to open. You should see the FocusFlow header in purple, your timer with the SVG ring below it, and the task input field underneath.
  • Type a task name into the input and press Enter. The task appears in the list with a checkbox and an x delete button.
  • Check the checkbox to mark it complete. The task should move to the bottom of the list.

Window opens but looks unstyled?

Make sure you have import "./App.css" at the top of App.tsx. If the old Tauri styles are still showing, delete any leftover styles.css or App.css imports from the original template.

help me fix styling issues in my Tauri React app

✔️ Awesome, I've got everything!

Great. Confirm you can add, check off, and delete tasks before moving on.

ⓧ I'd like to double check the full code

:root {
  --bg-primary: #1a1d2e;
  --bg-secondary: #232738;
  --bg-card: #2a2e42;
  --text-primary: #e8eaf6;
  --text-secondary: #9fa8b7;
  --accent: #7c8cf8;
  --accent-hover: #6b7bf7;
  --success: #66bb6a;
  --danger: #ef5350;
  --radius: 12px;
  --shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}

* { margin: 0; padding: 0; box-sizing: border-box; }

body {
  background-color: var(--bg-primary);
  color: var(--text-primary);
  font-family: "Segoe UI", system-ui, sans-serif;
  overflow: hidden;
}

.app { width: 100vw; height: 100vh; position: relative; }

.app-content {
  position: relative;
  z-index: 1;
  height: 100%;
  display: flex;
  flex-direction: column;
  padding: 24px;
}

.app-header { text-align: center; margin-bottom: 24px; }
.app-header h1 { font-size: 1.5rem; font-weight: 600; color: var(--accent); letter-spacing: 0.5px; }

.app-main { display: flex; gap: 24px; flex: 1; overflow: hidden; }
.left-panel { flex: 1; display: flex; flex-direction: column; gap: 20px; }
.right-panel { width: 280px; display: flex; flex-direction: column; }
import { useState } from "react";
import Timer from "./components/Timer";
import Tasks from "./components/Tasks";
import "./App.css";

function App() {
  return (
    <div className="app">
      <div className="app-content">
        <header className="app-header">
          <h1>FocusFlow</h1>
        </header>
        <main className="app-main">
          <div className="left-panel">
            <Timer />
            <Tasks />
          </div>
        </main>
      </div>
    </div>
  );
}

export default App;

Your focus app now tracks both time and tasks. Next up, you will build an ambient soundboard with layered audio and a dynamic rain animation that responds to volume.

Build the Ambient Soundboard with Rain Animation

Your timer counts down and your task list keeps you on track. Now it is time to add the feature that makes this app feel like a focus sanctuary.

In this step, you will build an ambient soundboard that layers rain, fire, and wind sounds with independent volume controls. You will also create a canvas-based rain animation that reacts in real time to the rain volume slider.

In this step, get ready to:
  • Create a soundboard with three looping audio channels powered by Howler.js.
  • Build a rain animation on HTML5 Canvas that responds to volume intensity.
  • Wire the new components into your app so they communicate through shared state.
Create the Soundboard component

The soundboard needs audio files to play. You will source three royalty-free ambient loops (rain, fire, wind) and place them in your project's public/sounds/ folder.

  • Visit freesound.org and create a free account.
  • Search for and download one looping audio file for each sound: rain, fire (or campfire/crackling), and wind.
  • Download each sound in both OGG and MP3 formats. OGG provides seamless looping. MP3 acts as a fallback.
  • Rename the files to rain.ogg, rain.mp3, fire.ogg, fire.mp3, wind.ogg, and wind.mp3.
  • Place all six files inside the public/sounds/ folder (where your chime files already live).

Why OGG over MP3 for looping?

MP3 has known padding issues that cause audible gaps when looping. OGG files loop seamlessly in Howler.js's default Web Audio mode. Including MP3 as a fallback ensures compatibility with browsers that do not support OGG.

Now you will create the Soundboard component. Each sound channel gets its own Howl instance with loop: true, a toggle button, and an independent volume slider.

  • Create a new file at src/components/Soundboard.tsx and add the following code:
import { useState, useEffect, useRef } from "react";
import { Howl } from "howler";

interface SoundboardProps {
  onRainVolumeChange: (volume: number) => void;
}

function Soundboard({ onRainVolumeChange }: SoundboardProps) {
  // Track volume (0-100) and playing state for each channel
  const [volumes, setVolumes] = useState({ rain: 0, fire: 0, wind: 0 });
  const [playing, setPlaying] = useState({ rain: false, fire: false, wind: false });
  const soundsRef = useRef<Record<string, Howl | null>>({ rain: null, fire: null, wind: null });

  // Initialize Howl instances on mount
  useEffect(() => {
    soundsRef.current.rain = new Howl({ src: ["/sounds/rain.ogg", "/sounds/rain.mp3"], loop: true, volume: 0 });
    soundsRef.current.fire = new Howl({ src: ["/sounds/fire.ogg", "/sounds/fire.mp3"], loop: true, volume: 0 });
    soundsRef.current.wind = new Howl({ src: ["/sounds/wind.ogg", "/sounds/wind.mp3"], loop: true, volume: 0 });
    return () => { Object.values(soundsRef.current).forEach((howl) => howl?.unload()); };
  }, []);

  // Toggle a sound on or off
  const toggleSound = (name: string) => {
    const howl = soundsRef.current[name];
    if (!howl) return;
    if (playing[name as keyof typeof playing]) {
      howl.pause();
      setPlaying((prev) => ({ ...prev, [name]: false }));
    } else {
      howl.play();
      howl.volume(volumes[name as keyof typeof volumes] / 100);
      setPlaying((prev) => ({ ...prev, [name]: true }));
    }
  };

  // Adjust volume and notify parent when rain changes
  const changeVolume = (name: string, value: number) => {
    setVolumes((prev) => ({ ...prev, [name]: value }));
    const howl = soundsRef.current[name];
    if (howl) { howl.volume(value / 100); }
    if (name === "rain") { onRainVolumeChange(value); }
  };

  const channels = [
    { name: "rain", label: "Rain", icon: "~" },
    { name: "fire", label: "Fire", icon: "*" },
    { name: "wind", label: "Wind", icon: ">" },
  ];

  return (
    <div className="soundboard-container">
      <h2 className="soundboard-title">Ambience</h2>
      {channels.map((channel) => (
        <div key={channel.name} className="sound-channel">
          <button className={`sound-toggle ${playing[channel.name as keyof typeof playing] ? "active" : ""}`} onClick={() => toggleSound(channel.name)}>
            <span className="sound-icon">{channel.icon}</span>
            <span className="sound-label">{channel.label}</span>
          </button>
          <input type="range" min="0" max="100" value={volumes[channel.name as keyof typeof volumes]} onChange={(e) => changeVolume(channel.name, Number(e.target.value))} className="volume-slider" />
          <span className="volume-value">{volumes[channel.name as keyof typeof volumes]}%</span>
        </div>
      ))}
    </div>
  );
}

export default Soundboard;

What does this code do?

Each sound channel is a Howl instance that loads both OGG and MP3 sources with loop: true. Howler.js automatically picks the best format your system supports.

The toggleSound function plays or pauses a channel. The changeVolume function updates the Howl's volume in real time. When the rain slider moves, onRainVolumeChange fires to notify the parent component, which will drive the rain animation later.

  • Save Soundboard.tsx.

Seeing a red underline on the Howl import?

Make sure you installed @types/howler in Step 1. If not, run npm install -D @types/howler from your project folder.

Help me fix a TypeScript error on my Howl import.

Build the rain canvas animation

The rain animation uses HTML5 Canvas with requestAnimationFrame to render falling raindrops. The number of drops scales directly with the rain volume slider value. At 0% there are no drops. At 100% there are 200 drops falling at varying speeds.

  • Create a new file at src/components/RainCanvas.tsx and add the following code:
import { useEffect, useRef } from "react";

interface RainCanvasProps {
  intensity: number;
}

interface Raindrop {
  x: number;
  y: number;
  speed: number;
  length: number;
  opacity: number;
}

function RainCanvas({ intensity }: RainCanvasProps) {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const dropsRef = useRef<Raindrop[]>([]);
  const animationRef = useRef<number>(0);

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const ctx = canvas.getContext("2d");
    if (!ctx) return;

    // Keep canvas sized to the full window
    const resize = () => { canvas.width = window.innerWidth; canvas.height = window.innerHeight; };
    resize();
    window.addEventListener("resize", resize);

    const animate = () => {
      if (!ctx || !canvas) return;
      ctx.clearRect(0, 0, canvas.width, canvas.height);

      // Scale drop count from 0 to 200 based on intensity
      const targetCount = Math.floor((intensity / 100) * 200);
      while (dropsRef.current.length < targetCount) {
        dropsRef.current.push({ x: Math.random() * canvas.width, y: Math.random() * canvas.height, speed: 4 + Math.random() * 6, length: 10 + Math.random() * 20, opacity: 0.1 + Math.random() * 0.3 });
      }
      while (dropsRef.current.length > targetCount) { dropsRef.current.pop(); }

      // Draw each raindrop as a short vertical line
      ctx.strokeStyle = "rgba(174, 194, 224, 1)";
      ctx.lineWidth = 1;
      for (const drop of dropsRef.current) {
        ctx.globalAlpha = drop.opacity;
        ctx.beginPath();
        ctx.moveTo(drop.x, drop.y);
        ctx.lineTo(drop.x + 0.5, drop.y + drop.length);
        ctx.stroke();

        // Move drop downward and reset when off screen
        drop.y += drop.speed;
        if (drop.y > canvas.height) { drop.y = -drop.length; drop.x = Math.random() * canvas.width; }
      }

      ctx.globalAlpha = 1;
      animationRef.current = requestAnimationFrame(animate);
    };

    animate();

    return () => { window.removeEventListener("resize", resize); cancelAnimationFrame(animationRef.current); };
  }, [intensity]);

  // Render nothing when intensity is zero
  if (intensity === 0) return null;

  return (
    <canvas ref={canvasRef} className="rain-canvas" style={{ position: "absolute", top: 0, left: 0, width: "100%", height: "100%", pointerEvents: "none", zIndex: 0 }} />
  );
}

export default RainCanvas;

What does this code do?

The intensity prop (0-100) controls how many raindrops appear. The formula Math.floor((intensity / 100) * 200) maps the slider range to 0-200 active drops.

Each frame, requestAnimationFrame clears the canvas and redraws every drop slightly lower. When a drop falls past the bottom, it resets to the top at a new random x position. The canvas is positioned absolutely behind the app content with pointerEvents: "none" so it never blocks clicks.

  • Save RainCanvas.tsx.

Worried about performance?

200 raindrops is lightweight for a canvas animation. If you notice lag, check that your audio files are not excessively large. Keep each loop file under 5 MB.

Help me debug canvas performance issues in my Tauri app.

Wire the soundboard and rain animation into App.tsx

Both components are ready. Now you need to connect them in App.tsx so the rain volume slider drives the canvas animation intensity.

  • Open src/App.tsx and replace the entire file content with the following:
import { useState } from "react";
import Timer from "./components/Timer";
import Tasks from "./components/Tasks";
import Soundboard from "./components/Soundboard";
import RainCanvas from "./components/RainCanvas";
import "./App.css";

function App() {
  // Shared state connects the rain slider to the canvas animation
  const [rainVolume, setRainVolume] = useState(0);

  return (
    <div className="app">
      <RainCanvas intensity={rainVolume} />
      <div className="app-content">
        <header className="app-header">
          <h1>FocusFlow</h1>
        </header>
        <main className="app-main">
          <div className="left-panel">
            <Timer />
            <Tasks />
          </div>
          <div className="right-panel">
            <Soundboard onRainVolumeChange={setRainVolume} />
          </div>
        </main>
      </div>
    </div>
  );
}

export default App;

How are the components connected?

The rainVolume state lives in App and flows in two directions. It passes down to RainCanvas as the intensity prop, controlling the number of visible raindrops.

The Soundboard calls onRainVolumeChange whenever the rain slider moves, which updates rainVolume in App. This is a standard React pattern called lifting state up.

  • Save App.tsx.
  • Switch to your running Tauri dev window. You should see the Soundboard panel on the right side with three channel rows labeled Rain, Fire, and Wind.
  • Click the Rain toggle button, then drag the rain volume slider up. You should see raindrops begin to fall behind your UI, increasing in density as you raise the volume.

No sound playing when you toggle?

Check that your audio files are named exactly rain.ogg, fire.ogg, wind.ogg (plus their .mp3 versions) and placed inside public/sounds/. The paths in the code must match the file names exactly.

If you see the rain animation but hear nothing, your browser may require a user gesture before playing audio. Click the toggle button again to restart playback.

Help me troubleshoot audio playback in my Tauri soundboard.

✔️ Awesome, I've got everything!

Great. Double check you have saved all three files: Soundboard.tsx, RainCanvas.tsx, and App.tsx.

ⓧ I'd like to double check the full code

Here is the complete code for each file you created or modified in this step.

import { useState, useEffect, useRef } from "react";
import { Howl } from "howler";

interface SoundboardProps {
  onRainVolumeChange: (volume: number) => void;
}

function Soundboard({ onRainVolumeChange }: SoundboardProps) {
  const [volumes, setVolumes] = useState({ rain: 0, fire: 0, wind: 0 });
  const [playing, setPlaying] = useState({ rain: false, fire: false, wind: false });
  const soundsRef = useRef<Record<string, Howl | null>>({ rain: null, fire: null, wind: null });

  useEffect(() => {
    soundsRef.current.rain = new Howl({ src: ["/sounds/rain.ogg", "/sounds/rain.mp3"], loop: true, volume: 0 });
    soundsRef.current.fire = new Howl({ src: ["/sounds/fire.ogg", "/sounds/fire.mp3"], loop: true, volume: 0 });
    soundsRef.current.wind = new Howl({ src: ["/sounds/wind.ogg", "/sounds/wind.mp3"], loop: true, volume: 0 });
    return () => { Object.values(soundsRef.current).forEach((howl) => howl?.unload()); };
  }, []);

  const toggleSound = (name: string) => {
    const howl = soundsRef.current[name];
    if (!howl) return;
    if (playing[name as keyof typeof playing]) {
      howl.pause();
      setPlaying((prev) => ({ ...prev, [name]: false }));
    } else {
      howl.play();
      howl.volume(volumes[name as keyof typeof volumes] / 100);
      setPlaying((prev) => ({ ...prev, [name]: true }));
    }
  };

  const changeVolume = (name: string, value: number) => {
    setVolumes((prev) => ({ ...prev, [name]: value }));
    const howl = soundsRef.current[name];
    if (howl) { howl.volume(value / 100); }
    if (name === "rain") { onRainVolumeChange(value); }
  };

  const channels = [
    { name: "rain", label: "Rain", icon: "~" },
    { name: "fire", label: "Fire", icon: "*" },
    { name: "wind", label: "Wind", icon: ">" },
  ];

  return (
    <div className="soundboard-container">
      <h2 className="soundboard-title">Ambience</h2>
      {channels.map((channel) => (
        <div key={channel.name} className="sound-channel">
          <button className={`sound-toggle ${playing[channel.name as keyof typeof playing] ? "active" : ""}`} onClick={() => toggleSound(channel.name)}>
            <span className="sound-icon">{channel.icon}</span>
            <span className="sound-label">{channel.label}</span>
          </button>
          <input type="range" min="0" max="100" value={volumes[channel.name as keyof typeof volumes]} onChange={(e) => changeVolume(channel.name, Number(e.target.value))} className="volume-slider" />
          <span className="volume-value">{volumes[channel.name as keyof typeof volumes]}%</span>
        </div>
      ))}
    </div>
  );
}

export default Soundboard;
import { useEffect, useRef } from "react";

interface RainCanvasProps {
  intensity: number;
}

interface Raindrop {
  x: number;
  y: number;
  speed: number;
  length: number;
  opacity: number;
}

function RainCanvas({ intensity }: RainCanvasProps) {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const dropsRef = useRef<Raindrop[]>([]);
  const animationRef = useRef<number>(0);

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const ctx = canvas.getContext("2d");
    if (!ctx) return;

    const resize = () => { canvas.width = window.innerWidth; canvas.height = window.innerHeight; };
    resize();
    window.addEventListener("resize", resize);

    const animate = () => {
      if (!ctx || !canvas) return;
      ctx.clearRect(0, 0, canvas.width, canvas.height);
      const targetCount = Math.floor((intensity / 100) * 200);
      while (dropsRef.current.length < targetCount) {
        dropsRef.current.push({ x: Math.random() * canvas.width, y: Math.random() * canvas.height, speed: 4 + Math.random() * 6, length: 10 + Math.random() * 20, opacity: 0.1 + Math.random() * 0.3 });
      }
      while (dropsRef.current.length > targetCount) { dropsRef.current.pop(); }
      ctx.strokeStyle = "rgba(174, 194, 224, 1)";
      ctx.lineWidth = 1;
      for (const drop of dropsRef.current) {
        ctx.globalAlpha = drop.opacity;
        ctx.beginPath();
        ctx.moveTo(drop.x, drop.y);
        ctx.lineTo(drop.x + 0.5, drop.y + drop.length);
        ctx.stroke();
        drop.y += drop.speed;
        if (drop.y > canvas.height) { drop.y = -drop.length; drop.x = Math.random() * canvas.width; }
      }
      ctx.globalAlpha = 1;
      animationRef.current = requestAnimationFrame(animate);
    };

    animate();

    return () => { window.removeEventListener("resize", resize); cancelAnimationFrame(animationRef.current); };
  }, [intensity]);

  if (intensity === 0) return null;

  return (
    <canvas ref={canvasRef} className="rain-canvas" style={{ position: "absolute", top: 0, left: 0, width: "100%", height: "100%", pointerEvents: "none", zIndex: 0 }} />
  );
}

export default RainCanvas;
import { useState } from "react";
import Timer from "./components/Timer";
import Tasks from "./components/Tasks";
import Soundboard from "./components/Soundboard";
import RainCanvas from "./components/RainCanvas";
import "./App.css";

function App() {
  const [rainVolume, setRainVolume] = useState(0);

  return (
    <div className="app">
      <RainCanvas intensity={rainVolume} />
      <div className="app-content">
        <header className="app-header">
          <h1>FocusFlow</h1>
        </header>
        <main className="app-main">
          <div className="left-panel">
            <Timer />
            <Tasks />
          </div>
          <div className="right-panel">
            <Soundboard onRainVolumeChange={setRainVolume} />
          </div>
        </main>
      </div>
    </div>
  );
}

export default App;

Your app now feels like a proper focus environment. Layered sounds and a responsive rain animation create a calm atmosphere for deep work. Next up, you will package this entire app into a native Windows installer that you can share with anyone.

Package as a Windows Installer

Your FocusFlow app is fully functional in development mode. The timer counts down, tasks persist, and ambient sounds layer together beautifully.

The final step is turning this into a real Windows application that anyone can install. Tauri compiles your React frontend and Rust shell into a native installer, just like the apps you download from any software website.

In this step, get ready to:
  • Configure the app's production settings (name, window size, identifier).
  • Build a Windows installer that produces an .msi and .exe file.
Configure Tauri for production

Before building an installer, you need to tell Tauri what your app is called, how big its window should be, and what unique identifier to use.

  • In VS Code, open the file src-tauri/tauri.conf.json from the Explorer sidebar.
  • Find the productName field near the top of the file. Update it to:
"productName": "FocusFlow"
  • Find the identifier field. Update it to a unique reverse-domain identifier:
"identifier": "com.focusflow.app"

What is the identifier?

The identifier is a unique string in reverse domain name notation that identifies your app on the system. It is used in registry paths, data directories, and installer metadata. It must contain only alphanumeric characters, hyphens, and periods.

  • Find the app object, then the windows array inside it. Update the first window entry to set the title, default size, and minimum size:
"windows": [
  {
    "title": "FocusFlow",
    "width": 900,
    "height": 700,
    "minWidth": 600,
    "minHeight": 500,
    "fullscreen": false,
    "resizable": true
  }
]

What do these window settings do?

The width and height set the default window dimensions when the app launches. The minWidth and minHeight prevent users from shrinking the window below 600x500, which would break the layout.

  • Save src-tauri/tauri.conf.json.
  • Verify the config works by running npm run tauri dev again. The window title bar should now say FocusFlow and the window should open at 900x700.

Window title still says the old name?

Make sure you saved tauri.conf.json before running the dev command. Also check that title is inside the window object within the app.windows array, not at the top level.

Why isn't my Tauri window title updating?

Build the production installer

With the config in place, Tauri can now compile your React frontend into static assets, bundle them with the Rust shell, and produce Windows installer files.

  • Close the dev server if it is still running by pressing Ctrl+C in the terminal.
  • Build the production installer by running:
npm run tauri build

What does this command do?

This command runs your Vite build to compile the React frontend into optimized static files. Then it compiles the Rust backend in release mode and bundles everything into Windows installers.

The first build takes several minutes because Rust compiles all dependencies from scratch. Subsequent builds are much faster thanks to caching.

  • Wait for the build to complete. You will see output in the terminal as it compiles Rust dependencies and creates the installer bundles.
  • Once the build finishes, find the installers by running:
ls src-tauri/target/release/bundle/

You should see two folders: one for the .msi installer (created via WiX) and one for the NSIS .exe installer.

What are the two installer types?

The .msi file is a Windows Installer package built with WiX Toolset. It integrates with Windows' built-in installer framework and supports features like silent installs.

The NSIS .exe is a standalone setup executable. Both produce the same installed application. You can share either file with anyone who wants to install your app.

  • Test the installer by double-clicking the generated .exe setup file inside src-tauri/target/release/bundle/nsis/.
  • Follow the install wizard prompts and launch the app. FocusFlow should open as a standalone desktop application, completely independent of your terminal or dev tools.

Build failing with an error?

If you see a Rust compilation error, make sure you closed the dev server before building. The build cannot proceed while another Tauri process holds the target directory.

If you see an error about missing Microsoft C++ Build Tools, revisit Step 1 and ensure you installed the "Desktop development with C++" workload.

My Tauri build is failing. How do I fix it?

You have shipped a real desktop application. From React components to a downloadable Windows installer, FocusFlow is now a product you can share with anyone.

Secret mission

Add a Tree Growth Focus Mode

What if your focus time grew something beautiful? In this bonus challenge, you'll add an alternative "Tree Mode" that replaces the soundboard with a generative canvas tree. The tree grows taller and sprouts new branches each second the timer runs. Complete a full work session and leaves bloom at the tips.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources as they are, or delete them entirely.

Resources you used:

  • FocusFlow app installed on your system (from the .msi or .exe installer).
  • Tauri project folder on your Desktop (source code, node_modules, Rust build artifacts).
  • Rust toolchain (installed via rustup).
  • Microsoft C++ Build Tools (installed via Visual Studio Installer).

Keep everything running

No action needed. The app stays installed like any normal Windows program. There are zero ongoing costs.

  • FocusFlow remains in your Start menu and runs whenever you open it.
  • Your project folder stays on your Desktop for future edits.
  • Rust and C++ Build Tools remain available for future Tauri projects.

Pause - I'll come back to this later

There is nothing to pause for this project. Everything is local and costs nothing to keep. Treat this the same as the Keep tab.

Delete - I don't want to use this again

Remove all project resources and start fresh. Follow each item below to fully clean up.

  • Uninstall the FocusFlow app. Open Windows Settings, select Apps, then Installed apps. Search for FocusFlow, click the three-dot menu, and select Uninstall.
  • Delete the project folder. Open PowerShell and run the command below:
  • Uninstall the Rust toolchain. Open a new terminal and run:
  • Uninstall Microsoft C++ Build Tools. Open Windows Settings, select Apps, then Installed apps. Search for Visual Studio Build Tools, click the three-dot menu, and select Uninstall.

Delete the project folder by running:

Remove-Item -Recurse -Force "$HOME\Desktop\focusflow"

Uninstall the Rust toolchain by running:

rustup self uninstall

When prompted, confirm the uninstall. This removes all Rust toolchains, Cargo, and associated data from your system.

That's a Wrap!

That's a Wrap!

Nice work! You just built and shipped a native Windows desktop app from scratch using React, TypeScript, and Tauri.

You've learned how to:

  • Build a full Pomodoro timer with customizable work/break sessions, a task list, and localStorage persistence.
  • Implement an ambient soundboard mixing three simultaneous looping audio tracks with Howler.js, paired with a dynamic HTML5 Canvas rain animation that responds to volume.
  • Package a React + TypeScript frontend into a native Windows installer using Tauri 2, producing both .msi and .exe installers ready to share.
  • Secret Mission: Added a generative tree growth animation as an alternative focus mode that grows while the timer runs and sprouts leaves after completing a session.

Ready to quiz yourself?