Web InnoventixFreeCode

Profile Tabs

Original · free

profile page with tabbed content

byWeb InnoventixReact + Tailwind
profiletabsprofiles
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/profile-tabs.json
profile-tabs.tsx
"use client";

import { useCallback, useId, useMemo, useRef, useState } from "react";
import type { KeyboardEvent as ReactKeyboardEvent } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

type TabId = "overview" | "projects" | "activity" | "stack";

type Tab = {
  id: TabId;
  label: string;
  badge?: string;
};

type Project = {
  name: string;
  blurb: string;
  role: string;
  year: string;
  metric: string;
  stack: string[];
};

type ActivityItem = {
  when: string;
  title: string;
  detail: string;
  kind: "ship" | "write" | "talk" | "merge";
};

type StackGroup = {
  heading: string;
  note: string;
  items: { name: string; level: number; years: string }[];
};

const TABS: Tab[] = [
  { id: "overview", label: "Overview" },
  { id: "projects", label: "Projects", badge: "4" },
  { id: "activity", label: "Activity", badge: "5" },
  { id: "stack", label: "Stack" },
];

const PROJECTS: Project[] = [
  {
    name: "Ledgerline",
    blurb:
      "Double-entry accounting engine for freelancers. Rewrote the reconciliation pass so a 40k-transaction import settles in under two seconds instead of timing out.",
    role: "Lead engineer",
    year: "2025",
    metric: "40k txns / 1.8s",
    stack: ["TypeScript", "Postgres", "Rust"],
  },
  {
    name: "Северный",
    blurb:
      "Offline-first field notes app for Arctic survey crews. CRDT sync means two geologists can edit the same log 300km apart with no signal and merge cleanly at camp.",
    role: "Founding engineer",
    year: "2024",
    metric: "0 merge conflicts in 11 months",
    stack: ["React Native", "Automerge", "SQLite"],
  },
  {
    name: "Kerning Club",
    blurb:
      "A type-spacing trainer that shows you your kerning next to a master's. Built the diff renderer that overlays both curves at 8x zoom without a canvas repaint storm.",
    role: "Solo build",
    year: "2024",
    metric: "22k signups, no ad spend",
    stack: ["Svelte", "WebGL", "Cloudflare"],
  },
  {
    name: "Quietport",
    blurb:
      "Status page that refuses to lie. Pulls from real probes instead of a human toggling a switch, and publishes the raw probe history alongside every incident.",
    role: "Maintainer",
    year: "2023",
    metric: "1.4k GitHub stars",
    stack: ["Go", "ClickHouse", "HTMX"],
  },
];

const ACTIVITY: ActivityItem[] = [
  {
    when: "2 days ago",
    title: "Shipped Ledgerline 3.2",
    detail:
      "Multi-currency ledgers, plus an audit trail that survives a full database restore. Took four weeks longer than planned because rounding is a liar.",
    kind: "ship",
  },
  {
    when: "1 week ago",
    title: "Wrote “Your ORM is a distributed system”",
    detail:
      "3,100 words on why lazy loading across a network boundary is a design decision, not a convenience.",
    kind: "write",
  },
  {
    when: "3 weeks ago",
    title: "Merged 14 PRs into Quietport",
    detail:
      "Mostly community fixes for the probe scheduler. Two were from a contributor who had never written Go before — those took the longest and were worth it.",
    kind: "merge",
  },
  {
    when: "2 months ago",
    title: "Talked at LocalFirst Berlin",
    detail:
      "Forty minutes on CRDT sync in places with no cell towers. The Q&A ran longer than the talk.",
    kind: "talk",
  },
  {
    when: "4 months ago",
    title: "Deleted 6,000 lines from Kerning Club",
    detail:
      "Replaced the plugin architecture nobody used with one exported function. Bundle went from 190kb to 44kb.",
    kind: "ship",
  },
];

