Beginner1 hour12+5 parts needed

Parent info

Cost: ~$39
Time: 1 hour
Age: 12+
Difficulty: ●●●
Soldering: No soldering needed
What they'll learn: Microcontroller programming, LED circuits, LED animation libraries, WiFi networking

Parts you need

Affiliate links — we may earn a small commission

ESP32-S3 Dev Board
WS2812B LED Strip 5m 60LED/m
5V 2A Power Supply
330-ohm Resistor
Breadboard + Jumper Wires
🎮

Try this circuit in your browser!

Run the code, press the buttons and watch what happens — before you buy any parts. No account needed.

Open in Simulator →

Your phone controls the Christmas tree.

Imagine this: you’re on the couch. You tap a button on your phone — no app, just a browser — and your Christmas tree shifts from twinkling white stars to a slow rainbow wave rolling up every branch.

You built that. You wired it, you coded it, you made it happen. And it runs all season without touching a thing.

That’s what we’re building. In about an hour. For roughly $27.


What you’ll need

Part What it does Price
ESP32-S3 Dev Board The brain — runs the web server and controls the LEDs ~$12
WS2812B LED Strip, 5m The lights — each LED is individually addressable ~$15
5V 2A Power Supply Powers the LEDs (the ESP32 cannot handle a full strip) ~$6
330-ohm resistor Protects the first LED from signal spikes on startup ~$1
Breadboard + jumper wires Connects everything. No soldering for the ESP32 side. ~$5

You also need: a computer with Arduino IDE installed, your home WiFi name and password.

Total: ~$27 | Time: ~1 hour | Difficulty: ●○○○○

Note: Power the LED strip directly from the 5V supply — NOT from the ESP32’s 3.3V pin. A full strip draws more current than the board can safely provide.


How it works (60 seconds)

Think of it like this: each LED on the strip has its own tiny brain.

The strip is a chain of smart LEDs. The ESP32 sends a message down the chain: “LED 1, be red. LED 2, be blue. LED 3, be off.” Each LED reads its own instruction and passes the rest down to the next one.

The 330-ohm resistor is a speed bump — it slows the electrical signal just enough to stop the first LED from getting confused by a voltage spike when the board powers on.

The web server is the clever part. Your phone’s browser connects to a tiny website running on the ESP32 itself — no internet needed, just your home WiFi. When you tap a button, it sends a request to the board, and the pattern changes. Like changing the channel on a TV, except the TV is hanging on your Christmas tree.


Wiring diagram for Smart Christmas Tree Lights: esp32 s3 devkitc 1 connected to r1, WS2812B LED Strip (90 LEDs)

Step 0: Cut and prep your LED strip

Time: ~10 minutes

WS2812B strips have cut marks every 3 LEDs — little scissors symbols printed on the backing. Cut there only.

For a typical small tree, 90 LEDs (1.5 metres) is a good starting point. For a tall tree, go up to 150.

Solder three short wires (~10cm) to the input pads at the start of your strip:

  • Red wire → VCC pad
  • Black wire → GND pad
  • Any other colour → DIN (data input) pad

Check: The DIN pad is the input end. LED strips have a direction — the arrow on the backing points from input to output. Start from the end with no arrow pointing away from it.


Step 1: Wire it up

Time: ~10 minutes

You’re making 4 connections.

LED strip power (2 wires from strip to 5V supply):

  1. Strip VCC (red) → 5V supply +5V — red wire
  2. Strip GND (black) → 5V supply GND — black wire

LED strip data (2 connections through the resistor): 3. Strip DIN → one leg of the 330-ohm resistor 4. Other leg of resistor → ESP32 GPIO 2 (C6: GPIO 8) — any colour wire

Shared ground: 5. ESP32 GND pin → 5V supply GND (same rail as the strip GND)

Check: Count your connections: 2 wires from strip to power supply, 1 wire from strip DIN through a resistor to GPIO 2 (C6: GPIO 8), and ESP32 GND connected to the same ground as the power supply. The ESP32’s USB-C is NOT plugged in yet.

Common mistake: Connecting the LED strip VCC to the ESP32’s 3.3V pin. That pin is for sensors, not for powering a whole LED strip. Use the external 5V supply.


Step 2: Upload the code

