A christmas tree with text underneath it: stylish holidays

Every year, I create a little demo right before the holidays as a CSS greeting card. This year is no different. To celebrate the end of the year and all those new amazing features that came into CSS, I decided to let myself go a little with new CSS techniques. A sort of recap of things I learned and put them in one little CodePen. This time, I’m combining scroll-driven animations, anchoring, and @property in CSS.

SVG, scroll-driven animations, anchoring, @property, offset-path, and container style queries combined

This year, I went for a Christmas tree with a whole bunch of goodies in it. That does mean that I had to drop a bit of browser support for it. This year, my Chrismas greetings are Chromium only… sorry 🙁

The idea behind this article is more about my thoughts and technique behind it, but for how each and every one of those CSS features works, I’ll drop a bunch of links at the end of this article.

But first a video of the finished product, or you can click here to go to the CodePen at the bottom right away.

The setup: A bunch of SVG work.

The first thing I had to do was to create my assets. I had 2 single path drawings. One of the tree and one for the baubles. The inside of the baubles were created later on, but the important thing was that they were the same shape and size, to keep my coding consistent.

I knew I was going to animate the strokes of these SVGs with stroke-dasharray and stroke-dashoffset, so I needed to get those values. I used Illustrator to create my assets, and if you want to know the correct length for these values you can go to window > document info. Inside that panel, you can click the hamburger menu icon and you will find objects. The first thing you will get to see is the path info that holds a length.

I had to set these values as variables and I knew I was going to do some animating with them inside of a single timeline, this is why I chose the @property syntax right away.

@property --o-tree {
  syntax: "<number>";
  inherits: true;
  initial-value: 1031.69;
}

@property --o-ball {
  syntax: "<number>";
  inherits: true;
  initial-value: 148.443;
}

Setting up HTML: The star that follows…

I’m not going to go through everything inside of the HTML, but this is the basic outline (explainer below):

<div class="tree-wrapper">
  <div class="svg-items">
    <svg class="ball ball__3">
      <path class="ball__path" d="..." style="stroke:var(--color-ball-3);" />
      <path class="ball__inner" d="..." />
    </svg>
    <svg class="ball ball__2">
      <path class="ball__path" d="..." style="stroke:var(--color-ball-2);" />
      <path class="ball__inner" d="..." />
    </svg>
    <svg class="ball ball__1">
      <path class="ball__path" d="..." style="stroke:var(--color-ball-1);" />
      <path class="ball__inner" d="..." />
    </svg>
    <svg class="tree">
      <path d="..." style="var(--tree-color)" />
    </svg>
  </div>
  <div class="star">
    <div class="spark"></div>
  </div>
</div>
  • The tree-wrapper, will be my single point of animation, passing through custom properties to all its children
  • We have 3 ball__path items that have the exact same stroke, those are the outlines, the ball__inner items are purely the decorations which will fade in at the end.
  • The tree has a single path containing the tree outline
  • Finally, we have our .star that will follow along the same path as the stroke-dashoffset with offset-distance. We will attach the star to the correct elements by using anchor positioning.

You might notice that I’ve used some lazy inline CSS with variables on my SVGs. I just set some colors for the strokes on my root:

:root {
  --tree-color: #1d5b2d;
  --color-ball-1: #5386e4;
  --color-ball-2: #e072a4;
  --color-ball-3: #0e7c7b;
}

I will break this demo down as much as possible without getting lost in details.

The tree and baubles

We have a bunch of offsets that we will need to animate, these are set with @property, we will set the initial value to 0. The starting points of these are already declared by --o-tree and --o-ball

@property --dash-offset-tree {
  syntax: "<number>";
  inherits: true;
  initial-value: 0;
}

@property --dash-offset-ball-1 {
  syntax: "<number>";
  inherits: true;
  initial-value: 0;
}

@property --dash-offset-ball-2 {
  syntax: "<number>";
  inherits: true;
  initial-value: 0;
}

@property --dash-offset-ball-3 {
  syntax: "<number>";
  inherits: true;
  initial-value: 0;
}

Presentation styles aside, the tree itself has the following CSS:

.tree {
  stroke-dasharray: var(--o-tree);
  stroke-dashoffset: var(--dash-offset-tree);
  anchor-name: --tree;
}

Here you see that we set the stroke-dasharray to the value we set in --o-tree, the stroke-dashoffset will start with the previously set base-value, but we will update that with the timeline to end at 0. We also set an anchor-name, this is where the .star will be aligned to. We actually do pretty much the same for all of the baubles, here is an example of the first one:

.ball__1 {
  anchor-name: --ball-1;
  .ball__path {
    stroke-dasharray: var(--o-ball);
    stroke-dashoffset: var(--dash-offset-ball-1);
  }
}