const STACK: StackGroup[] = [
  {
    heading: "Daily drivers",
    note: "Things I'd reach for at 3am without reading the docs.",
    items: [
      { name: "TypeScript", level: 95, years: "9 yrs" },
      { name: "Postgres", level: 88, years: "8 yrs" },
      { name: "React", level: 92, years: "9 yrs" },
    ],
  },
  {
    heading: "Comfortable",
    note: "Productive, but I still keep a tab open.",
    items: [
      { name: "Rust", level: 64, years: "3 yrs" },
      { name: "Go", level: 71, years: "5 yrs" },
      { name: "WebGL", level: 58, years: "2 yrs" },
    ],
  },
  {
    heading: "Learning in public",
    note: "Actively bad at these and writing about it.",
    items: [
      { name: "Zig", level: 28, years: "7 mo" },
      { name: "Elixir", level: 34, years: "1 yr" },
    ],
  },
];

const KIND_STYLES: Record<ActivityItem["kind"], { dot: string; label: string }> = {
  ship: { dot: "bg-emerald-500", label: "Release" },
  write: { dot: "bg-violet-500", label: "Writing" },
  talk: { dot: "bg-amber-500", label: "Talk" },
  merge: { dot: "bg-sky-500", label: "Open source" },
};

function IconGithub() {
  return (
    <svg viewBox="0 0 24 24" aria-hidden="true" className="h-4 w-4" fill="currentColor">
      <path d="M12 2C6.48 2 2 6.58 2 12.25c0 4.53 2.87 8.37 6.84 9.72.5.1.68-.22.68-.49l-.01-1.7c-2.78.62-3.37-1.37-3.37-1.37-.45-1.19-1.11-1.5-1.11-1.5-.91-.64.07-.63.07-.63 1 .07 1.53 1.06 1.53 1.06.89 1.57 2.34 1.12 2.91.86.09-.66.35-1.12.63-1.38-2.22-.26-4.56-1.14-4.56-5.07 0-1.12.39-2.03 1.03-2.75-.1-.26-.45-1.3.1-2.71 0 0 .84-.28 2.75 1.05a9.3 9.3 0 0 1 5 0c1.91-1.33 2.75-1.05 2.75-1.05.55 1.41.2 2.45.1 2.71.64.72 1.03 1.63 1.03 2.75 0 3.94-2.34 4.81-4.57 5.06.36.32.68.94.68 1.9l-.01 2.82c0 .27.18.6.69.49A10.03 10.03 0 0 0 22 12.25C22 6.58 17.52 2 12 2Z" />
    </svg>
  );
}

function IconLink() {
  return (
    <svg
      viewBox="0 0 24 24"
      aria-hidden="true"
      className="h-4 w-4"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.8}
      strokeLinecap="round"
      strokeLinejoin="round"
    >
      <path d="M10 13a5 5 0 0 0 7.07 0l2.83-2.83a5 5 0 0 0-7.07-7.07L11.5 4.43" />
      <path d="M14 11a5 5 0 0 0-7.07 0L4.1 13.83a5 5 0 0 0 7.07 7.07L12.5 19.5" />
    </svg>
  );
}

function IconPin() {
  return (
    <svg
      viewBox="0 0 24 24"
      aria-hidden="true"
      className="h-4 w-4"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.8}
      strokeLinecap="round"
      strokeLinejoin="round"
    >
      <path d="M12 21s7-5.5 7-11a7 7 0 1 0-14 0c0 5.5 7 11 7 11Z" />
      <circle cx="12" cy="10" r="2.5" />
    </svg>
  );
}

function IconClock() {
  return (
    <svg
      viewBox="0 0 24 24"
      aria-hidden="true"
      className="h-4 w-4"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.8}
      strokeLinecap="round"
      strokeLinejoin="round"
    >
      <circle cx="12" cy="12" r="9" />
      <path d="M12 7v5l3.2 1.9" />
    </svg>
  );
}