Time: ~10 minutes

  1. Open Arduino IDE on your computer.
  2. Go to Tools → Board → Boards Manager and make sure “esp32 by Espressif” is installed.
  3. Go to Sketch → Include Library → Manage Libraries and search for “FastLED” — install it.
  4. Copy the complete code below into a new sketch.
  5. Find these two lines and fill in your WiFi details:
    const char* ssid     = "YourWiFiName";
    const char* password = "YourWiFiPassword";
  6. Check NUM_LEDS matches your strip length (90 for 1.5m at 60LED/m).
  7. Connect the ESP32 to your computer via USB-C. Select your board under Tools → Board and your port under Tools → Port.
  8. Click Upload.

The big picture first. This program turns the ESP32 into a WiFi-controlled Christmas tree. The ESP32 is the brain. The WS2812B LED strip wraps around your tree — each light has its own tiny chip that can display any colour on command. The ESP32 also runs a tiny website inside itself. When you open that website on your phone and tap a button, the tree’s pattern changes instantly. No app, no Bluetooth — just your home WiFi.

A program is like a recipe. Fill in your WiFi details and upload.

// ========== CHOOSE YOUR BOARD ==========
// Uncomment the line for YOUR board:
#define BOARD_S3    // ESP32-S3-DevKitC-1
//#define BOARD_C6  // ESP32-C6-DevKitC-1
// ========================================

#ifdef BOARD_S3
  #define PIN_NEOPIXEL           2
#endif
#ifdef BOARD_C6
  #define PIN_NEOPIXEL           8
#endif

#include <FastLED.h>
#include <WiFi.h>
#include <WebServer.h>

#define LED_PIN       PIN_NEOPIXEL
#define NUM_LEDS      90
#define BRIGHTNESS    80
#define LED_TYPE      WS2812B
#define COLOR_ORDER   GRB

const char* ssid     = "YourWiFiName";
const char* password = "YourWiFiPassword";

CRGB leds[NUM_LEDS];
WebServer server(80);
int currentPattern = 0;
unsigned long lastPatternChange = 0;
const unsigned long PATTERN_DURATION = 30000;

const char* htmlPage = R"rawhtml(
<!DOCTYPE html><html><head>
<title>Christmas Tree</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>body{font-family:sans-serif;text-align:center;background:#1a0a00;color:#fff;padding:20px;}
button{font-size:1.2em;margin:10px;padding:15px 30px;border:none;border-radius:8px;cursor:pointer;}</style>
</head><body>
<h1>🎄 Christmas Tree</h1>
<p>Choose a pattern:</p>
<button style="background:#c41e3a;" onclick="fetch('/pattern?id=0')">Twinkling Stars</button>
<button style="background:#228b22;" onclick="fetch('/pattern?id=1')">Rainbow Wave</button>
<button style="background:#fff;color:#c41e3a;" onclick="fetch('/pattern?id=2')">Candy Cane</button>
<button style="background:#4488ff;" onclick="fetch('/pattern?id=3')">Snowfall</button>
<button style="background:#555;" onclick="fetch('/pattern?id=4')">Auto Cycle</button>
</body></html>
)rawhtml";

void patternTwinkle() {
  fadeToBlackBy(leds, NUM_LEDS, 20);
  int pos = random16(NUM_LEDS);
  leds[pos] += CHSV(0, 0, random8(150, 255));
}

void patternRainbow() {
  static uint8_t hue = 0;
  fill_rainbow(leds, NUM_LEDS, hue, 7);
  hue++;
}

void patternCandyCane() {
  static uint8_t offset = 0;
  for (int i = 0; i < NUM_LEDS; i++) {
    if (((i + offset) / 4) % 2 == 0) {
      leds[i] = CRGB::Red;
    } else {
      leds[i] = CRGB::White;
    }
  }
  offset++;
}

void patternSnowfall() {
  fadeToBlackBy(leds, NUM_LEDS, 30);
  if (random8() < 60) {
    int pos = random8(NUM_LEDS - 5, NUM_LEDS - 1);
    leds[pos] = CRGB(180, 200, 255);
  }
  for (int i = 0; i < NUM_LEDS - 1; i++) {
    if (leds[i + 1].getAverageLight() > 20) {
      leds[i] += leds[i + 1];
    }
  }
}