Just like before, this gets a unique anchor-name and the path containing the bauble outline will get the stroke-dashoffset animations.

The wrapper that passes everything

It’s time we talk about the .tree-wrapper. As this is our single point of passing through custom properties. Once again positioning and presentation styles aside, this is the code:

.tree-wrapper {
  --star-anchor: --tree;
  --dash-offset-tree: var(--o-tree);
  --dash-offset-ball-1: var(--o-ball);
  --dash-offset-ball-2: var(--o-ball);
  --dash-offset-ball-3: var(--o-ball);
  --scroll-me-opacity: 1;

  animation: story auto linear;
  animation-timeline: --page-scroller;
}

We will set the position of the star to the --tree anchor as this is the starting point. The dash-offsets are set equally to their maximum offset size, we will update those while scrolling to 0.

Finally, we call our story animation and set the animation-timeline to --page-scroller. This can be found in my root.

html {
  scroll-timeline: --page-scroller block;
}

I know this isn’t necessary as it is the root scroller I’m using, but I like a named timeline.

The star

The custom properties for the star are the following:

@property --star-offset {
  syntax: "<percentage>";
  inherits: true;
  initial-value: 0%;
}

This will control the star offset, aka, the animation path it will follow.

@property --star-opacity {
  syntax: "<number>";
  inherits: true;
  initial-value: 1;
}

This will update the opacity from time to time, right between jumping between anchors

@property --star-transform {
  syntax: "<transform-function>+ | none";
  inherits: true;
  initial-value: none;
}

This is a property for a space-separated transform list. This is used at the end to enlarge the star.

The star itself:

.star {
  --ball-path: path(..);
  position: fixed;
  top: anchor(var(--star-anchor) top);
  left: anchor(var(--star-anchor) left);

  opacity: var(--star-opacity);
  offset-distance: var(--star-offset);
  transform: var(--star-transform);
  transform-origin: center;
  offset-path: path(...);
  @container style(--star-anchor: --ball-1) {
    offset-path: var(--ball-path);
  }
  @container style(--star-anchor: --ball-2) {
    offset-path: var(--ball-path);
  }
  @container style(--star-anchor: --ball-3) {
    offset-path: var(--ball-path);
  }
}

Woah, a lot is happening here, I know, bear with me. Firstly, I set a general --ball-path variable, which contains the path of the bauble outline. The actual default offset-path property on the star is the same as the tree path. Also, notice that we are setting --star-anchor, this contains --tree as a starting name, but we will update that custom property during the animation, switching it from --tree, to --ball-1, --ball-2, etc.

In those cases, the offset-path should be the shape of a bauble, which is why we use style queries to set the offset-path to that shape when the anchor changes.

The timeline

To create my scroll, I just set the body to a min-height of 500vh. The timeline itself might be a bit much…, but I enjoyed making this. You can view the full timeline in the CodePen below. But here is a short explanation of what happens in the timeline:

  1. The star keeps the opacity of 1 for the start of the animation
  2. Meanwhile, the anchor of the star is set to the --tree and the offset is placed to 100%, creating the animation along the path.
  3. Between that 1% the star fades-out
  4. From 0% to 25%, the stroke-dashoffset of the tree gets animated from the start value to 0.
  5. Very quickly (25.1%), the stars offset gets reset and the anchor is shifted to --ball-1, also triggering that style query we wrote before an setting the correct offset-path. The stroke-dashoffset of the ball is set to the initial value of 148.443, ready to be animated to 0 as well.
  6. The star fades in and stays like that in for some time
  7. 50%, we repeat this story to go over to --ball-2, with a reset of the anchor setting at 50.1%,
  8. And we continue this loop till the end
@keyframes story {
 24% {
   --star-opacity: 1;
 }
 25% {
   --star-offset: 100%;
   --star-anchor: --tree;
   --star-opacity: 0;
   --dash-offset-tree: 0;
 }
 25.1% {
   --star-offset: 0%;
   --star-anchor: --ball-1;
   --dash-offset-ball-1: var(--o-ball);
 }
 28%,
 49% {
   --star-opacity: 1;
 }
 50% {
   --star-offset: 100%;
   --star-anchor: --ball-1;
   --star-opacity: 0;
   --dash-offset-ball-1: 0;
 }
 /* ... */
}

Happy holidays!

Even though this animation won’t run off the main thread (because custom properties just don’t…), it was just so much fun creating this demo. I’ll probably write one more article this year for a recap of 2024. But I just wanted to share my CSS Christmas card for this year.

Whichever holiday you are celebrating this end of the year, I wish you all a beautiful holiday season and a lot of love/warmth with family and friends.

Shout out!

 in  css , html