export default function ProfileTabs() {
  const reduce = useReducedMotion();
  const baseId = useId();
  const [active, setActive] = useState<TabId>("overview");
  const [copied, setCopied] = useState(false);
  const tabRefs = useRef<Record<string, HTMLButtonElement | null>>({});
  const copyTimer = useRef<number | null>(null);

  const activeIndex = useMemo(() => TABS.findIndex((t) => t.id === active), [active]);

  const focusTab = useCallback((id: TabId) => {
    setActive(id);
    tabRefs.current[id]?.focus();
  }, []);

  const onTabKeyDown = useCallback(
    (event: ReactKeyboardEvent<HTMLButtonElement>) => {
      const last = TABS.length - 1;
      let next = -1;
      if (event.key === "ArrowRight") next = activeIndex === last ? 0 : activeIndex + 1;
      else if (event.key === "ArrowLeft") next = activeIndex === 0 ? last : activeIndex - 1;
      else if (event.key === "Home") next = 0;
      else if (event.key === "End") next = last;
      if (next < 0) return;
      event.preventDefault();
      focusTab(TABS[next].id);
    },
    [activeIndex, focusTab],
  );

  const copyEmail = useCallback(() => {
    const email = "mara@ledgerline.dev";
    const done = () => {
      setCopied(true);
      if (copyTimer.current !== null) window.clearTimeout(copyTimer.current);
      copyTimer.current = window.setTimeout(() => setCopied(false), 2000);
    };
    if (navigator.clipboard?.writeText) {
      navigator.clipboard.writeText(email).then(done, done);
    } else {
      done();
    }
  }, []);

  const panelMotion = reduce
    ? { initial: false as const, animate: {}, exit: {} }
    : {
        initial: { opacity: 0, y: 8 },
        animate: { opacity: 1, y: 0 },
        exit: { opacity: 0, y: -6 },
      };

  return (
    <section className="relative w-full overflow-hidden bg-slate-50 px-4 py-20 text-slate-900 sm:px-6 sm:py-28 dark:bg-slate-950 dark:text-slate-100">
      <style>{`
        @keyframes ptabs-sheen {
          0% { transform: translateX(-120%) skewX(-12deg); }
          100% { transform: translateX(320%) skewX(-12deg); }
        }
        @keyframes ptabs-pulse {
          0%, 100% { opacity: 1; transform: scale(1); }
          50% { opacity: 0.35; transform: scale(1.9); }
        }
        .ptabs-sheen::after {
          content: "";
          position: absolute;
          inset: 0;
          width: 40%;
          background: linear-gradient(90deg, transparent, rgba(255,255,255,0.5), transparent);
          animation: ptabs-sheen 4.5s ease-in-out infinite;
          animation-delay: 1.2s;
          pointer-events: none;
        }
        .ptabs-ping {
          animation: ptabs-pulse 2.4s ease-out infinite;
        }
        @media (prefers-reduced-motion: reduce) {
          .ptabs-sheen::after,
          .ptabs-ping {
            animation: none !important;
          }
        }
      `}</style>

      <div
        aria-hidden="true"
        className="pointer-events-none absolute inset-x-0 top-0 h-72 bg-[radial-gradient(60%_100%_at_50%_0%,rgba(99,102,241,0.14),transparent_70%)] dark:bg-[radial-gradient(60%_100%_at_50%_0%,rgba(129,140,248,0.16),transparent_70%)]"
      />

      <div className="relative mx-auto w-full max-w-4xl">
        {/* Header card */}
        <div className="rounded-3xl border border-slate-200/80 bg-white shadow-[0_1px_0_0_rgba(15,23,42,0.04),0_24px_60px_-30px_rgba(15,23,42,0.35)] dark:border-slate-800 dark:bg-slate-900 dark:shadow-[0_24px_60px_-30px_rgba(0,0,0,0.9)]">
          <div className="relative h-28 overflow-hidden rounded-t-3xl bg-gradient-to-br from-indigo-500 via-violet-500 to-sky-400 sm:h-32">
            <div className="ptabs-sheen absolute inset-0" />
            <div
              aria-hidden="true"
              className="absolute inset-0 opacity-30 [background-image:linear-gradient(rgba(255,255,255,0.35)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.35)_1px,transparent_1px)] [background-size:28px_28px]"
            />
          </div>

          <div className="px-5 pb-6 sm:px-8 sm:pb-8">
            <div className="-mt-12 flex flex-col gap-5 sm:-mt-14 sm:flex-row sm:items-end sm:justify-between">
              <div className="flex items-end gap-4">
                <div className="relative">
                  <div
                    aria-hidden="true"
                    className="flex h-24 w-24 select-none items-center justify-center rounded-2xl border-4 border-white bg-gradient-to-br from-slate-800 to-slate-600 text-2xl font-semibold tracking-tight text-white sm:h-28 sm:w-28 dark:border-slate-900 dark:from-slate-200 dark:to-slate-400 dark:text-slate-900"
                  >
                    MV
                  </div>
                  <span className="absolute -bottom-1 -right-1 flex h-6 w-6 items-center justify-center rounded-full border-4 border-white bg-white dark:border-slate-900 dark:bg-slate-900">
                    <span className="relative flex h-2.5 w-2.5">
                      <span className="ptabs-ping absolute inline-flex h-full w-full rounded-full bg-emerald-500" />
                      <span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-emerald-500" />
                    </span>
                    <span className="sr-only">Currently available for work</span>
                  </span>
                </div>

                <div className="pb-1">
                  <h2 className="text-xl font-semibold tracking-tight text-slate-900 sm:text-2xl dark:text-slate-50">
                    Mara Volkov
                  </h2>
                  <p className="mt-0.5 text-sm text-slate-600 dark:text-slate-400">
                    Systems-leaning product engineer
                  </p>
                  <div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-slate-500 dark:text-slate-500">
                    <span className="inline-flex items-center gap-1.5">
                      <IconPin />
                      Tallinn, Estonia
                    </span>
                    <span className="inline-flex items-center gap-1.5">
                      <IconClock />
                      Replies in ~1 day
                    </span>
                  </div>
                </div>
              </div>

              <div className="flex flex-wrap items-center gap-2">
                <button
                  type="button"
                  onClick={copyEmail}
                  aria-live="polite"
                  className="inline-flex h-10 items-center justify-center rounded-xl bg-slate-900 px-4 text-sm font-medium text-white transition-colors hover:bg-slate-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:bg-slate-100 dark:text-slate-900 dark:hover:bg-white dark:focus-visible:ring-offset-slate-900"
                >
                  {copied ? "Email copied" : "Copy email"}
                </button>
                <a
                  href="https://github.com"
                  target="_blank"
                  rel="noopener"
                  className="inline-flex h-10 w-10 items-center justify-center rounded-xl border border-slate-200 text-slate-600 transition-colors hover:border-slate-300 hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-slate-700 dark:text-slate-400 dark:hover:border-slate-600 dark:hover:text-slate-100 dark:focus-visible:ring-offset-slate-900"
                >
                  <IconGithub />
                  <span className="sr-only">Mara Volkov on GitHub (opens in a new tab)</span>
                </a>
                <a
                  href="https://example.com"
                  target="_blank"
                  rel="noopener"
                  className="inline-flex h-10 w-10 items-center justify-center rounded-xl border border-slate-200 text-slate-600 transition-colors hover:border-slate-300 hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-slate-700 dark:text-slate-400 dark:hover:border-slate-600 dark:hover:text-slate-100 dark:focus-visible:ring-offset-slate-900"
                >
                  <IconLink />
                  <span className="sr-only">Personal site (opens in a new tab)</span>
                </a>
              </div>
            </div>

            {/* Tablist */}
            <div className="mt-7 border-b border-slate-200 dark:border-slate-800">
              <div
                role="tablist"
                aria-label="Profile sections"
                className="-mb-px flex gap-1 overflow-x-auto"
              >
                {TABS.map((tab) => {
                  const selected = tab.id === active;
                  return (
                    <button
                      key={tab.id}
                      ref={(node) => {
                        tabRefs.current[tab.id] = node;
                      }}
                      type="button"
                      role="tab"
                      id={`${baseId}-tab-${tab.id}`}
                      aria-selected={selected}
                      aria-controls={`${baseId}-panel-${tab.id}`}
                      tabIndex={selected ? 0 : -1}
                      onClick={() => setActive(tab.id)}
                      onKeyDown={onTabKeyDown}
                      className={`relative shrink-0 rounded-t-lg px-3.5 py-3 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-indigo-500 sm:px-4 ${
                        selected
                          ? "text-slate-900 dark:text-slate-50"
                          : "text-slate-500 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-200"
                      }`}
                    >
                      <span className="inline-flex items-center gap-2">
                        {tab.label}
                        {tab.badge ? (
                          <span
                            className={`rounded-md px-1.5 py-0.5 text-[10px] font-semibold tabular-nums transition-colors ${
                              selected
                                ? "bg-indigo-100 text-indigo-700 dark:bg-indigo-500/20 dark:text-indigo-300"
                                : "bg-slate-100 text-slate-500 dark:bg-slate-800 dark:text-slate-400"
                            }`}
                          >
                            {tab.badge}
                          </span>
                        ) : null}
                      </span>
                      {selected ? (
                        <motion.span
                          layoutId={`${baseId}-underline`}
                          className="absolute inset-x-2 -bottom-px h-0.5 rounded-full bg-indigo-600 dark:bg-indigo-400"
                          transition={
                            reduce
                              ? { duration: 0 }
                              : { type: "spring", stiffness: 420, damping: 34 }
                          }
                        />
                      ) : null}
                    </button>
                  );
                })}
              </div>
            </div>

            {/* Panels */}
            <div className="pt-6">
              <AnimatePresence mode="wait" initial={false}>
                <motion.div
                  key={active}
                  {...panelMotion}
                  transition={reduce ? { duration: 0 } : { duration: 0.22, ease: "easeOut" }}
                  role="tabpanel"
                  id={`${baseId}-panel-${active}`}
                  aria-labelledby={`${baseId}-tab-${active}`}
                  tabIndex={0}
                  className="rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500"
                >
                  {active === "overview" ? <OverviewPanel /> : null}
                  {active === "projects" ? <ProjectsPanel /> : null}
                  {active === "activity" ? <ActivityPanel /> : null}
                  {active === "stack" ? <StackPanel reduce={Boolean(reduce)} /> : null}
                </motion.div>
              </AnimatePresence>
            </div>
          </div>
        </div>

        <p className="mt-4 text-center text-xs text-slate-500 dark:text-slate-500">
          Use <kbd className="rounded border border-slate-300 px-1 py-0.5 font-sans text-[10px] dark:border-slate-700">←</kbd>{" "}
          <kbd className="rounded border border-slate-300 px-1 py-0.5 font-sans text-[10px] dark:border-slate-700">→</kbd>{" "}
          on the tabs to move between sections.
        </p>
      </div>
    </section>
  );
}

