How to Make CSS-Only Holiday Decorations

You can decorate a page for the season without images or SVGs. In this project you will build a CSS-only holiday scene: a stylized tree made from triangles, a glowing star topper, round ornaments, blinking string lights, gentle snowfall, and a couple of gift boxes. Everything renders from basic HTML elements, pseudo-elements, gradients, and transforms. By the end you will have a themed component you can drop into any landing page, newsletter hero, or seasonal demo.

Why CSS-Only Holiday Decorations Matter

Pure CSS decorations are easy to ship, quick to theme, and resolution independent. You avoid extra network requests for images or icon sprites, which keeps your page lean. CSS gives you instant color theming via custom properties, and precise control with pseudo-elements. You also gain motion with keyframes and media queries that respect user preferences. When you need a festive accent that loads fast and scales on any screen, CSS shapes and a few transforms handle the job.

Prerequisites

You only need a small toolkit to follow along. The code stays focused on layout primitives, transforms, and a handful of gradients.

  • Basic HTML
  • CSS custom properties
  • CSS pseudo-elements (::before / ::after)

Step 1: The HTML Structure

The markup groups everything inside a single .scene container. The tree is a block with three triangle layers, a star topper, a trunk, an ornaments wrapper, and a simple list of bulbs for the lights. Snow sits in a separate layer so it can animate independently. Two gift boxes round out the scene. All visual details come from CSS.

<!-- HTML -->
<div class="scene" aria-hidden="true">
  <div class="tree">
    <div class="star" aria-hidden="true"></div>

    <span class="layer layer--top" aria-hidden="true"></span>
    <span class="layer layer--mid" aria-hidden="true"></span>
    <span class="layer layer--bot" aria-hidden="true"></span>

    <div class="trunk" aria-hidden="true"></div>

    <div class="ornaments" aria-hidden="true">
      <span class="bauble bauble--1"></span>
      <span class="bauble bauble--2"></span>
      <span class="bauble bauble--3"></span>
      <span class="bauble bauble--4"></span>
    </div>

    <ul class="lights" aria-hidden="true">
      <li style="--i:0"></li>
      <li style="--i:1"></li>
      <li style="--i:2"></li>
      <li style="--i:3"></li>
      <li style="--i:4"></li>
      <li style="--i:5"></li>
      <li style="--i:6"></li>
      <li style="--i:7"></li>
      <li style="--i:8"></li>
      <li style="--i:9"></li>
    </ul>
  </div>

  <div class="snow" aria-hidden="true"></div>

  <div class="gift gift--left" role="img" aria-label="Gift box">
    <span class="ribbon"></span>
  </div>
  <div class="gift gift--right" role="img" aria-label="Gift box">
    <span class="ribbon"></span>
  </div>
</div>

Step 2: The Basic CSS & Styling

This baseline handles layout, theming, and the stage. Custom properties control brand colors and timing so you can reskin quickly. The scene centers with grid, and a subtle gradient sets a calm winter sky. Sizing uses rem and percentages to scale across containers.

/* CSS */
:root{
  --bg: #0b132b;
  --bg2: #1c2541;
  --snow: #ffffff;
  --tree: #1f7a4c;
  --tree-dark: #115c39;
  --star: #ffd166;
  --trunk: #7b4b2a;
  --gift-red: #e63946;
  --gift-green: #2a9d8f;
  --ribbon: #f1fa8c;
  --bauble-1: #f94144;
  --bauble-2: #f3722c;
  --bauble-3: #43aa8b;
  --bauble-4: #577590;
  --light-on: #ffd166;
  --light-off: #825e2f;
  --glow: 0 0 12px rgba(255, 209, 102, .8);
  --dur: 8s;
}

*,
*::before,
*::after{ box-sizing: border-box; }

html, body{
  height: 100%;
  margin: 0;
}

