Fund Compare
Fintech & dataTwo funds honestly compared: mirrored metric bars grow from the centre, the better side gets the dot per metric, and portfolio overlap says whether holding both diversifies anything.
Nifty Alpha Flexicap
Flexi cap · direct growth
Bluechip Advantage
Large cap · direct growth
●18.4%3y returns · annualised14.1%
●15.2%5y returns · annualised13.8%
●74%Rolling consistency · 3y windows beating benchmark58%
●0.62%Expense ratio · lower is better0.95%
−21.3%Max drawdown · worst peak-to-trough−16.8%●
●1.12Sharpe · risk-adjusted return0.94
●6.5yManager tenure · current manager2.1y
●38%Top-10 weight · concentration, lower is better52%
Exit load
1% < 1y vs Nil
Portfolio overlap
41%
Nifty Alpha Flexicap leads on 7 of 8 metrics · past returns don't guarantee future ones · not advice
Install
npx shadcn@latest add https://labs.duku.design/r/fund-compare.jsonAsk your agent to use this
Connect DUKU Labs to MCP, then hand your coding agent this prompt:
Use the DUKU Labs MCP server to install the Fund Compare component (get_component "fund-compare", then its install command). Adapt it to my existing design tokens, preserve all accessibility and reduced-motion behavior, and keep every interaction state working.
Interaction
| Try | What happens |
|---|---|
| Mount | Mirrored bars fill outward from the centre; green dots mark the winner per metric |
| Check the overlap meter | Above 50% it turns warning with 'holding both adds little diversification' |
| Read the footer | Which fund leads on how many metrics — plus the returns disclaimer |
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| funds | [FundProfile, FundProfile] | DEMO_FUNDS | Returns, rolling consistency, expense, drawdown, Sharpe, tenure, concentration. |
| overlapPct | number | 41 | Portfolio overlap between the two funds. |
Source — registry/default/fintech/fund-compare.tsx
"use client";
import * as React from "react";
import gsap from "gsap";
import { useGSAP } from "@gsap/react";
import { cn } from "@/lib/utils";
import { useReducedMotion } from "@/registry/default/lib/use-reduced-motion";
/* ------------------------------------------------------------------ */
/* Model */
/* ------------------------------------------------------------------ */
export interface FundProfile {
name: string;
category: string;
returns3y: number;
returns5y: number;
/** Share of 3y rolling windows beating the benchmark, %. */
rollingBeat: number;
expenseRatio: number;
exitLoad: string;
maxDrawdown: number;
sharpe: number;
managerTenureYears: number;
/** Top-10 holdings share, %. */
top10Weight: number;
}
export interface FundCompareProps
extends React.HTMLAttributes<HTMLDivElement> {
funds?: [FundProfile, FundProfile];
/** Portfolio overlap between the two funds, %. */
overlapPct?: number;
ref?: React.Ref<HTMLDivElement>;
}
export const DEMO_FUNDS: [FundProfile, FundProfile] = [
{
name: "Nifty Alpha Flexicap",
category: "Flexi cap · direct growth",
returns3y: 18.4,
returns5y: 15.2,
rollingBeat: 74,
expenseRatio: 0.62,
exitLoad: "1% < 1y",
maxDrawdown: -21.3,
sharpe: 1.12,
managerTenureYears: 6.5,
top10Weight: 38,
},
{
name: "Bluechip Advantage",
category: "Large cap · direct growth",
returns3y: 14.1,
returns5y: 13.8,
rollingBeat: 58,
expenseRatio: 0.95,
exitLoad: "Nil",
maxDrawdown: -16.8,
sharpe: 0.94,
managerTenureYears: 2.1,
top10Weight: 52,
},
];
interface MetricRow {
key: string;
label: string;
hint: string;
value: (f: FundProfile) => number;
format: (v: number) => string;
/** true if higher is better. */
higherBetter: boolean;
/** Normalizer for the bar, returns 0–1. */
norm: (v: number) => number;
}
const METRICS: MetricRow[] = [
{ key: "r3", label: "3y returns", hint: "annualised", value: (f) => f.returns3y, format: (v) => `${v.toFixed(1)}%`, higherBetter: true, norm: (v) => v / 25 },
{ key: "r5", label: "5y returns", hint: "annualised", value: (f) => f.returns5y, format: (v) => `${v.toFixed(1)}%`, higherBetter: true, norm: (v) => v / 25 },
{ key: "roll", label: "Rolling consistency", hint: "3y windows beating benchmark", value: (f) => f.rollingBeat, format: (v) => `${v.toFixed(0)}%`, higherBetter: true, norm: (v) => v / 100 },
{ key: "er", label: "Expense ratio", hint: "lower is better", value: (f) => f.expenseRatio, format: (v) => `${v.toFixed(2)}%`, higherBetter: false, norm: (v) => v / 1.5 },
{ key: "dd", label: "Max drawdown", hint: "worst peak-to-trough", value: (f) => Math.abs(f.maxDrawdown), format: (v) => `−${v.toFixed(1)}%`, higherBetter: false, norm: (v) => v / 30 },
{ key: "sharpe", label: "Sharpe", hint: "risk-adjusted return", value: (f) => f.sharpe, format: (v) => v.toFixed(2), higherBetter: true, norm: (v) => v / 1.5 },
{ key: "tenure", label: "Manager tenure", hint: "current manager", value: (f) => f.managerTenureYears, format: (v) => `${v.toFixed(1)}y`, higherBetter: true, norm: (v) => v / 10 },
{ key: "conc", label: "Top-10 weight", hint: "concentration, lower is better", value: (f) => f.top10Weight, format: (v) => `${v.toFixed(0)}%`, higherBetter: false, norm: (v) => v / 60 },
];
/* ------------------------------------------------------------------ */
/* Compare */
/* ------------------------------------------------------------------ */
/**
* Two funds, honestly compared: mirrored metric bars grow from the
* centre, the better side is marked per metric, and portfolio overlap
* tells you whether holding both diversifies anything at all.
*/
export function FundCompare({
funds = DEMO_FUNDS,
overlapPct = 41,
className,
ref,
...rest
}: FundCompareProps) {
const reduced = useReducedMotion();
const rootRef = React.useRef<HTMLDivElement>(null);
React.useImperativeHandle(ref, () => rootRef.current as HTMLDivElement);
const [a, b] = funds;
const winsA = METRICS.filter((m) => {
const va = m.value(a);
const vb = m.value(b);
return m.higherBetter ? va > vb : va < vb;
}).length;
useGSAP(
() => {
const root = rootRef.current;
if (!root) return;
const bars = gsap.utils.toArray<HTMLElement>(root.querySelectorAll("[data-bar]"));
if (reduced) {
for (const bar of bars) gsap.set(bar, { width: `${Number(bar.dataset.bar) * 100}%` });
return;
}
for (const bar of bars) {
gsap.fromTo(
bar,
{ width: 0 },
{
width: `${Math.min(1, Number(bar.dataset.bar)) * 100}%`,
duration: 0.6,
ease: "power3.out",
stagger: 0.05,
}
);
}
},
{ scope: rootRef }
);
const winDot = (mine: number, theirs: number, higherBetter: boolean) => {
const win = higherBetter ? mine > theirs : mine < theirs;
return win ? (
<span aria-label="better" className="text-success">●</span>
) : null;
};
return (
<div
ref={rootRef}
data-slot="fund-compare"
className={cn(
"w-full max-w-xl rounded-2xl border border-border bg-card p-4",
className
)}
{...rest}
>
{/* Fund headers */}
<div className="grid grid-cols-[1fr_auto_1fr] items-start gap-2">
<div>
<p className="text-sm font-semibold leading-5 text-foreground">{a.name}</p>
<p className="text-[10px] text-muted-foreground">{a.category}</p>
</div>
<span className="pt-1 text-[10px] font-medium uppercase text-muted-foreground">vs</span>
<div className="text-right">
<p className="text-sm font-semibold leading-5 text-foreground">{b.name}</p>
<p className="text-[10px] text-muted-foreground">{b.category}</p>
</div>
</div>
{/* Metric rows: mirrored bars from the centre */}
<div className="mt-4 flex flex-col gap-2.5">
{METRICS.map((m) => {
const va = m.value(a);
const vb = m.value(b);
return (
<div key={m.key}>
<div className="grid grid-cols-[1fr_auto_1fr] items-baseline gap-2 text-xs tabular-nums">
<span className="flex items-center gap-1.5 font-medium text-foreground">
{winDot(va, vb, m.higherBetter)}
{m.format(m.key === "dd" ? va : va)}
</span>
<span className="text-center text-[10px] text-muted-foreground">
{m.label}
<span className="hidden sm:inline"> · {m.hint}</span>
</span>
<span className="flex items-center justify-end gap-1.5 font-medium text-foreground">
{m.format(vb)}
{winDot(vb, va, m.higherBetter)}
</span>
</div>
<div className="mt-1 grid grid-cols-2 gap-1" aria-hidden="true">
<div className="flex justify-end overflow-hidden rounded-l-full bg-muted">
<span data-bar={Math.min(1, m.norm(va))} className="block h-1.5 rounded-l-full bg-primary" style={{ width: 0 }} />
</div>
<div className="flex overflow-hidden rounded-r-full bg-muted">
<span data-bar={Math.min(1, m.norm(vb))} className="block h-1.5 rounded-r-full bg-info" style={{ width: 0 }} />
</div>
</div>
</div>
);
})}
</div>
{/* Exit load + overlap */}
<div className="mt-4 grid gap-2 sm:grid-cols-2">
<div className="rounded-xl border border-border bg-background px-3 py-2 text-xs">
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">Exit load</p>
<p className="mt-0.5 tabular-nums text-foreground">
{a.exitLoad} <span className="text-muted-foreground">vs</span> {b.exitLoad}
</p>
</div>
<div className="rounded-xl border border-border bg-background px-3 py-2 text-xs">
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
Portfolio overlap
</p>
<div className="mt-1.5 flex items-center gap-2">
<span
className="h-1.5 flex-1 overflow-hidden rounded-full bg-muted"
role="meter"
aria-label="Portfolio overlap"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={overlapPct}
>
<span
data-bar={overlapPct / 100}
className={cn(
"block h-full rounded-full",
overlapPct > 50 ? "bg-warning" : "bg-primary"
)}
style={{ width: 0 }}
/>
</span>
<span className="font-medium tabular-nums text-foreground">{overlapPct}%</span>
</div>
{overlapPct > 50 ? (
<p className="mt-1 text-[10px] leading-4 text-warning">
High overlap — holding both adds little diversification.
</p>
) : null}
</div>
</div>
<p className="mt-3 text-[11px] text-muted-foreground">
<span className="font-medium text-foreground">{winsA >= METRICS.length / 2 ? a.name : b.name}</span>{" "}
leads on {Math.max(winsA, METRICS.length - winsA)} of {METRICS.length} metrics ·
past returns don't guarantee future ones · not advice
</p>
</div>
);
}