function OverviewPanel() {
  return (
    <div className="grid gap-6 sm:grid-cols-5">
      <div className="sm:col-span-3">
        <h3 className="text-sm font-semibold text-slate-900 dark:text-slate-100">
          What I actually do
        </h3>
        <div className="mt-3 space-y-3 text-sm leading-relaxed text-slate-600 dark:text-slate-400">
          <p>
            I take products that work on a laptop and make them work at three in the morning under
            load, offline, or on a decade-old Android. Most of that is unglamorous: query plans,
            retry semantics, and deleting the abstraction someone added for a case that never came.
          </p>
          <p>
            Nine years in, split roughly evenly between fintech ledgers and offline-first tooling.
            I've never enjoyed a rewrite, but I've done four and I know when one is the honest
            answer.
          </p>
          <p>
            Right now I'm taking on one contract at a time — usually a team that has a performance
            or data-integrity problem they can name but can't isolate.
          </p>
        </div>
      </div>

      <div className="sm:col-span-2">
        <h3 className="text-sm font-semibold text-slate-900 dark:text-slate-100">At a glance</h3>
        <dl className="mt-3 grid grid-cols-2 gap-3">
          {[
            { k: "Years shipping", v: "9" },
            { k: "Prod incidents caused", v: "3" },
            { k: "Open source repos", v: "27" },
            { k: "Timezone", v: "UTC+2" },
          ].map((stat) => (
            <div
              key={stat.k}
              className="rounded-xl border border-slate-200 bg-slate-50/70 p-3 dark:border-slate-800 dark:bg-slate-800/40"
            >
              <dt className="text-[11px] uppercase tracking-wide text-slate-500 dark:text-slate-500">
                {stat.k}
              </dt>
              <dd className="mt-1 text-lg font-semibold tabular-nums text-slate-900 dark:text-slate-100">
                {stat.v}
              </dd>
            </div>
          ))}
        </dl>
        <div className="mt-3 rounded-xl border border-emerald-200 bg-emerald-50 p-3 dark:border-emerald-500/25 dark:bg-emerald-500/10">
          <p className="text-xs font-medium text-emerald-800 dark:text-emerald-300">
            Open to contract work from September 2026
          </p>
          <p className="mt-1 text-xs text-emerald-700/80 dark:text-emerald-400/80">
            Two-week minimum. Remote, or on-site in the Baltics.
          </p>
        </div>
      </div>
    </div>
  );
}

