Skill Bars Progress
Original · freeanimated skill/percent bars
byWeb InnoventixReact + Tailwind
progskillbarsprogress
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/prog-skill-bars.jsonprog-skill-bars.tsx
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { KeyboardEvent } from "react";
import { animate, motion, useInView, useReducedMotion } from "motion/react";
type Category = "Frontend" | "Backend" | "Design" | "Platform";
type Filter = Category | "All";
interface Skill {
name: string;
detail: string;
level: number;
category: Category;
}
interface CategoryStyle {
fill: string;
chip: string;
dot: string;
}
interface AnimatedPercentProps {
value: number;
active: boolean;
reduce: boolean;
delay: number;
}
interface BarRowProps {
skill: Skill;
index: number;
active: boolean;
reduce: boolean;
}
const SKILLS: Skill[] = [
{ name: "React & TypeScript", detail: "Component architecture, hooks, strict typing", level: 94, category: "Frontend" },
{ name: "CSS & Tailwind", detail: "Design tokens, responsive systems, motion", level: 88, category: "Frontend" },
{ name: "Next.js App Router", detail: "Server components, streaming, edge caching", level: 81, category: "Frontend" },
{ name: "Node.js & Fastify", detail: "REST + typed RPC, background jobs", level: 79, category: "Backend" },
{ name: "PostgreSQL & Prisma", detail: "Schema modelling, query tuning, migrations", level: 72, category: "Backend" },
{ name: "GraphQL Federation", detail: "Schema stitching, resolver batching", level: 63, category: "Backend" },
{ name: "Figma & Prototyping", detail: "Auto-layout, variants, interactive flows", level: 84, category: "Design" },
{ name: "Design Systems", detail: "Token pipelines, accessibility audits", level: 77, category: "Design" },
{ name: "Docker & CI/CD", detail: "Multi-stage builds, GitHub Actions", level: 70, category: "Platform" },
{ name: "AWS & Terraform", detail: "IaC modules, VPC networking, IAM", level: 58, category: "Platform" },
];
const FILTERS: Filter[] = ["All", "Frontend", "Backend", "Design", "Platform"];
const CATEGORY_STYLES: Record<Category, CategoryStyle> = {
Frontend: {
fill: "from-indigo-500 via-indigo-500 to-violet-500",
chip: "bg-indigo-50 text-indigo-700 ring-indigo-600/20 dark:bg-indigo-500/10 dark:text-indigo-300 dark:ring-indigo-400/25",
dot: "bg-indigo-500",
},
Backend: {
fill: "from-sky-500 via-sky-500 to-indigo-500",
chip: "bg-sky-50 text-sky-700 ring-sky-600/20 dark:bg-sky-500/10 dark:text-sky-300 dark:ring-sky-400/25",
dot: "bg-sky-500",
},
Design: {
fill: "from-emerald-500 via-emerald-500 to-sky-500",
chip: "bg-emerald-50 text-emerald-700 ring-emerald-600/20 dark:bg-emerald-500/10 dark:text-emerald-300 dark:ring-emerald-400/25",
dot: "bg-emerald-500",
},
Platform: {
fill: "from-amber-500 via-amber-500 to-rose-500",
chip: "bg-amber-50 text-amber-700 ring-amber-600/20 dark:bg-amber-500/10 dark:text-amber-300 dark:ring-amber-400/25",
dot: "bg-amber-500",
},
};
const EASE: [number, number, number, number] = [0.16, 1, 0.3, 1];
function tierLabel(level: number): string {
if (level >= 90) return "Expert";
if (level >= 78) return "Advanced";
if (level >= 62) return "Proficient";
return "Working";
}
function AnimatedPercent({ value, active, reduce, delay }: AnimatedPercentProps) {
const [display, setDisplay] = useState<number>(reduce ? value : 0);
useEffect(() => {
if (reduce) {
setDisplay(value);
return;
}
if (!active) {
setDisplay(0);
return;
}
const controls = animate(0, value, {
duration: 1.2,
delay,
ease: EASE,
onUpdate: (latest: number) => setDisplay(Math.round(latest)),
});
return () => controls.stop();
}, [active, value, reduce, delay]);
return <>{display}</>;
}
function BarRow({ skill, index, active, reduce }: BarRowProps) {
const style = CATEGORY_STYLES[skill.category];
const delay = index * 0.08;
const target = `${skill.level}%`;
return (
<li className="group relative">
<div className="mb-2 flex items-baseline justify-between gap-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate text-sm font-semibold tracking-tight text-slate-900 dark:text-slate-100">
{skill.name}
</span>
<span
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider ring-1 ring-inset ${style.chip}`}
>
<span className={`h-1.5 w-1.5 rounded-full ${style.dot}`} aria-hidden="true" />
{skill.category}
</span>
</div>
<p className="mt-0.5 truncate text-xs text-slate-500 dark:text-slate-400">{skill.detail}</p>
</div>
<div className="flex shrink-0 items-baseline gap-1 tabular-nums">
<span className="text-base font-bold text-slate-900 dark:text-slate-50">
<AnimatedPercent value={skill.level} active={active} reduce={reduce} delay={delay} />
</span>
<span className="text-xs font-medium text-slate-400 dark:text-slate-500">%</span>
</div>
</div>
<div
role="progressbar"
aria-label={`${skill.name} proficiency`}
aria-valuenow={skill.level}
aria-valuemin={0}
aria-valuemax={100}
aria-valuetext={`${skill.level}% — ${tierLabel(skill.level)}`}
className="relative h-2.5 w-full overflow-hidden rounded-full bg-slate-200/80 ring-1 ring-inset ring-slate-900/5 dark:bg-slate-800/80 dark:ring-white/5"
>
<motion.div
className={`relative h-full rounded-full bg-gradient-to-r ${style.fill}`}
initial={reduce ? false : { width: "0%" }}
animate={{ width: reduce ? target : active ? target : "0%" }}
transition={reduce ? { duration: 0 } : { duration: 1.2, delay, ease: EASE }}
>
<span className="pointer-events-none absolute inset-x-0 top-0 h-1/2 rounded-t-full bg-white/25" aria-hidden="true" />
<span className="psb-shimmer pointer-events-none absolute inset-y-0 -left-1/3 w-1/3 bg-gradient-to-r from-transparent via-white/40 to-transparent" aria-hidden="true" />
</motion.div>
</div>
<div className="mt-1 text-right text-[10px] font-medium uppercase tracking-wider text-slate-400 dark:text-slate-500">
{tierLabel(skill.level)}
</div>
</li>
);
}
export default function ProgSkillBars() {
const prefersReduced = useReducedMotion();
const reduce = prefersReduced ?? false;
const [filter, setFilter] = useState<Filter>("All");
const [sortByLevel, setSortByLevel] = useState<boolean>(false);
const [runId, setRunId] = useState<number>(0);
const optionRefs = useRef<Array<HTMLButtonElement | null>>([]);
const listRef = useRef<HTMLDivElement | null>(null);
const inView = useInView(listRef, { once: true, amount: 0.25 });
const active = reduce ? true : inView;
const visible = useMemo(() => {
const rows = SKILLS.filter((s) => filter === "All" || s.category === filter);
return sortByLevel ? [...rows].sort((a, b) => b.level - a.level) : rows;
}, [filter, sortByLevel]);
const average = useMemo(() => {
if (visible.length === 0) return 0;
return Math.round(visible.reduce((sum, s) => sum + s.level, 0) / visible.length);
}, [visible]);
const onFilterKeyDown = useCallback(
(event: KeyboardEvent<HTMLButtonElement>, index: number) => {
const last = FILTERS.length - 1;
let next = index;
if (event.key === "ArrowRight" || event.key === "ArrowDown") next = index === last ? 0 : index + 1;
else if (event.key === "ArrowLeft" || event.key === "ArrowUp") next = index === 0 ? last : index - 1;
else if (event.key === "Home") next = 0;
else if (event.key === "End") next = last;
else return;
event.preventDefault();
setFilter(FILTERS[next]);
optionRefs.current[next]?.focus();
},
[],
);
const focusRing =
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-950";
return (
<section className="relative w-full overflow-hidden bg-gradient-to-b from-slate-50 to-white px-4 py-20 text-slate-900 sm:px-6 lg:py-28 dark:from-slate-950 dark:to-slate-900 dark:text-slate-50">
<style>{`
@keyframes psb-shimmer {
0% { transform: translateX(0); opacity: 0; }
18% { opacity: 1; }
100% { transform: translateX(600%); opacity: 0; }
}
@keyframes psb-fade-up {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
.psb-shimmer { animation: psb-shimmer 2.6s cubic-bezier(0.16,1,0.3,1) infinite; }
.psb-fade-up { animation: psb-fade-up 0.7s cubic-bezier(0.16,1,0.3,1) both; }
@media (prefers-reduced-motion: reduce) {
.psb-shimmer, .psb-fade-up { animation: none !important; }
}
`}</style>
<div
aria-hidden="true"
className="pointer-events-none absolute -top-24 left-1/2 h-72 w-[42rem] max-w-full -translate-x-1/2 rounded-full bg-indigo-400/20 blur-3xl dark:bg-indigo-600/15"
/>
<div className="relative mx-auto max-w-3xl">
<div className="psb-fade-up overflow-hidden rounded-3xl bg-white/80 shadow-xl shadow-slate-900/5 ring-1 ring-slate-200 backdrop-blur-sm dark:bg-slate-900/60 dark:shadow-black/30 dark:ring-slate-800">
<header className="border-b border-slate-200 px-6 py-7 sm:px-8 dark:border-slate-800">
<div className="flex items-start gap-3">
<span className="mt-0.5 inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-indigo-500 to-violet-500 text-white shadow-lg shadow-indigo-500/30">
<svg viewBox="0 0 24 24" fill="none" className="h-5 w-5" aria-hidden="true">
<path d="M4 20V10M10 20V4M16 20v-6M22 20H2" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</span>
<div>
<h2 className="text-xl font-bold tracking-tight text-slate-900 sm:text-2xl dark:text-slate-50">
Proficiency breakdown
</h2>
<p className="mt-1 text-sm text-slate-600 dark:text-slate-400">
Self-assessed competency across the stack, benchmarked against senior-level expectations.
</p>
</div>
</div>
<div className="mt-6 flex flex-wrap items-center gap-3">
<div
role="radiogroup"
aria-label="Filter skills by discipline"
className="inline-flex flex-wrap gap-1 rounded-xl bg-slate-100 p-1 ring-1 ring-inset ring-slate-200 dark:bg-slate-800/70 dark:ring-slate-700"
>
{FILTERS.map((option, index) => {
const selected = filter === option;
return (
<button
key={option}
ref={(el) => {
optionRefs.current[index] = el;
}}
type="button"
role="radio"
aria-checked={selected}
tabIndex={selected ? 0 : -1}
onClick={() => setFilter(option)}
onKeyDown={(event) => onFilterKeyDown(event, index)}
className={`rounded-lg px-3 py-1.5 text-xs font-semibold transition-colors ${focusRing} ${
selected
? "bg-white text-slate-900 shadow-sm ring-1 ring-slate-200 dark:bg-slate-700 dark:text-white dark:ring-slate-600"
: "text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-100"
}`}
>
{option}
</button>
);
})}
</div>
<div className="flex items-center gap-2">
<button
type="button"
aria-pressed={sortByLevel}
onClick={() => setSortByLevel((v) => !v)}
className={`inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-semibold transition-colors ${focusRing} ${
sortByLevel
? "bg-indigo-500 text-white ring-1 ring-inset ring-indigo-400 hover:bg-indigo-600"
: "bg-slate-100 text-slate-600 ring-1 ring-inset ring-slate-200 hover:text-slate-900 dark:bg-slate-800/70 dark:text-slate-300 dark:ring-slate-700 dark:hover:text-white"
}`}
>
<svg viewBox="0 0 24 24" fill="none" className="h-3.5 w-3.5" aria-hidden="true">
<path d="M7 4v16M7 20l-3-3M7 4l3 3M14 7h7M14 12h5M14 17h3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
Sort by level
</button>
<button
type="button"
onClick={() => setRunId((v) => v + 1)}
className={`inline-flex items-center gap-1.5 rounded-lg bg-slate-100 px-3 py-1.5 text-xs font-semibold text-slate-600 ring-1 ring-inset ring-slate-200 transition-colors hover:text-slate-900 dark:bg-slate-800/70 dark:text-slate-300 dark:ring-slate-700 dark:hover:text-white ${focusRing}`}
>
<svg viewBox="0 0 24 24" fill="none" className="h-3.5 w-3.5" aria-hidden="true">
<path d="M20 11A8 8 0 1 0 18 16.5M20 5v6h-6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
Replay
</button>
</div>
</div>
</header>
<div ref={listRef} className="px-6 py-6 sm:px-8">
<div className="mb-6 flex items-center justify-between gap-4 rounded-2xl bg-slate-50 px-4 py-3 ring-1 ring-inset ring-slate-200 dark:bg-slate-800/40 dark:ring-slate-700/60">
<div>
<div className="text-[10px] font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">
Average proficiency
</div>
<div className="text-lg font-bold tabular-nums text-slate-900 dark:text-slate-50">
<AnimatedPercent value={average} active={active} reduce={reduce} delay={0.05} key={`${filter}-${average}-${runId}`} />%
</div>
</div>
<div className="text-right text-xs text-slate-500 dark:text-slate-400">
{visible.length} {visible.length === 1 ? "skill" : "skills"} shown
</div>
</div>
{visible.length > 0 ? (
<ul className="space-y-7">
{visible.map((skill, index) => (
<BarRow
key={`${skill.name}-${runId}`}
skill={skill}
index={index}
active={active}
reduce={reduce}
/>
))}
</ul>
) : (
<p className="py-8 text-center text-sm text-slate-500 dark:text-slate-400">
No skills in this discipline yet.
</p>
)}
</div>
</div>
<p className="mt-4 text-center text-xs text-slate-400 dark:text-slate-500">
Use arrow keys to move between disciplines. Bars fill as they scroll into view.
</p>
</div>
</section>
);
}Dependencies
motion
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
Bar Basic Progress
Originalbasic progress bars in colours

Bar Striped Progress
Originalanimated striped progress bar

Bar Gradient Progress
Originalgradient progress bar with label

Bar Steps Progress
Originalstepped progress bar

Ring Progress
Originalcircular progress ring with percent

Ring Gradient Progress
Originalgradient circular progress

Semicircle Progress
Originalsemicircle gauge progress

Multi Progress
Originalmulti-segment stacked progress

Indeterminate Progress
Originalindeterminate progress bar

Label Inside Progress
Originalprogress bar with inner label

Upload Progress
Originalfile upload progress rows

Spotlight Hero
OriginalA centred hero with a soft radial spotlight, badge and dual call-to-action.