body{
  display: grid;
  place-items: center;
  background: radial-gradient(1200px 600px at 50% -10%, #233a69 0%, var(--bg) 60%), linear-gradient(var(--bg2), var(--bg));
  color: #eaeaea;
  font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
}

.scene{
  position: relative;
  width: min(540px, 92vw);
  aspect-ratio: 3 / 2;
  border-radius: 16px;
  background: linear-gradient(#0f1e43 0 45%, #14355b 45% 60%, #0d1b2a 60% 100%);
  overflow: hidden;
  box-shadow: 0 20px 40px rgba(0,0,0,.35), inset 0 0 0 1px rgba(255,255,255,.06);
}

/* Ground snow */
.scene::after{
  content: "";
  position: absolute;
  left: -10%;
  right: -10%;
  bottom: -10%;
  height: 40%;
  background: radial-gradient(60% 60% at 50% 10%, rgba(255,255,255,.35), rgba(255,255,255,0) 60%),
              linear-gradient(#d8eefe, #9cc6e9 60%, #7a9fc2);
  border-top-left-radius: 50% 25%;
  border-top-right-radius: 50% 25%;
  filter: blur(0.3px);
}

/* Tree wrapper */
.tree{
  position: absolute;
  left: 50%;
  bottom: 20%;
  transform: translateX(-50%);
  width: 44%;
  height: 62%;
}

Advanced Tip: Keep your palette in custom properties from the start. Swapping seasonal themes takes minutes when colors, durations, and glow levels live in variables. You can even expose them as inline styles for A/B testing different looks.

Step 3: Building the Tree and Star

The tree uses three stacked triangles built with the classic border trick. If you have not built a border triangle before, review how to create a triangle-up with CSS. The star uses a clip-path polygon for a crisp five-point topper. You can also study a dedicated approach for a 5-point star with CSS if you want alternative techniques.

/* CSS */
/* Tree layers as border triangles */
.layer{
  position: absolute;
  left: 50%;
  transform: translateX(-50%);
  width: 0;
  height: 0;
  border-left: 9rem solid transparent;
  border-right: 9rem solid transparent;
  border-bottom: 9rem solid var(--tree);
  filter: drop-shadow(0 6px 0 var(--tree-dark));
}

.layer--top{
  bottom: 52%;
  border-left-width: 6rem;
  border-right-width: 6rem;
  border-bottom-width: 6rem;
}

.layer--mid{
  bottom: 32%;
  border-left-width: 7.5rem;
  border-right-width: 7.5rem;
  border-bottom-width: 7.5rem;
}

.layer--bot{
  bottom: 10%;
  border-left-width: 9rem;
  border-right-width: 9rem;
  border-bottom-width: 9rem;
}

/* Simple trunk */
.trunk{
  position: absolute;
  left: 50%;
  bottom: 3%;
  transform: translateX(-50%);
  width: 3.2rem;
  height: 3.8rem;
  background: linear-gradient(0deg, #5f3b20, var(--trunk));
  border-radius: 0.4rem;
  box-shadow: 0 6px 0 #5a361a;
}

/* Star topper */
.star{
  position: absolute;
  left: 50%;
  bottom: 66%;
  transform: translate(-50%, 0) rotate(0deg);
  width: 3.2rem;
  height: 3.2rem;
  background: var(--star);
  clip-path: polygon(50% 0%, 61% 35%, 98% 35%, 68% 56%, 79% 91%, 50% 70%, 21% 91%, 32% 56%, 2% 35%, 39% 35%);
  filter: drop-shadow(0 0 0.6rem rgba(255, 209, 102, .9));
}

How This Works (Code Breakdown)

Each triangle layer has zero width and height. The triangle shape comes from the borders. Transparent left and right borders form the slanted sides, and the bottom border supplies the fill color. Adjusting the three border widths lets us scale each layer while keeping a consistent angle. The drop-shadow on each triangle adds depth between layers without extra markup.

The layers sit at absolute positions with percentage-based bottoms so they stack cleanly. Using transform translateX(-50%) on left: 50% centers the triangles, which keeps resizing predictable. You can tune the proportions by editing only the border widths. This mirrors the technique in the linked guide for the triangle-up with CSS, which remains one of the most reliable ways to draw a conifer silhouette.

The trunk is a small rounded rectangle with a subtle linear-gradient that suggests wood grain. A vertical box-shadow on the trunk reads as a short rim of snow under the lowest tier of the tree. The star uses clip-path to draw the 10-vertex polygon that forms a five-point star. The glow comes from a drop-shadow on the star element rather than extra pseudo-elements, which keeps the DOM lean.

Step 4: Ornaments, Lights, and Snow

Ornaments are circles with a highlight gradient. Lights are small bulbs repeated along an arc and animated to create a twinkle. Snow is a layer with pseudo-elements that render dozens of flakes via multiple box-shadows. For round ornaments, the classic border-radius trick builds perfect circles. If you need a refresher on the technique, see how to make a circle with CSS.

/* CSS */
/* Ornaments */
.ornaments{
  position: absolute;
  inset: 0;
}

.bauble{
  --size: 1.2rem;
  position: absolute;
  width: var(--size);
  height: var(--size);
  border-radius: 50%;
  background:
    radial-gradient(circle at 35% 35%, rgba(255,255,255,.85), rgba(255,255,255,0) 45%),
    radial-gradient(circle at 70% 70%, rgba(0,0,0,.15), rgba(0,0,0,0) 55%);
  box-shadow: 0 0 0 2px rgba(255,255,255,.25) inset, 0 2px 0 rgba(0,0,0,.15);
}
.bauble--1{ left: 38%; bottom: 48%; background-color: var(--bauble-1); }
.bauble--2{ left: 56%; bottom: 42%; background-color: var(--bauble-2); }
.bauble--3{ left: 44%; bottom: 28%; background-color: var(--bauble-3); }
.bauble--4{ left: 62%; bottom: 20%; background-color: var(--bauble-4); }

/* Lights string */
.lights{
  position: absolute;
  list-style: none;
  padding: 0;
  margin: 0;
  left: 50%;
  bottom: 36%;
  transform: translateX(-50%);
  width: 64%;
  height: 20%;
}

.lights li{
  --n: 10; /* number of bulbs */
  position: absolute;
  width: .66rem;
  height: .66rem;
  border-radius: 50%;
  background: var(--light-off);
  left: calc((var(--i) / (var(--n) - 1)) * 100%);
  top: calc(12% + (sin(var(--i) * 18deg) * 14%));
  box-shadow: 0 2px 0 rgba(0,0,0,.25);
}

/* Small top arc using a pseudo wire */
.lights::before{
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  top: 12%;
  height: 2px;
  background: repeating-linear-gradient(90deg, #203f38 0 12px, #2a5b52 12px 24px);
  border-radius: 50%;
  transform: translateY(-50%);
}

/* Snow layer: many dots via box-shadows */
.snow{
  position: absolute;
  inset: 0;
  pointer-events: none;
}
.snow::before,
.snow::after{
  content: "";
  position: absolute;
  inset: -10% 0 auto 0;
  height: 200%;
  background: radial-gradient(2px 2px at 20% 10%, var(--snow) 99%, transparent),
              radial-gradient(1.5px 1.5px at 80% 20%, var(--snow) 99%, transparent),
              radial-gradient(1.7px 1.7px at 50% 5%, var(--snow) 99%, transparent),
              radial-gradient(2.2px 2.2px at 30% 30%, var(--snow) 99%, transparent),
              radial-gradient(2px 2px at 70% 40%, var(--snow) 99%, transparent);
  opacity: .9;
  animation: snow var(--dur) linear infinite;
}
.snow::after{
  animation-duration: calc(var(--dur) * .7);
  opacity: .6;
  filter: blur(.6px);
}

/* Gifts */
.gift{
  position: absolute;
  bottom: 10%;
  width: 80px;
  height: 56px;
  border-radius: 6px;
  box-shadow: 0 8px 0 rgba(0,0,0,.2);
}
.gift--left{
  left: 20%;
  background: linear-gradient(90deg, var(--gift-red) 0 44%, var(--ribbon) 44% 56%, var(--gift-red) 56% 100%);
}
.gift--right{
  right: 18%;
  background: linear-gradient(90deg, var(--gift-green) 0 44%, var(--ribbon) 44% 56%, var(--gift-green) 56% 100%);
}
.gift .ribbon{
  position: absolute;
  left: 50%;
  top: -16px;
  transform: translateX(-50%);
  width: 32px;
  height: 16px;
  background: var(--ribbon);
  border-radius: 16px 16px 0 0;
  box-shadow: inset 0 -3px 0 rgba(0,0,0,.15);
}

/* Animation primitives */
@keyframes snow{
  from{ transform: translateY(-10%); }
  to{ transform: translateY(0%); }
}

How This Works (Code Breakdown)

Each ornament is a small, absolutely positioned circle. A pair of radial-gradients add a highlight and a soft shadow that gives the bauble a glossy look. The positions place ornaments across the triangle tiers, but you can add more spans and tweak the percentages to cover the tree with a balanced layout.

The lights are just list items. Each item uses a custom property --i to calculate both horizontal placement and a small vertical offset along a sine arc. That math produces a cable-like curve without drawing SVG paths. A repeating-linear-gradient on the ::before pseudo-element fakes the wire. You can adjust the width of the .lights container and the number of bulbs to match the size of your tree.

For snow, two pseudo-elements stack multiple radial gradients with varied sizes and positions. Animating a vertical translate on both gives an endless fall. The second layer runs faster and blurred to create parallax. Using backgrounds and transforms keeps the browser on the compositor path for smooth motion.

Advanced Techniques: Adding Animations & Hover Effects

Lights feel more alive with a gentle twinkle and the star can pulse. Add a keyframe that alternates the bulb color and a lightweight flicker for the star. Respect user settings by turning motion off for those who prefer less activity.

/* CSS */
/* Twinkle animation for bulbs */
@keyframes twinkle{
  0%, 100%{ background: var(--light-off); filter: none; }
  50%{ background: var(--light-on); box-shadow: var(--glow); filter: saturate(1.2); }
}

/* Stagger each bulb with its index to avoid synchronized blinking */
.lights li{
  animation: twinkle 2.4s calc(var(--i) * .18s) infinite ease-in-out;
}

/* Star pulse */
@keyframes star-pulse{
  0%, 100%{ transform: translate(-50%, 0) scale(1); filter: drop-shadow(0 0 .6rem rgba(255, 209, 102, .9)); }
  50%{ transform: translate(-50%, 0) scale(1.08); filter: drop-shadow(0 0 1.2rem rgba(255, 209, 102, 1)); }
}
.star{ animation: star-pulse 3.2s ease-in-out infinite; }

/* Holiday hover: brighten gifts */
.gift:hover{ filter: brightness(1.1) saturate(1.05); transform: translateY(-2px); transition: transform .2s ease, filter .2s ease; }

/* Motion preference */
@media (prefers-reduced-motion: reduce){
  .star, .lights li, .snow::before, .snow::after{
    animation: none !important;
    transition: none !important;
  }
}

Accessibility & Performance

Decorations like this are visual flourishes, not primary content. Treat them as presentational so screen readers do not need to describe them. If a decoration communicates meaning, convert it to a real control or a labeled figure. For pure ambience, hide it from assistive tech and keep motion respectful.

Accessibility

Wrap the scene with aria-hidden="true" so it does not clutter the reading order. If you reuse pieces in a context where they summarize a status (for example, a star that indicates a favorite item), switch to a semantic element with an aria-label that states the meaning. The motion layer respects prefers-reduced-motion by removing animations. That preference should always gate decorative motion. For color, keep adequate contrast for any interactive accents. The gifts in this example brighten on hover, but they do not serve as controls. If you turn a gift into a button, ensure a focus outline and a visible focus state.

Performance

This scene ships no images. Every shape is a CSS primitive, so it renders quickly and scales cleanly. Avoid giant box-shadow lists or heavy blur filters on large layers. The snow layer uses a few radial-gradients, which are cheap compared to hundreds of DOM nodes. Animations target transform and opacity, which tier well on most browsers. Use custom properties for colors and timing so you can tweak themes without editing dozens of rules. If you need several trees on a page, wrap them in a container and reuse the same CSS class names to take advantage of style sharing in the cascade.

Bring Seasonal Cheer With a Few Lines of CSS

You built a CSS-only holiday scene: a layered triangle tree, a glowing star, round ornaments, blinking bulbs, snowfall, and gift boxes. Along the way you used variables, pseudo-elements, gradients, and keyframes to create depth and motion without assets.

This toolkit grows with simple additions. Try a wreath using rings and gaps, candy canes with angled stripes, or a moonlit sky using a crescent from the shapes library. With the triangle, circle, and star patterns in hand, and references like the circle with CSS and the triangle-up with CSS and 5-point star with CSS, you now have the building blocks to craft your own festive sets for any holiday.

Leave a Comment