function ProjectsPanel() {
  return (
    <ul className="grid gap-3 sm:grid-cols-2">
      {PROJECTS.map((project) => (
        <li key={project.name}>
          <article className="group h-full rounded-2xl border border-slate-200 bg-white p-4 transition-colors hover:border-slate-300 dark:border-slate-800 dark:bg-slate-900 dark:hover:border-slate-700">
            <div className="flex items-baseline justify-between gap-3">
              <h3 className="text-sm font-semibold text-slate-900 dark:text-slate-100">
                {project.name}
              </h3>
              <span className="shrink-0 text-xs tabular-nums text-slate-400 dark:text-slate-600">
                {project.year}
              </span>
            </div>
            <p className="mt-0.5 text-xs font-medium text-indigo-600 dark:text-indigo-400">
              {project.role}
            </p>
            <p className="mt-2 text-sm leading-relaxed text-slate-600 dark:text-slate-400">
              {project.blurb}
            </p>
            <p className="mt-3 inline-flex rounded-lg bg-slate-100 px-2 py-1 text-xs font-medium tabular-nums text-slate-700 dark:bg-slate-800 dark:text-slate-300">
              {project.metric}
            </p>
            <ul className="mt-3 flex flex-wrap gap-1.5">
              {project.stack.map((tech) => (
                <li
                  key={tech}
                  className="rounded-md border border-slate-200 px-1.5 py-0.5 text-[11px] text-slate-500 dark:border-slate-700 dark:text-slate-400"
                >
                  {tech}
                </li>
              ))}
            </ul>
          </article>
        </li>
      ))}
    </ul>
  );
}