void setup() {
  Serial.begin(115200);
  FastLED.addLeds<LED_TYPE, LED_PIN, COLOR_ORDER>(leds, NUM_LEDS);
  FastLED.setBrightness(BRIGHTNESS);
  FastLED.clear();
  FastLED.show();

  WiFi.begin(ssid, password);
  Serial.print("Connecting to WiFi");
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }
  Serial.println("\nConnected! Open this in your phone browser: http://" + WiFi.localIP().toString());

  server.on("/", []() {
    server.send(200, "text/html", htmlPage);
  });
  server.on("/pattern", []() {
    if (server.hasArg("id")) {
      currentPattern = server.arg("id").toInt();
      lastPatternChange = millis();
    }
    server.send(200, "text/plain", "OK");
  });
  server.begin();
}

void loop() {
  server.handleClient();

  if (currentPattern == 4) {
    if (millis() - lastPatternChange > PATTERN_DURATION) {
      static int autoIndex = 0;
      autoIndex = (autoIndex + 1) % 4;
      currentPattern = autoIndex;
      lastPatternChange = millis();
    }
  }

  switch (currentPattern) {
    case 0: patternTwinkle();   break;
    case 1: patternRainbow();   break;
    case 2: patternCandyCane(); break;
    case 3: patternSnowfall();  break;
    case 4: patternTwinkle();   break;
  }

  FastLED.show();
  delay(30);
}

Line-by-line: what every line does and why

Lines 1–3: Borrowing three instruction books

#include <FastLED.h>
#include <WiFi.h>
#include <WebServer.h>

#include means “grab this instruction book.”

  • FastLED — how to control smart LED strips.
  • WiFi — how to connect to your home network.
  • WebServer — how to turn the ESP32 into a tiny website.

Lines 5–10: Named numbers

#define LED_PIN       PIN_NEOPIXEL
#define NUM_LEDS      90
#define BRIGHTNESS    80
#define LED_TYPE      WS2812B
#define COLOR_ORDER   GRB

#define gives numbers or words names. Data wire on pin 2 (C6: pin 8) — the number comes from PIN_NEOPIXEL in the board block at the top. 90 LEDs (1.5m at 60/m). Brightness 80 out of 255 — gentle to start with. WS2812B is the model name of these smart LEDs. GRB is the colour order — this strip sends green before red internally. FastLED handles the translation.


Lines 12–13: WiFi details

const char* ssid     = "YourWiFiName";
const char* password = "YourWiFiPassword";

const char* stores a piece of text — like a sticky note. const means “don’t change this.” Replace the placeholders with your real WiFi name and password.


Lines 15–19: Global variables

CRGB leds[NUM_LEDS];
WebServer server(80);
int currentPattern = 0;
unsigned long lastPatternChange = 0;
const unsigned long PATTERN_DURATION = 30000;
  • CRGB leds[90] — a shelf with 90 boxes, one per LED. Each holds a colour.
  • WebServer server(80) — creates the website engine on port 80. Port 80 is what browsers use. Named server.
  • int currentPattern = 0 — which pattern is active. 0 = twinkling stars. int means whole number.
  • unsigned long lastPatternChange = 0 — sticky note: when did the pattern last change?
  • PATTERN_DURATION = 30000 — 30 seconds in auto mode before switching.

The HTML web page

const char* htmlPage = R"rawhtml(
  ...buttons...
)rawhtml";

This is the webpage your phone sees. R"rawhtml(...)rawhtml" is a way to write a big block of text in the code without needing to escape every quote mark. The onclick="fetch('/pattern?id=0')" on each button means: when you tap it, the phone sends a request to /pattern?id=0 on the ESP32. That’s how the button communicates with the lights.


patternTwinkle(): random white sparks

void patternTwinkle() {
  fadeToBlackBy(leds, NUM_LEDS, 20);
  int pos = random16(NUM_LEDS);
  leds[pos] += CHSV(0, 0, random8(150, 255));
}
  • fadeToBlackBy(leds, NUM_LEDS, 20) — dim every LED by 20/255 each frame. Old sparks fade out.
  • random16(NUM_LEDS) — pick a random position between 0 and 89.
  • CHSV(0, 0, random8(150, 255)) — CHSV means Hue, Saturation, Value. Hue 0, saturation 0 = pure white. Value = random brightness 150–255.
  • leds[pos] += — the += adds to the existing colour rather than replacing it. This makes sparks build up before fading, looking more natural.

patternRainbow(): a rolling colour wave

