Fintech & Payments

Cap Table

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.

Install

npx shadcn@latest add @paragon/cap-table

cap-table.tsx

"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>
  );
}