An ownership breakdown table beside a donut of equity by holder whose segments sweep in on view, with hover linking a table row to its donut slice.
npx shadcn@latest add @paragon/cap-table"use client";
import * as React from "react";
import { useInView, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
const HOLDER_COLORS = [
"oklch(0.585 0.17 260)",
"oklch(0.68 0.13 165)",
"oklch(0.76 0.13 85)",
"oklch(0.62 0.18 305)",
"oklch(0.66 0.16 25)",
"oklch(0.55 0.02 260)",
];
export interface CapTableHolder {
name: string;
/** Share class, e.g. "Common", "Series A". */
class: string;
shares: number;
color?: string;
}
export interface CapTableProps extends React.ComponentProps<"div"> {
holders: CapTableHolder[];
/** Total authorized shares; defaults to the sum of holdings (fully diluted). */
totalShares?: number;
static?: boolean;
}
const R = 52;
const STROKE = 20;
const CIRC = 2 * Math.PI * R;
/**
* An ownership breakdown: a donut of equity by holder beside a table of
* shares and fully-diluted percentages. Each donut segment sweeps from zero
* to its share on first view via stroke-dashoffset (transform-free, so the
* ring never reflows), staggered per holder. Hovering a table row lifts the
* matching segment. Percentages are exact and tabular; reduced motion draws
* the final state at once.
*/
export function CapTable({
holders,
totalShares,
static: isStatic = false,
className,
...props
}: CapTableProps) {
const ref = React.useRef<HTMLDivElement>(null);
const reduced = useReducedMotion();
const inView = useInView(ref, { once: true, margin: "0px 0px -48px 0px" });
const [active, setActive] = React.useState<number | null>(null);
const animate = !isStatic && !reduced;
const drawn = !animate || inView;
const total =
totalShares ?? (holders.reduce((sum, h) => sum + h.shares, 0) || 1);
const fmt = new Intl.NumberFormat("en-US");
// Precompute segment offsets around the ring.
let acc = 0;
const segments = holders.map((h, i) => {
const frac = h.shares / total;
const seg = { frac, start: acc, color: h.color ?? HOLDER_COLORS[i % HOLDER_COLORS.length] };
acc += frac;
return seg;
});
return (
<div
ref={ref}
className={cn(
"flex w-full max-w-lg flex-col items-center gap-6 rounded-xl bg-card p-5 shadow-border sm:flex-row",
className,
)}
{...props}
>
<div className="relative shrink-0">
<svg viewBox="0 0 128 128" className="size-36 -rotate-90">
<circle
cx="64"
cy="64"
r={R}
fill="none"
stroke="currentColor"
strokeOpacity={0.08}
strokeWidth={STROKE}
/>
{segments.map((s, i) => {
const length = drawn ? s.frac * CIRC : 0;
const isActive = active === i;
return (
<circle
key={i}
cx="64"
cy="64"
r={R}
fill="none"
stroke={s.color}
strokeWidth={isActive ? STROKE + 4 : STROKE}
strokeDasharray={`${length} ${CIRC - length}`}
strokeDashoffset={-s.start * CIRC}
style={{
transition: animate
? `stroke-dasharray 600ms var(--ease-out) ${i * 90}ms, stroke-width 150ms var(--ease-out)`
: "stroke-width 150ms var(--ease-out)",
}}
/>
);
})}
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="text-[11px] text-muted-foreground">
Fully diluted
</span>
<span className="text-lg font-semibold tabular-nums">
{fmt.format(total)}
</span>
</div>
</div>
<div className="min-w-0 flex-1">
<table className="w-full text-sm">
<thead>
<tr className="text-[11px] uppercase tracking-wide text-muted-foreground">
<th className="pb-2 text-left font-medium">Holder</th>
<th className="pb-2 text-right font-medium">Shares</th>
<th className="pb-2 text-right font-medium">%</th>
</tr>
</thead>
<tbody>
{holders.map((h, i) => {
const frac = h.shares / total;
const color = h.color ?? HOLDER_COLORS[i % HOLDER_COLORS.length];
return (
<tr
key={`${h.name}-${i}`}
onMouseEnter={() => setActive(i)}
onMouseLeave={() => setActive(null)}
className={cn(
"border-t transition-colors duration-150 ease-out",
active === i && "bg-accent/50",
)}
>
<td className="py-2">
<div className="flex items-center gap-2">
<span
className="size-2.5 shrink-0 rounded-sm"
style={{ background: color }}
aria-hidden
/>
<div className="min-w-0">
<p className="truncate font-medium">{h.name}</p>
<p className="truncate text-[12px] text-muted-foreground">
{h.class}
</p>
</div>
</div>
</td>
<td className="py-2 text-right tabular-nums">
{fmt.format(h.shares)}
</td>
<td className="py-2 text-right font-medium tabular-nums">
{(frac * 100).toFixed(1)}%
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
}