void patternRainbow() {
  static uint8_t hue = 0;
  fill_rainbow(leds, NUM_LEDS, hue, 7);
  hue++;
}
  • static uint8_t hue = 0 — static remembers this between calls. hue is the starting colour (0=red, wraps around after 255).
  • fill_rainbow(leds, NUM_LEDS, hue, 7) — FastLED’s built-in rainbow painter. Fills all 90 LEDs with a spectrum starting at hue. The 7 is how much the hue shifts per LED.
  • hue++ — advance by 1 each frame. The rainbow slowly scrolls.

patternCandyCane(): red and white stripes scrolling

void patternCandyCane() {
  static uint8_t offset = 0;
  for (int i = 0; i < NUM_LEDS; i++) {
    if (((i + offset) / 4) % 2 == 0) {
      leds[i] = CRGB::Red;
    } else {
      leds[i] = CRGB::White;
    }
  }
  offset++;
}
  • static uint8_t offset = 0 — remembered, increases each frame to scroll the pattern.
  • for (int i = 0; i < NUM_LEDS; i++) — loop through all 90 LEDs.
  • ((i + offset) / 4) % 2 == 0 — divide by 4 to make bands 4 LEDs wide, then % 2 checks even/odd band. Even = red, odd = white.
  • offset++ — shift the whole pattern one step. This scrolls the stripes.

patternSnowfall(): snowflakes drifting down

void patternSnowfall() {
  fadeToBlackBy(leds, NUM_LEDS, 30);
  if (random8() < 60) {
    int pos = random8(NUM_LEDS - 5, NUM_LEDS - 1);
    leds[pos] = CRGB(180, 200, 255);
  }
  for (int i = 0; i < NUM_LEDS - 1; i++) {
    if (leds[i + 1].getAverageLight() > 20) {
      leds[i] += leds[i + 1];
    }
  }
}
  • fadeToBlackBy(30) — slowly dim everything. Old snowflakes fade.
  • random8() < 60 — about 24% chance per frame to spawn a new snowflake at the top of the strip (near the end of the array = top of the tree).
  • CRGB(180, 200, 255) — icy blue-white.
  • The for loop copies each LED’s brightness to the one below it: leds[i] += leds[i + 1]. This makes the flake drift downward — it copies itself one step lower each frame. getAverageLight() checks if a flake is bright enough to be worth copying.

setup(): runs once on power-on

void setup() {
  Serial.begin(115200);
  FastLED.addLeds<LED_TYPE, LED_PIN, COLOR_ORDER>(leds, NUM_LEDS);
  FastLED.setBrightness(BRIGHTNESS);
  FastLED.clear();
  FastLED.show();

Standard LED setup: open the phone line, register the strip, set brightness, blank the strip, send to hardware.

  WiFi.begin(ssid, password);
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }
  Serial.println("\nConnected! Open this in your phone browser: http://" + WiFi.localIP().toString());

while (WiFi.status() != WL_CONNECTED) — != means “not equal to.” Wait, printing a dot every half second, until connected. Then print the IP address — this is what you type into your phone.

  server.on("/", []() {
    server.send(200, "text/html", htmlPage);
  });
  server.on("/pattern", []() {
    if (server.hasArg("id")) {
      currentPattern = server.arg("id").toInt();
      lastPatternChange = millis();
    }
    server.send(200, "text/plain", "OK");
  });
  server.begin();
}
  • server.on("/", ...) — when someone visits the root address, send them the HTML page. The []() { ... } is a tiny nameless function (lambda).
  • server.on("/pattern", ...) — when a button is tapped, the phone calls /pattern?id=0 (or 1, 2, 3, 4). server.hasArg("id") checks if id was included. server.arg("id").toInt() reads its value as a number. currentPattern gets updated.
  • server.begin() — start listening for connections.

loop(): the heartbeat — runs forever

void loop() {
  server.handleClient();

Must be called every frame. Checks if any phone is connecting or has tapped a button. Without this, the web server would freeze.

  if (currentPattern == 4) {
    if (millis() - lastPatternChange > PATTERN_DURATION) {
      static int autoIndex = 0;
      autoIndex = (autoIndex + 1) % 4;
      currentPattern = autoIndex;
      lastPatternChange = millis();
    }
  }

Pattern 4 = Auto Cycle mode. If 30 seconds have passed, advance autoIndex through 0–3 (% 4 wraps it), and set currentPattern to that. static int autoIndex remembers which pattern was last shown across frames.

  switch (currentPattern) {
    case 0: patternTwinkle();   break;
    case 1: patternRainbow();   break;
    case 2: patternCandyCane(); break;
    case 3: patternSnowfall();  break;
    case 4: patternTwinkle();   break;
  }
  FastLED.show();
  delay(30);
}

