How to build animated skill bars with pure CSS keyframes
A skill bar that just appears at 90% is boring. A skill bar that sweeps up from zero to 90% reads as a little bit of proof — you watch it fill. I wanted that on my portfolio’s skills section, driven straight from my data array, with no JavaScript animation loop. Pure CSS handles it, and it’s only a few lines.
The keyframe
The animation itself is one keyframe that goes from zero width to a target width:
/* src/index.css */
@keyframes fillBar {
from {
width: 0;
}
to {
width: var(--target-w);
}
}
.skill-fill {
animation: fillBar 1.4s cubic-bezier(0.22, 1, 0.36, 1) forwards;
width: 0;
}Three details make this work:
var(--target-w)— the end width isn’t hardcoded. It reads a CSS variable, so every bar can animate to a different value using the same keyframe. I set that variable per bar from JavaScript (below).cubic-bezier(0.22, 1, 0.36, 1)— a custom easing curve. It starts fast and decelerates hard into the target, which feels snappy rather than linear. (This is the popular “easeOutQuint”-style curve.)forwards— the fill-mode. Without it, the bar would snap back towidth: 0the instant the animation ends.forwardstells the browser to hold the final keyframe, so the bar stays full.
The element also starts at width: 0 so it’s collapsed before the animation runs.
Driving the width from data
I keep proficiency levels in a plain array — the single source of truth for the numbers:
// src/data/portfolio.ts
export const skillLevels = [
{ name: "React / Next.js", level: 95 },
{ name: "TypeScript", level: 92 },
{ name: "Node.js / Express", level: 88 },
// ...
]Then the section maps over that array and feeds each bar its own --target-w and a staggered delay through inline styles:
// src/components/portfolio/SkillsSection.tsx
{skillLevels.map(({ name, level }, i) => (
<div key={name}>
<span>{name}</span>
<div className="h-1.5 bg-[oklch(0.12_0_0)] overflow-hidden">
<div
className={barsStarted ? "skill-fill" : "w-0"}
style={
barsStarted
? ({
"--target-w": `${level}%`,
animationDelay: `${0.1 + i * 0.1}s`,
} as React.CSSProperties)
: { width: "0%" }
}
/>
</div>
</div>
))}What’s happening:
- The outer track has
overflow-hidden, so the inner fill is clipped to the bar’s rounded box as it grows. "--target-w": "95%"becomes the keyframe’stovalue. Note the cast toReact.CSSProperties— TypeScript doesn’t know about custom--properties, so you assert the type.animationDelay: 0.1 + i * 0.1sstaggers the bars: bar 0 starts at 0.1s, bar 1 at 0.2s, and so on. That cascade of bars filling one after another is what makes the section feel alive.- The class only becomes
skill-filloncebarsStartedis true — before that the element is justw-0. That’s how the animation is held until the section is actually on screen (a separateisActivegate).
The gotcha with restarting the animation
CSS animations only run once per element mount. If you want a bar to re-animate later, you can’t just re-apply the class — the browser considers it already played. My hero section handles a replayable version by toggling animationPlayState between "paused" and "running" instead:
// src/components/portfolio/HeroSection.tsx
style={{
"--target-w": `${level}%`,
animationDelay: timeline >= 9 ? `${0.7 + i * 0.12}s` : "0s",
animationPlayState: timeline >= 9 ? "running" : "paused",
} as React.CSSProperties}The bar is mounted with the animation paused at frame zero, and only starts playing when the timeline reaches the right step. It’s a clean way to gate a CSS animation on state without remounting the node.
Because these bars build their final width from a keyframe, they need special handling for visitors who prefer reduced motion — otherwise the bar can get stuck empty at width: 0.
What to remember
- Animate
widthfrom0tovar(--target-w)in a keyframe, and set the variable per bar. - Use
forwardsfill-mode so the bar holds its final width instead of snapping back. - Pick a
cubic-beziereasing so the fill decelerates and feels intentional. - Stagger
animation-delayacross mapped items for a cascading reveal. - To replay or gate a CSS animation, toggle
animation-play-staterather than re-adding the class.