EdTech

Skill Tree

A Duolingo-style skill tree with locked, unlocked, current, and complete nodes on a serpentine path, colored rails, and a pulsing current node.

Install

npx shadcn@latest add @paragon/skill-tree

skill-tree.tsx

"use client";

import * as React from "react";
import { Check, Lock, Star, Crown } from "lucide-react";
import { cn } from "@/lib/utils";

export type SkillState = "complete" | "current" | "unlocked" | "locked";

export interface SkillNode {
  id: string;
  label: string;
  state: SkillState;
  /** Column offset from center, in units of node-width. Negative = left. */
  offset?: number;
  /** Crown/trophy checkpoint node. */
  checkpoint?: boolean;
}

export interface SkillTreeProps extends React.ComponentProps<"div"> {
  nodes?: SkillNode[];
  /** Accent color for completed/current paths. */
  accent?: string;
}

const DEFAULT_NODES: SkillNode[] = [
  { id: "basics", label: "Basics", state: "complete", offset: 0 },
  { id: "phrases", label: "Phrases", state: "complete", offset: -1 },
  { id: "travel", label: "Travel", state: "complete", offset: 1, checkpoint: true },
  { id: "food", label: "Food", state: "current", offset: 0 },
  { id: "family", label: "Family", state: "unlocked", offset: -1 },
  { id: "market", label: "Market", state: "locked", offset: 1 },
  { id: "stories", label: "Stories", state: "locked", offset: 0, checkpoint: true },
];

const ROW_H = 84;
const COL = 56;

function stateStyle(state: SkillState, accent: string) {
  switch (state) {
    case "complete":
      return { ring: accent, fill: accent, text: "#fff", dim: false };
    case "current":
      return { ring: accent, fill: "var(--color-card)", text: accent, dim: false };
    default:
      return {
        ring: "var(--color-border)",
        fill: "var(--color-secondary)",
        text: "var(--color-muted-foreground)",
        dim: state === "locked",
      };
  }
}

/**
 * A Duolingo-style skill tree: circular skill nodes on a serpentine path with
 * locked / unlocked / current / complete states and connecting rails drawn as
 * a single SVG layer (so paths color by completion without per-node layout).
 * The current node carries a reduced-motion-safe pulse ring. Deterministic.
 */
export function SkillTree({
  nodes = DEFAULT_NODES,
  accent = "#58cc02",
  className,
  ...props
}: SkillTreeProps) {
  const height = nodes.length * ROW_H + 24;
  const centerX = 130;
  const x = (n: SkillNode) => centerX + (n.offset ?? 0) * COL;
  const y = (i: number) => 40 + i * ROW_H;

  return (
    <div
      data-slot="skill-tree"
      className={cn(
        "w-full max-w-[280px] rounded-xl bg-card p-4 text-card-foreground shadow-border",
        className,
      )}
      {...props}
    >
      <style href="paragon-skill-tree" precedence="paragon">{`
        @keyframes paragon-skill-pulse {
          0% { transform: scale(1); opacity: 0.5; }
          70%, 100% { transform: scale(1.5); opacity: 0; }
        }
        @media (prefers-reduced-motion: reduce) {
          [data-skill-pulse] { animation: none !important; opacity: 0 !important; }
        }
      `}</style>

      <div className="relative" style={{ height }}>
        <svg
          aria-hidden
          className="pointer-events-none absolute inset-0 h-full w-full"
          viewBox={`0 0 260 ${height}`}
        >
          {nodes.slice(0, -1).map((n, i) => {
            const next = nodes[i + 1];
            const connected =
              n.state === "complete" &&
              (next.state === "complete" || next.state === "current");
            return (
              <line
                key={n.id}
                x1={x(n)}
                y1={y(i)}
                x2={x(next)}
                y2={y(i + 1)}
                stroke={connected ? accent : "var(--color-border)"}
                strokeWidth={4}
                strokeLinecap="round"
                strokeDasharray={connected ? undefined : "1 8"}
              />
            );
          })}
        </svg>

        {nodes.map((n, i) => {
          const s = stateStyle(n.state, accent);
          const Icon = n.checkpoint
            ? Crown
            : n.state === "complete"
              ? Check
              : n.state === "locked"
                ? Lock
                : Star;
          const interactive = n.state === "current" || n.state === "unlocked";
          return (
            <div
              key={n.id}
              className="absolute flex -translate-x-1/2 -translate-y-1/2 flex-col items-center"
              style={{ left: x(n), top: y(i) }}
            >
              <div className="relative">
                {n.state === "current" && (
                  <span
                    data-skill-pulse
                    aria-hidden
                    className="absolute inset-0 rounded-full"
                    style={{
                      boxShadow: `0 0 0 3px ${accent}`,
                      animation:
                        "paragon-skill-pulse 1.8s var(--ease-out) infinite",
                    }}
                  />
                )}
                <button
                  type="button"
                  disabled={!interactive}
                  aria-label={`${n.label} — ${n.state}`}
                  className={cn(
                    "flex size-11 items-center justify-center rounded-full border-[3px] transition-[scale] duration-150 ease-out",
                    interactive && "active:not-disabled:scale-[0.94]",
                    !interactive && "cursor-default",
                    s.dim && "opacity-55",
                  )}
                  style={{ borderColor: s.ring, background: s.fill, color: s.text }}
                >
                  <Icon className="size-5" />
                </button>
              </div>
              <span
                className={cn(
                  "mt-1 text-[11px] font-medium",
                  n.state === "locked"
                    ? "text-muted-foreground"
                    : "text-foreground",
                )}
              >
                {n.label}
              </span>
            </div>
          );
        })}
      </div>
    </div>
  );
}