Web InnoventixFreeCode

Skill Bars Progress

Original · free

animated skill/percent bars

byWeb InnoventixReact + Tailwind
progskillbarsprogress
Open

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.json
prog-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 quote

Similar components

Browse all →