switch is a menu. Jump to the right pattern. FastLED.show() sends the frame to the strip. delay(30) = about 33 frames per second.


The whole thing in one sentence

When powered on, the ESP32 connects to WiFi and starts a tiny website (setup). Then it loops forever: check for button taps from your phone, draw the current pattern, send it to the strip — 33 times per second.

First thing to try: open Serial Monitor after uploading. Wait for the IP address to appear, type it into your phone browser, and tap each button to confirm all 4 patterns work.

Check: Open the Serial Monitor (Tools → Serial Monitor, set to 115200 baud). Wait for the message “Connected! Open this in your phone browser: http://192.168.x.x”. That IP address is what you type into your phone.


Step 3: Connect and pick a pattern

Time: ~5 minutes

  1. Plug the 5V power supply into the LED strip.
  2. On your phone, open any browser.
  3. Make sure your phone is on the same WiFi network as the ESP32.
  4. Type the IP address from the Serial Monitor (e.g. http://192.168.1.42).
  5. You’ll see a dark page with 5 coloured buttons. Tap one.

Check: The LEDs should change immediately when you tap. If nothing happens after 2 seconds, tap again — the first tap sometimes takes a moment on initial connection.


Step 4: Hang it on the tree!

Time: ~15 minutes

  1. Wrap the strip around your tree in a loose spiral — one LED roughly every 5cm works well.
  2. Use small cable clips or twist-ties every few LEDs to keep the strip from sliding down overnight.
  3. Tuck the ESP32 and power supply in a small basket or box at the base of the tree.
  4. Set to Auto Cycle before bed. Wake up to a different pattern every 30 seconds, all night.

What just happened (what you learned)

Congratulations — you just built your first IoT device. Here’s what’s actually going on:

  • FastLED controls addressable LEDs — each LED has a tiny chip that listens for colour commands, like a string of smart bulbs where you tell each one exactly what colour to be.

  • GPIO pins are the ESP32’s hands — numbered output ports that send or receive electrical signals. GPIO 2 (C6: GPIO 8) sends the data signal that tells the strip what to show each frame.

  • The 330-ohm resistor is a speed bump for electrical signals. It prevents a tiny voltage spike from frying the first LED in the chain when the ESP32 powers on.

  • The web server on the ESP32 means your board is literally a tiny website. Your phone’s browser connects to it the same way it connects to any website — just over home WiFi instead of the internet.

  • fadeToBlackBy() dims every LED a small amount each frame, so old colours fade out automatically. This is how you get smooth animations without tracking which LEDs you last touched.


Level Up

Add a new pattern without rewriting everything: The code is already set up to add more patterns. Write a new function like patternFireplace(), add it to the switch statement as case 5, and change % 4 to % 5 in the auto-cycle.

Make the tree dim itself at bedtime: The ESP32 is already on WiFi. Add configTime() to get the real time from the internet, then check the hour in loop() — if it’s past 10pm, reduce BRIGHTNESS to 20. The tree dims automatically.

Remember your last pattern after a power cut: Use the Preferences library (built into ESP32 Arduino) to save currentPattern to non-volatile storage every time it changes. Your tree will remember where you left it.


Troubleshooting

Problem Fix
Serial Monitor shows dots forever, no IP Check WiFi name and password (case-sensitive). Make sure your router is 2.4GHz — ESP32 doesn’t support 5GHz.
Phone can’t reach the IP address Your phone must be on the same WiFi network as the ESP32. Mobile data won’t work here.
LEDs don’t light up at all Check that the 5V supply is plugged in. Check that GND is shared between the ESP32 and the power supply.
First LED is wrong colour or stays on Make sure the DIN wire goes through the 330-ohm resistor before reaching GPIO 2 (C6: GPIO 8).
Pattern changes but LEDs look wrong Set COLOR_ORDER GRB (not RGB) — most WS2812B strips use GRB order.
Upload fails in Arduino IDE Make sure you selected the correct board (ESP32S3 Dev Module) and the correct COM port.

Affiliate disclosure: Some links on this page are affiliate links. If you buy through them, we may earn a small commission at no extra cost to you.