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, theball__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 thestroke-dashoffset
withoffset-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-offset
s 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:
- The star keeps the
opacity
of1
for the start of the animation - Meanwhile, the anchor of the star is set to the
--tree
and the offset is placed to100%
, creating the animation along the path. - Between that
1%
the star fades-out - From
0%
to25%
, thestroke-dashoffset
of the tree gets animated from the start value to0
. - 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 correctoffset-path
. Thestroke-dashoffset
of the ball is set to the initial value of148.443
, ready to be animated to0
as well. - The star fades in and stays like that in for some time
50%
, we repeat this story to go over to--ball-2
, with a reset of the anchor setting at50.1%
,- 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.
Related articles
Related by me
- Let’s hang! An intro to CSS Anchor Positioning with basic examples
- Animating clip paths on scroll with @property in CSS
- Taking a closer look at @property in CSS
- Scroll driven animations in CSS are a joy to play around with!