A marker hops along a timeline from 2020 to 2026, pausing on each tick while the year in its bubble changes. One registered number drives both the marker and the filled line.
Also known as: year counter animation, timeline year animation, animated year number.
<div class="txt-year-counter" aria-label="2020 to 2026">
<div class="txt-year-counter-track" aria-hidden="true">
<i></i><i></i><i></i><i></i><i></i><i></i><i></i>
<span class="txt-year-counter-dot"><b></b></span>
</div>
</div>@property --txt-year-counter-y {
syntax: "<integer>";
initial-value: 2020;
inherits: false;
}
@property --txt-year-counter-p {
syntax: "<number>";
initial-value: 0;
inherits: true;
}
.txt-year-counter {
--c: var(--txt-color, #3b82f6);
font-size: var(--txt-size, 32px);
font-family: system-ui, sans-serif;
font-weight: 700;
line-height: 1.2;
width: 10em;
padding-top: 1.4em;
--w: 10em;
--hop: 0.5s;}
.txt-year-counter-track {
position: relative;
display: flex;
justify-content: space-between;
align-items: center;
height: 0.5em;
background: linear-gradient(color-mix(in srgb, var(--c) 22%, transparent), color-mix(in srgb, var(--c) 22%, transparent)) center / 100% 0.1em no-repeat;
/* 6 hops of --hop each: move for half a hop, rest for half */
animation: txt-year-counter-progress calc(var(--hop) * 5.5) cubic-bezier(0.65, 0, 0.35, 1) forwards;
}
.txt-year-counter-track::before {
content: "";
position: absolute;
left: 0;
top: calc(50% - 0.05em);
width: 100%;
height: 0.1em;
background: var(--c);
transform-origin: left;
transform: scaleX(var(--txt-year-counter-p));
}
.txt-year-counter-track i {
width: 0.22em;
height: 0.22em;
border-radius: 50%;
background: color-mix(in srgb, var(--c) 35%, #9ca3af);
}
.txt-year-counter-dot {
position: absolute;
left: 0;
top: 50%;
width: 0.5em;
height: 0.5em;
border-radius: 50%;
background: var(--c);
box-shadow: 0 0 0 0.12em color-mix(in srgb, var(--c) 25%, transparent);
transform: translate(calc(var(--txt-year-counter-p) * var(--w) - 50%), -50%);
}
.txt-year-counter-dot b {
position: absolute;
bottom: 0.8em;
left: 50%;
translate: -50% 0;
padding: 0.1em 0.35em;
border-radius: 0.3em;
font-size: 0.9em;
color: #fff;
background: var(--c);
font-variant-numeric: tabular-nums;
counter-reset: txt-year-counter-y var(--txt-year-counter-y);
animation:
txt-year-counter-year calc(var(--hop) * 6) steps(6) calc(var(--hop) * -0.5) forwards,
txt-year-counter-pop var(--hop) cubic-bezier(0.34, 1.56, 0.64, 1) calc(var(--hop) * 0.5) 6;
}
.txt-year-counter-dot b::after {
content: counter(txt-year-counter-y);
}
@keyframes txt-year-counter-progress {
0% { --txt-year-counter-p: 0; }
9.09%, 18.18% { --txt-year-counter-p: 0.1667; }
27.27%, 36.36% { --txt-year-counter-p: 0.3333; }
45.45%, 54.55% { --txt-year-counter-p: 0.5; }
63.64%, 72.73% { --txt-year-counter-p: 0.6667; }
81.82%, 90.91% { --txt-year-counter-p: 0.8333; }
100% { --txt-year-counter-p: 1; }
}
@keyframes txt-year-counter-year {
from { --txt-year-counter-y: 2020; }
to { --txt-year-counter-y: 2026; }
}
@keyframes txt-year-counter-pop {
0% { transform: scale(1.18); }
40% { transform: scale(0.97); }
to { transform: scale(1); }
}
@media (prefers-reduced-motion: reduce) {
.txt-year-counter, .txt-year-counter *, .txt-year-counter::before, .txt-year-counter::after, .txt-year-counter *::before, .txt-year-counter *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}--txt-color on the element or a parent.
Currently previewing #3b82f6.--txt-size. Every other dimension is in
em, so the whole thing scales together. Currently previewing 40px.transition or animation
shorthand. Staggered delays are relative, so they keep their rhythm.