ehsan.blog
~/blog/how-to-build-animated-skill-bars-with-css-keyframes — zsh
cat how-to-build-animated-skill-bars-with-css-keyframes.md

How to build animated skill bars with pure CSS keyframes

·4 min read

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:

css
/* 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 to width: 0 the instant the animation ends. forwards tells 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:

ts
// 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:

tsx
// 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’s to value. Note the cast to React.CSSProperties — TypeScript doesn’t know about custom -- properties, so you assert the type.
  • animationDelay: 0.1 + i * 0.1s staggers 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-fill once barsStarted is true — before that the element is just w-0. That’s how the animation is held until the section is actually on screen (a separate isActive gate).

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:

tsx
// 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 width from 0 to var(--target-w) in a keyframe, and set the variable per bar.
  • Use forwards fill-mode so the bar holds its final width instead of snapping back.
  • Pick a cubic-bezier easing so the fill decelerates and feels intentional.
  • Stagger animation-delay across mapped items for a cascading reveal.
  • To replay or gate a CSS animation, toggle animation-play-state rather than re-adding the class.
ls ./related
cat ./comments

Comments are not configured yet. Enable GitHub Discussions and paste the giscus repo-id / category-id into src/consts.ts.