A Duolingo-style skill tree with locked, unlocked, current, and complete nodes on a serpentine path, colored rails, and a pulsing current node.
npx shadcn@latest add @paragon/skill-tree"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>
);
}