function ActivityPanel() {
  return (
    <ol className="relative space-y-5 border-l border-slate-200 pl-6 dark:border-slate-800">
      {ACTIVITY.map((item) => (
        <li key={item.title} className="relative">
          <span
            aria-hidden="true"
            className={`absolute -left-[26.5px] top-1.5 h-2.5 w-2.5 rounded-full ring-4 ring-white dark:ring-slate-900 ${KIND_STYLES[item.kind].dot}`}
          />
          <div className="flex flex-wrap items-center gap-2">
            <h3 className="text-sm font-semibold text-slate-900 dark:text-slate-100">
              {item.title}
            </h3>
            <span className="rounded-md bg-slate-100 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-slate-500 dark:bg-slate-800 dark:text-slate-400">
              {KIND_STYLES[item.kind].label}
            </span>
          </div>
          <p className="mt-0.5 text-xs text-slate-400 dark:text-slate-600">{item.when}</p>
          <p className="mt-1.5 text-sm leading-relaxed text-slate-600 dark:text-slate-400">
            {item.detail}
          </p>
        </li>
      ))}
    </ol>
  );
}

function StackPanel({ reduce }: { reduce: boolean }) {
  return (
    <div className="space-y-6">
      {STACK.map((group) => (
        <div key={group.heading}>
          <h3 className="text-sm font-semibold text-slate-900 dark:text-slate-100">
            {group.heading}
          </h3>
          <p className="mt-0.5 text-xs text-slate-500 dark:text-slate-500">{group.note}</p>
          <ul className="mt-3 space-y-2.5">
            {group.items.map((item) => (
              <li key={item.name} className="flex items-center gap-4">
                <span className="w-24 shrink-0 text-sm text-slate-700 dark:text-slate-300">
                  {item.name}
                </span>
                <div
                  role="meter"
                  aria-label={`${item.name} proficiency`}
                  aria-valuenow={item.level}
                  aria-valuemin={0}
                  aria-valuemax={100}
                  aria-valuetext={`${item.level} out of 100`}
                  className="h-1.5 flex-1 overflow-hidden rounded-full bg-slate-200 dark:bg-slate-800"
                >
                  <motion.div
                    initial={reduce ? false : { scaleX: 0 }}
                    animate={{ scaleX: item.level / 100 }}
                    transition={reduce ? { duration: 0 } : { duration: 0.6, ease: "easeOut" }}
                    style={{ transformOrigin: "left" }}
                    className="h-full w-full rounded-full bg-gradient-to-r from-indigo-500 to-violet-500"
                  />
                </div>
                <span className="w-12 shrink-0 text-right text-xs tabular-nums text-slate-400 dark:text-slate-600">
                  {item.years}
                </span>
              </li>
            ))}
          </ul>
        </div>
      ))}
    </div>
  );
}

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 →