Part 2 of 4 · CSS Shine Lab
Keyframe animations
Define a motion's waypoints once in an @keyframes rule and let it run on its own. Looping, multi-step motion a transition can't do.
- Innovation
Reach for a keyframe animation when the motion should run on its own, with no trigger: a loading spinner, a typing indicator, a shimmer over a placeholder. You define the waypoints once and the same rule can drive as many elements as you like. Like transitions, keyframe animations have shipped in every major browser for well over a decade.
Implementation
A transition animates between two states and needs a trigger, whether that is
a :hover, a toggled class, or an element appearing. A keyframe animation is
self-contained: you set the waypoints in an @keyframes rule, attach it with
the animation shorthand, and it runs. That unlocks things a transition cannot
do:
- More than two steps, with as many percentage stops as you need.
- Looping, via
animation-iteration-count: infiniteor any number. - Reversing and ping-pong, via
animation-direction: reverseoralternate. - Plus
animation-delay,animation-fill-modeto hold the first or last frame, andanimation-play-stateto pause.
The four below all run on their own and loop forever, and each drops the
animation under prefers-reduced-motion.
A spinner
infinite keeps it running with no interaction at all, the bread-and-butter
loading spinner.
View source
HTML
<div class="kf-spinner" role="status" aria-label="Loading"></div> CSS
.kf-spinner {
width: 3rem;
height: 3rem;
border-radius: 50%;
border: 4px solid var(--color-border-strong);
border-top-color: var(--color-accent);
/* Runs on its own, forever — no state change or trigger needed, unlike a
transition. `infinite` is the looping feature. */
animation: kf-spin 0.8s linear infinite;
}
@keyframes kf-spin {
to {
rotate: 360deg;
}
}
@media (prefers-reduced-motion: reduce) {
.kf-spinner {
animation: none;
}
} A skeleton placeholder
A content placeholder while data loads. The animation sweeps a lighter band across each bar by moving an oversized gradient, the familiar shimmer.
View source
HTML
<div class="kf-skeleton" aria-hidden="true">
<div class="kf-skeleton__line"></div>
<div class="kf-skeleton__line"></div>
<div class="kf-skeleton__line kf-skeleton__line--short"></div>
</div> CSS
.kf-skeleton {
display: grid;
gap: 0.6rem;
width: 100%;
max-width: 22rem;
}
.kf-skeleton__line {
height: 0.85rem;
border-radius: 0.4rem;
/* A darker base with a lighter band that the animation sweeps across by
moving the (oversized) background — the classic "shimmer" placeholder. */
background: linear-gradient(
90deg,
var(--color-surface-elevated) 25%,
var(--color-border-strong) 50%,
var(--color-surface-elevated) 75%
);
background-size: 200% 100%;
animation: kf-shimmer 1.3s linear infinite;
}
.kf-skeleton__line--short {
width: 60%;
}
@keyframes kf-shimmer {
from {
background-position: 200% 0;
}
to {
background-position: -200% 0;
}
}
@media (prefers-reduced-motion: reduce) {
.kf-skeleton__line {
animation: none;
}
} A typing indicator
The “is typing” bubble from chat apps. One bounce runs on all three dots, offset
with animation-delay so it ripples across them.
View source
HTML
<div class="kf-typing" role="status" aria-label="Someone is typing">
<span class="kf-typing__dot"></span>
<span class="kf-typing__dot"></span>
<span class="kf-typing__dot"></span>
</div> CSS
.kf-typing {
display: inline-flex;
gap: 0.4rem;
padding: 0.75rem 1rem;
border-radius: var(--radius-full);
background: var(--color-surface-elevated);
}
.kf-typing__dot {
width: 0.6rem;
height: 0.6rem;
border-radius: 50%;
background: var(--color-text-secondary);
animation: kf-typing 1s ease-in-out infinite;
}
/* Stagger the dots with animation-delay so the bounce ripples across them. */
.kf-typing__dot:nth-child(2) {
animation-delay: 0.15s;
}
.kf-typing__dot:nth-child(3) {
animation-delay: 0.3s;
}
@keyframes kf-typing {
0%,
60%,
100% {
translate: 0 0;
opacity: 0.4;
}
30% {
translate: 0 -0.4rem;
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.kf-typing__dot {
animation: none;
}
} An indeterminate progress bar
For when you cannot report a real percentage: a short segment slides across and off, forever, to signal that something is working.
View source
HTML
<div class="kf-progress" role="progressbar" aria-label="Loading">
<div class="kf-progress__bar"></div>
</div> CSS
.kf-progress {
position: relative;
overflow: hidden;
width: 100%;
max-width: 22rem;
height: 0.5rem;
border-radius: var(--radius-full);
background: var(--color-surface-elevated);
}
/* A short segment that slides across and off, over and over — an indeterminate
progress bar for when you can't report a real percentage. */
.kf-progress__bar {
position: absolute;
top: 0;
left: 0;
width: 40%;
height: 100%;
border-radius: inherit;
background: var(--color-accent);
animation: kf-progress 1.4s ease-in-out infinite;
}
@keyframes kf-progress {
from {
left: -40%;
}
to {
left: 100%;
}
}
@media (prefers-reduced-motion: reduce) {
.kf-progress__bar {
animation: none;
}
}