Parent info
Parts you need
Affiliate links — we may earn a small commission
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 →The star on top just got an upgrade.
Imagine this: the lights go out. Everyone looks at the tree. The star on top breathes in slowly — warm golden amber — and back out. A slow pulse, like the whole tree is alive.
Your family asks where you bought it. You made it. In an afternoon.
That’s what we’re building. About 1 hour of electronics (plus 3D print time). Around $24.
What you’ll need
| Part | What it does | Price |
|---|---|---|
| ESP32-S3 Dev Board | Controls the 10 LEDs, runs the patterns | ~$12 |
| WS2812B individual LED modules (x10) | Two LEDs in each of the 5 star arms | ~$3 |
| 1000mAh LiPo + TP4056 charger | Battery system — charge via USB-C, runs all evening | ~$6 |
| Thin silicone wire, 28AWG | Routes through the star arms without being bulky | ~$2 |
| 330-ohm resistor | Protects the first LED | ~$1 |
You also need: a 3D printer with translucent gold or white PLA, hot glue.
Total: ~$24 (plus filament) | Time: ~1 hour + print | Difficulty: ●○○○○
The 3D print: Download a hollow five-pointed star from Printables.com — search “LED star tree topper hollow arms.” Print in translucent gold or white PLA at 20% infill. The translucent material diffuses the LED light beautifully.
How it works (60 seconds)
Think of it like a daisy chain of fairy lights where every bulb is smart.
You solder 10 WS2812B LED modules in a chain: the output pin of LED 1 connects to the input of LED 2, LED 2 to LED 3, and so on for all 10. The ESP32 sends one long message down the chain — each LED reads its own colour instruction and passes the rest along.
Route 2 LEDs into each of the 5 star arms. The ESP32 and battery hide at the tree base, with one thin wire running up the trunk.
The LiPo battery system has two parts: the TP4056 module handles safe USB-C charging, and the battery itself stores energy. Your ESP32 runs from the battery output. One thin wire for data, one for power, one for ground — that’s the only run going up the tree.
Step 0: Print and prep the star
Time: Print time varies — do this first
- Download a hollow five-pointed star STL from Printables.com. Search “LED tree topper hollow arms” — filter for models with arm channels wide enough for 28AWG wire.
- Print in translucent gold or white PLA, 20% infill, 0.2mm layer height.
- After printing, test-fit the silicone wire through each arm channel before assembling electronics. The wire must slide freely.
Check: You should be able to push a short length of thin wire through each arm from the body to the tip and back. If it’s too tight, try a thinner wire or a different model.
Step 1: Solder the LED chain
Time: ~20 minutes
This is the most fiddly step. Take it slowly.
Solder 10 LED modules in a series chain:
- Each module has 3 pads: DIN (data in), DOUT (data out), VCC, and GND.
- Connect the DOUT of LED 1 to the DIN of LED 2.
- Continue: DOUT of 2 → DIN of 3, all the way to LED 10.
- VCC of all 10 LEDs connect together (red wire).
- GND of all 10 LEDs connect together (black wire).
Keep wire between LEDs short — about 4cm per segment inside the arms.
Route 2 LEDs into each arm. Push them gently to the arm tip. A small drop of hot glue holds each LED in place.
Check: Before routing into the star, connect the chain to the ESP32 and power it up with the test code below. All 10 should light up. Fix any solder joints that aren’t working before threading into the star arms — it’s nearly impossible to fix after.
Step 2: Wire the battery system
Time: ~10 minutes
The TP4056 module sits between the LiPo battery and the ESP32. It handles safe charging.
[LiPo battery] → [TP4056 BAT+ / BAT-]
[TP4056 OUT+/OUT-] → [ESP32 VIN / GND]
[ESP32 GPIO 2 (C6: GPIO 8)] --[330 ohm]--> [LED chain DIN (LED 1)]
[ESP32 GND] → [LED chain GND (common)]
[TP4056 OUT+] → [LED chain VCC (common)]
Check: The TP4056 module has a red LED (charging) and a blue LED (charged). Plug USB-C into the TP4056. The red LED should light up. That means the battery is charging. Wait until it turns blue before your first real use.
Step 3: Upload the code
Time: ~5 minutes
The big picture first. This program turns the ESP32 into a smart tree topper controller — no phone, no WiFi, just battery-powered magic on top of your tree. The ESP32 is the brain. The 10 WS2812B LEDs are smart lights chained together, 2 in each of the 5 star arms. The program cycles through 3 patterns automatically: golden breathing, rainbow shimmer, and a shooting star chasing around the tips. It runs all evening on a LiPo battery.
A program is like a recipe. The computer reads it top to bottom and does exactly what’s written.
// ========== 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>
#define LED_PIN PIN_NEOPIXEL
#define NUM_LEDS 10
#define BRIGHTNESS 60
CRGB leds[NUM_LEDS];
unsigned long lastPatternSwitch = 0;
int currentPattern = 0;
const unsigned long PATTERN_MS = 20000;
void patternGoldenBreathe() {
uint8_t breathVal = beatsin8(8, 30, 220);
for (int i = 0; i < NUM_LEDS; i++) {
leds[i] = CHSV(30, 200, breathVal);
}
}
void patternRainbowShimmer() {
static uint8_t startHue = 0;
startHue++;
for (int i = 0; i < NUM_LEDS; i++) {
leds[i] = CHSV(startHue + (i * 25), 200, 180);
}
}
void patternShootingStar() {
static int pos = 0;
static unsigned long lastStep = 0;
fadeToBlackBy(leds, NUM_LEDS, 60);
if (millis() - lastStep > 80) {
leds[pos % NUM_LEDS] = CRGB::White;
leds[(pos + 1) % NUM_LEDS] = CRGB(100, 100, 100);
pos++;
lastStep = millis();
}
}
void setup() {
FastLED.addLeds<WS2812B, LED_PIN, GRB>(leds, NUM_LEDS);
FastLED.setBrightness(BRIGHTNESS);
FastLED.clear();
FastLED.show();
}
void loop() {
if (millis() - lastPatternSwitch > PATTERN_MS) {
currentPattern = (currentPattern + 1) % 3;
lastPatternSwitch = millis();
}
switch (currentPattern) {
case 0: patternGoldenBreathe(); break;
case 1: patternRainbowShimmer(); break;
case 2: patternShootingStar(); break;
}
FastLED.show();
delay(20);
}
Line-by-line: what every line does and why
Line 1: Borrowing the LED instruction book
#include <FastLED.h>
#include means “grab this instruction book.” FastLED contains all the rules for talking to smart LED chains — we borrow the whole book.
Lines 3–5: Named numbers
#define LED_PIN PIN_NEOPIXEL
#define NUM_LEDS 10
#define BRIGHTNESS 60
#define gives a number a name — like a sticky note. Data wire on pin 2 (C6: pin 8) — the number comes from PIN_NEOPIXEL in the board block at the top. 10 LEDs total (2 per arm × 5 arms). Brightness 60 out of 255 — about 24%. Low because it’s battery-powered; running LEDs at full brightness drains the battery fast.
Line 7: The LED array — 10 boxes
CRGB leds[NUM_LEDS];
A shelf with 10 compartments, numbered 0 to 9. Each holds one LED’s colour. LEDs 0–1 are in arm 1, LEDs 2–3 in arm 2, and so on. CRGB stands for “Colour — Red, Green, Blue.”
Lines 9–11: Pattern tracking
unsigned long lastPatternSwitch = 0;
int currentPattern = 0;
const unsigned long PATTERN_MS = 20000;
unsigned long lastPatternSwitch = 0— a sticky note: “when did I last change pattern?”unsigned longholds huge numbers needed for milliseconds.int currentPattern = 0— which pattern is showing right now (0, 1, or 2).intmeans whole number.PATTERN_MS = 20000— 20,000 milliseconds = 20 seconds per pattern.
patternGoldenBreathe(): the whole star breathing amber
void patternGoldenBreathe() {
uint8_t breathVal = beatsin8(8, 30, 220);
for (int i = 0; i < NUM_LEDS; i++) {
leds[i] = CHSV(30, 200, breathVal);
}
}
void means “this function does something but doesn’t return an answer.”
beatsin8(8, 30, 220)— generates a smooth up-and-down wave, like a heartbeat. 8 is BPM (beats per minute — slow and gentle). The value bounces between 30 (dim) and 220 (bright). The result goes intobreathVal.uint8_tis a whole number from 0 to 255.for (int i = 0; i < NUM_LEDS; i++)— loop through all 10 LEDs.int i = 0creates a counter.i < NUM_LEDSmeans “keep going while i is less than 10.”i++adds 1 each time.CHSV(30, 200, breathVal)— CHSV means “Colour using Hue, Saturation, Value.” Hue 30 = warm amber (like a candle). Saturation 200 = fairly vivid colour. Value =breathVal— this is the brightness, which changes each frame. So the star slowly glows brighter and then dimmer, over and over.
patternRainbowShimmer(): rolling rainbow across the star
void patternRainbowShimmer() {
static uint8_t startHue = 0;
startHue++;
for (int i = 0; i < NUM_LEDS; i++) {
leds[i] = CHSV(startHue + (i * 25), 200, 180);
}
}
static uint8_t startHue = 0—staticmeans “remember this between calls.”startHueis the starting colour. It goes 0–255 and wraps around (255 + 1 = 0 automatically withuint8_t).startHue++— advance the starting colour by 1 each frame. Over time the whole rainbow slowly slides.CHSV(startHue + (i * 25), 200, 180)— each of the 10 LEDs gets a hue shifted byi * 25from the start. LED 0 = startHue. LED 1 = startHue + 25. LED 2 = startHue + 50. This spreads the rainbow across the star. Since hue wraps around at 256, the rainbow is continuous.
patternShootingStar(): a bright dot chasing around the arms
void patternShootingStar() {
static int pos = 0;
static unsigned long lastStep = 0;
fadeToBlackBy(leds, NUM_LEDS, 60);
if (millis() - lastStep > 80) {
leds[pos % NUM_LEDS] = CRGB::White;
leds[(pos + 1) % NUM_LEDS] = CRGB(100, 100, 100);
pos++;
lastStep = millis();
}
}
static int pos = 0— remembered between calls. The “head” of the shooting star, counting up.fadeToBlackBy(leds, NUM_LEDS, 60)— dim every LED by 60/255 each frame. The bright head fades behind it automatically — the tail.if (millis() - lastStep > 80)— only move the star every 80 milliseconds. Without this, the star would zip too fast to see.leds[pos % NUM_LEDS] = CRGB::White— light the head bright white.%means remainder — after LED 9 it wraps back to LED 0, so the star goes in circles.leds[(pos + 1) % NUM_LEDS] = CRGB(100, 100, 100)— light the LED just behind the head in grey (dimmer white). Creates a small comet shape.pos++— advance the position by 1. Next time, the star has moved one step.lastStep = millis()— update the “last moved” time.
setup(): runs once on power-on
void setup() {
FastLED.addLeds<WS2812B, LED_PIN, GRB>(leds, NUM_LEDS);
FastLED.setBrightness(BRIGHTNESS);
FastLED.clear();
FastLED.show();
}
FastLED.addLeds<WS2812B, LED_PIN, GRB>(leds, NUM_LEDS)— “I have WS2812B LEDs on pin 2 (C6: pin 8), GRB colour order, 10 of them.”FastLED.setBrightness(60)— cap all colours at 60/255 brightness. Saves battery.FastLED.clear()— set all LEDs to black (off).FastLED.show()— send the colours to the real LEDs..clear()changes the plan;.show()prints it. Without this, the strip stays lit with whatever was in memory.
loop(): the heartbeat — runs forever
void loop() {
if (millis() - lastPatternSwitch > PATTERN_MS) {
currentPattern = (currentPattern + 1) % 3;
lastPatternSwitch = millis();
}
millis() is a stopwatch counting milliseconds since power-on. When more than 20,000ms (20 seconds) have passed, advance the pattern. (currentPattern + 1) % 3 means: add 1, and if the result reaches 3, wrap back to 0 — like a clock.
switch (currentPattern) {
case 0: patternGoldenBreathe(); break;
case 1: patternRainbowShimmer(); break;
case 2: patternShootingStar(); break;
}
FastLED.show();
delay(20);
}
switch is a menu — jump to whichever pattern matches. break exits the switch. FastLED.show() sends the frame to the LEDs. delay(20) waits 20ms — about 50 frames per second, smooth for a star.
The whole thing in one sentence
When the battery turns on, the star starts pattern 0 (setup). Then it loops forever: draw the current pattern, show it, wait 20ms, and every 20 seconds silently switch to the next one (loop).
First thing to try: change PATTERN_MS from 20000 to 4000 so each pattern shows for just 4 seconds. You can see all three patterns quickly and make sure all 10 LEDs work before mounting the star.
Connect the ESP32 to your computer via USB-C and upload. The star should begin golden breathing immediately.
Check: All 10 LEDs should breathe together in warm amber. If some are dark, check your solder joints in the chain — a bad connection breaks every LED after it.
Step 4: Mount and enjoy
Time: ~5 minutes
- Route the wire bundle from the star down through the hollow tree trunk.
- Connect the data wire (through the 330-ohm resistor) to ESP32 GPIO 2 (C6: GPIO 8).
- Place the ESP32 and LiPo/TP4056 in a small box at the tree base.
- Mount the star on the tree top. Press it down so it sits securely.
- Switch on the battery. The star starts glowing.
What just happened (what you learned)
-
beatsin8(BPM, low, high)generates a smooth sine wave oscillating betweenlowandhighat the given BPM. It uses the ESP32’s internal clock, so the breathing stays perfectly timed without anydelay()calls. -
CHSV (Hue, Saturation, Value) is an alternative to RGB. Hue picks the colour (0=red, 96=green, 160=blue, 30=amber), Saturation controls how vivid it is, Value controls brightness. Much easier to make beautiful natural colours than with raw RGB.
-
Chaining WS2812B LEDs means DOUT of one LED connects to DIN of the next — the data signal travels through every LED in sequence, each reading its colour and passing the rest onward.
-
LiPo + TP4056 is a complete rechargeable battery system — the TP4056 handles safe USB charging, the LiPo stores the energy. Three connections: battery in, power out to your circuit.
-
fadeToBlackBy()reduces every LED’s brightness by a fraction each frame — in the shooting star, this creates the tail automatically as the bright head moves forward while previous positions fade behind it.
Level Up
Map patterns to star tips: Define a starOrder[] array mapping the arm-traversal sequence to LED indices. Make the shooting star chase arm-tip to arm-tip rather than linearly down the chain number. Much more satisfying visually.
Independent twinkle per LED: Create a fourth pattern where each of the 10 LEDs independently fades in and out at a random rate. Use two arrays — uint8_t currentBright[10] and uint8_t targetBright[10] — and move each toward its target each frame.
Deep sleep for battery saving: Add a physical button to GPIO 0. Hold it to call esp_deep_sleep_start(). The ESP32 drops to near-zero power draw. Hold the button again to wake. Double your runtime.
Estimate your runtime: At BRIGHTNESS=60, 10 WS2812B LEDs draw roughly 10–15mA average during golden breathe. The ESP32 itself draws ~80mA. Total: ~100mA. On a 1000mAh LiPo: around 8–10 hours. A full Christmas Eve.
Troubleshooting
| Problem | Fix |
|---|---|
| Some LEDs are dark, others work fine | You have a bad solder joint in the chain. Every LED after the break goes dark. Check joints starting from the first dark LED. |
| All LEDs are dark | Check the 330-ohm resistor is on the DIN wire (not the VCC wire). Check the battery is charged. |
| Battery doesn’t charge | Make sure you’re plugging USB-C into the TP4056 module, not the ESP32. The TP4056 is the module with the red/blue LED. |
| Patterns run but star is dim | Increase BRIGHTNESS from 60 to 80. If you’re on mains power (not battery), you can go up to 150. |
| Breathing is too fast | Change beatsin8(8, ...) — reduce the first number. 8 BPM is already slow; try 5 for a very gentle pulse. |
| Wire broke inside the star arm | Prevention: use thin silicone wire (not rigid solid-core). Silicone wire flexes without snapping. |