# Portfolio 1: investments overview

> An investment portfolio: total value and its change over a week, month or year, a line you scrub by pointer or arrow keys, an allocation ring, and sortable holdings with price, share, day change and a small trend line.

A shadcn/ui block from the [@ballmac](https://ui.ballmac.com/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/ballmac/portfolio-1).

- Page, with a live preview: https://shadcn.exchange/r/ballmac/portfolio-1
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/ballmac/portfolio-1-light-2d7ed7e5d5caa374.png
- Registry item: https://ui.ballmac.com/r/portfolio-1.json
- Categories: blocks, portfolio
- Listed: 2026-10-18, source last changed 2026-10-18

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/ballmac/portfolio-1.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `lucide-react`
- Registry items: `utils`, `https://ui.ballmac.com/r/segmented-control.json`, `https://ui.ballmac.com/r/skeleton.json`, `https://ui.ballmac.com/r/state-view.json`

## Files

### `registry/ballmac/components/blocks/portfolio-1/portfolio-1.tsx`

```tsx
// Ballmac UI: Portfolio 1. https://ui.ballmac.com/blocks/portfolio-1
"use client"

import * as React from "react"
import { ArrowDown, ArrowUp, ArrowUpDown, Briefcase, TrendingDown, TrendingUp } from "lucide-react"

import { SegmentedControl, SegmentedControlItem } from "@/components/ballmac/segmented-control"
import { Skeleton } from "@/components/ballmac/skeleton"
import { frameProps, StateView, type StateProps } from "@/components/ballmac/state-view"
import { cn } from "@/lib/utils"

type Portfolio1Holding = {
  id: string
  symbol: string
  name: string
  /** The group it belongs to, such as "Stocks" or "Bonds". */
  kind: string
  shares: number
  price: number
  /** Change today, as a percent. */
  dayPct: number
  /** Recent prices, oldest first, for the small line. */
  spark: number[]
}

type Portfolio1Range = "1W" | "1M" | "1Y"

type Portfolio1Props = Omit<React.ComponentProps<"section">, "title"> &
  StateProps & {
    holdings?: Portfolio1Holding[]
    /** The value of the whole portfolio over time for each range, oldest first. */
    series?: Partial<Record<Portfolio1Range, number[]>>
    /** "Past week", "Past month", "Past year" labels are fixed; this is the first date label per range. */
    title?: string
    currency?: string
    locale?: string
    defaultRange?: Portfolio1Range
  }

const wave = (n: number, start: number, end: number, amp: number, phase: number) => Array.from({ length: n }, (_, i) => Math.round((start + ((end - start) * i) / (n - 1) + Math.sin(i * 0.9 + phase) * amp * (1 - i / (n * 1.6))) * 100) / 100)

const sample: Portfolio1Holding[] = [
  { id: "h1", symbol: "VTI", name: "Total US Market ETF", kind: "Stocks", shares: 210, price: 284.12, dayPct: 0.82, spark: wave(24, 262, 284, 3, 0) },
  { id: "h2", symbol: "VXUS", name: "Total International ETF", kind: "Stocks", shares: 340, price: 64.9, dayPct: -0.34, spark: wave(24, 62, 64.9, 1, 1) },
  { id: "h3", symbol: "BND", name: "Total Bond Market ETF", kind: "Bonds", shares: 520, price: 72.45, dayPct: 0.11, spark: wave(24, 71.5, 72.45, 0.3, 2) },
  { id: "h4", symbol: "AAPL", name: "Apple Inc.", kind: "Stocks", shares: 60, price: 231.7, dayPct: 1.46, spark: wave(24, 205, 231.7, 5, 0.5) },
  { id: "h5", symbol: "VNQ", name: "Real Estate ETF", kind: "Real estate", shares: 150, price: 91.38, dayPct: -0.62, spark: wave(24, 94, 91.38, 1.5, 2.5) },
  { id: "h6", symbol: "GLD", name: "Gold Trust", kind: "Commodities", shares: 45, price: 248.2, dayPct: 0.27, spark: wave(24, 236, 248.2, 2, 3) },
  { id: "h7", symbol: "CASH", name: "Money market", kind: "Cash", shares: 18400, price: 1, dayPct: 0, spark: wave(24, 1, 1, 0, 0) },
]

const sampleSeries: Record<Portfolio1Range, number[]> = {
  "1W": wave(7, 171900, 176583, 900, 0),
  "1M": wave(30, 164000, 176583, 2000, 1),
  "1Y": wave(52, 131500, 176583, 5200, 2),
}

const tones = ["var(--chart-1)", "var(--chart-2)", "var(--chart-3)", "var(--chart-4)", "var(--chart-5)", "var(--muted-foreground)"]

function Skel() {
  return (
    <div aria-hidden="true" className="grid gap-6">
      <Skeleton className="h-72 rounded-3xl" />
      <Skeleton className="h-80 rounded-2xl" />
    </div>
  )
}

function Spark({ data, up }: { data: number[]; up: boolean }) {
  if (data.length < 2 || Math.max(...data) === Math.min(...data)) return <span aria-hidden="true" className="bg-border block h-px w-20" />
  const lo = Math.min(...data)
  const hi = Math.max(...data)
  const pts = data.map((v, i) => `${(i / (data.length - 1)) * 80},${22 - ((v - lo) / (hi - lo)) * 20}`).join(" ")
  return (
    <svg aria-hidden="true" viewBox="0 0 80 24" className="h-6 w-20">
      <polyline points={pts} fill="none" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" className={up ? "stroke-chart-2" : "stroke-destructive"} />
    </svg>
  )
}

/**
 * An investment portfolio: the total value with how much it moved over a week, month or year, a line you can scrub with the pointer or arrow keys to read any
 * day's value, how the money is split as a ring, and every holding with its price, share of the portfolio, day change and a small line, sortable by column.
 * Everything is worked out from the holdings and the series you pass; gains and losses are written with a sign and an arrow, not only colored.
 */
function Portfolio1({ holdings = sample, series = sampleSeries, title = "Portfolio", currency = "USD", locale = "en", defaultRange = "1M", state = "ready", onRetry, emptyState, errorState, className, ...props }: Portfolio1Props) {
  const id = React.useId()
  const list = state === "empty" ? [] : holdings
  const [range, setRange] = React.useState<Portfolio1Range>(defaultRange)
  const [at, setAt] = React.useState<number | null>(null)
  const [kind, setKind] = React.useState<string | null>(null)
  const [sort, setSort] = React.useState<{ key: "symbol" | "value" | "day"; dir: 1 | -1 }>({ key: "value", dir: -1 })
  const money = (n: number, d = 0) => new Intl.NumberFormat(locale, { style: "currency", currency, maximumFractionDigits: d, minimumFractionDigits: d }).format(n)
  const signed = (n: number, d = 2) => `${n >= 0 ? "+" : "−"}${Math.abs(n).toFixed(d)}%`

  const data = series[range] ?? series["1M"] ?? []
  const total = list.reduce((n, h) => n + h.shares * h.price, 0)
  const first = data[0] ?? total
  const last = data.at(-1) ?? total
  const gain = last - first
  const gainPct = first ? (gain / first) * 100 : 0
  const shown = at === null ? last : (data[at] ?? last)
  const shownGain = shown - first
  const dayGain = list.reduce((n, h) => n + h.shares * h.price * (h.dayPct / (100 + h.dayPct)), 0)
  const kinds = [...new Set(list.map((h) => h.kind))]
  const byKind = kinds.map((k, i) => ({ k, v: list.filter((h) => h.kind === k).reduce((n, h) => n + h.shares * h.price, 0), c: tones[i % tones.length]! })).sort((a, b) => b.v - a.v)
  const rows = list
    .filter((h) => !kind || h.kind === kind)
    .map((h) => ({ h, value: h.shares * h.price }))
    .sort((a, b) => (sort.key === "symbol" ? a.h.symbol.localeCompare(b.h.symbol) : sort.key === "day" ? a.h.dayPct - b.h.dayPct : a.value - b.value) * sort.dir)

  // The area chart: a smooth line scaled to its own range, with a marker for the day being read.
  const W = 600
  const H = 180
  const lo = Math.min(...data)
  const hi = Math.max(...data)
  const x = (i: number) => (data.length > 1 ? (i / (data.length - 1)) * W : 0)
  const y = (v: number) => 12 + (hi === lo ? 0.5 : 1 - (v - lo) / (hi - lo)) * (H - 24)
  const line = data.map((v, i) => `${i ? "L" : "M"}${x(i).toFixed(1)} ${y(v).toFixed(1)}`).join(" ")
  const area = `${line} L${W} ${H} L0 ${H} Z`
  const up = gain >= 0

  // The ring: each group is an arc whose length is its share.
  const R = 52
  const C = 2 * Math.PI * R
  let acc = 0

  const Th = ({ k, children, end }: { k: "symbol" | "value" | "day"; children: React.ReactNode; end?: boolean }) => (
    <th scope="col" aria-sort={sort.key === k ? (sort.dir === 1 ? "ascending" : "descending") : "none"} className={cn("px-4 py-3 font-medium", end ? "text-end" : "text-start")}>
      <button type="button" onClick={() => setSort((s) => (s.key === k ? { key: k, dir: (s.dir * -1) as 1 | -1 } : { key: k, dir: k === "symbol" ? 1 : -1 }))} className="focus-visible:ring-ring/50 inline-flex items-center gap-1 rounded-sm outline-none focus-visible:ring-[3px]">
        {children}
        {sort.key === k ? sort.dir === 1 ? <ArrowUp aria-hidden="true" className="size-3.5" /> : <ArrowDown aria-hidden="true" className="size-3.5" /> : <ArrowUpDown aria-hidden="true" className="size-3.5 opacity-40" />}
      </button>
    </th>
  )

  const content = !list.length ? (
    <div className="bg-card rounded-2xl border px-6 py-16 text-center">
      <span aria-hidden="true" className="bg-muted mx-auto flex size-12 items-center justify-center rounded-full">
        <Briefcase className="size-6" />
      </span>
      <h2 className="mt-5 text-lg font-semibold tracking-tight">{emptyState?.title ?? "No holdings yet"}</h2>
      <p className="text-muted-foreground mx-auto mt-1.5 max-w-xs text-sm text-pretty">{emptyState?.description ?? "Once you add or connect an account, your holdings and how they have done appear here."}</p>
      {emptyState?.action}
    </div>
  ) : (
    <div className="grid grid-cols-1 gap-6">
      <div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_17rem]">
        <div className="bg-card min-w-0 rounded-3xl border p-5 sm:p-7">
          <div className="flex flex-wrap items-start justify-between gap-4">
            <div>
              <p className="text-muted-foreground text-sm">{at === null ? "Portfolio value" : `Value at point ${at + 1} of ${data.length}`}</p>
              <p className="text-4xl font-semibold tracking-[-0.045em] tabular-nums sm:text-5xl">{money(shown, 0)}</p>
              <p className="mt-2 flex items-center gap-2 text-sm font-medium tabular-nums">
                {(at === null ? up : shownGain >= 0) ? <TrendingUp aria-hidden="true" className="text-chart-2 size-4" /> : <TrendingDown aria-hidden="true" className="text-destructive size-4" />}
                {at === null ? `${gain >= 0 ? "+" : "−"}${money(Math.abs(gain))} (${signed(gainPct)})` : `${shownGain >= 0 ? "+" : "−"}${money(Math.abs(shownGain))} since the start`}
                <span className="text-muted-foreground font-normal">{range === "1W" ? "this week" : range === "1M" ? "this month" : "this year"}</span>
              </p>
              <p className="text-muted-foreground mt-1 text-sm tabular-nums">
                Today {dayGain >= 0 ? "+" : "−"}
                {money(Math.abs(dayGain))}
              </p>
            </div>
            <SegmentedControl value={range} onValueChange={(v) => { setRange(v as Portfolio1Range); setAt(null) }} aria-label="Time range" size="sm">
              {(["1W", "1M", "1Y"] as const).map((r) => (
                <SegmentedControlItem key={r} value={r}>
                  {r}
                </SegmentedControlItem>
              ))}
            </SegmentedControl>
          </div>
          <div
            dir="ltr"
            role="slider"
            tabIndex={0}
            aria-label="Portfolio value over time"
            aria-valuemin={0}
            aria-valuemax={data.length - 1}
            aria-valuenow={at ?? data.length - 1}
            aria-valuetext={`${money(shown, 0)}, point ${(at ?? data.length - 1) + 1} of ${data.length}`}
            onKeyDown={(e) => {
              const cur = at ?? data.length - 1
              const n = e.key === "ArrowLeft" ? cur - 1 : e.key === "ArrowRight" ? cur + 1 : e.key === "Home" ? 0 : e.key === "End" ? data.length - 1 : null
              if (n === null) return
              e.preventDefault()
              setAt(Math.max(0, Math.min(data.length - 1, n)))
            }}
            onBlur={() => setAt(null)}
            onPointerLeave={() => setAt(null)}
            onPointerMove={(e) => { const r = e.currentTarget.getBoundingClientRect(); setAt(Math.max(0, Math.min(data.length - 1, Math.round(((e.clientX - r.left) / r.width) * (data.length - 1))))) }}
            className="focus-visible:ring-ring/50 relative mt-6 cursor-crosshair rounded-xl outline-none focus-visible:ring-[3px]"
          >
            <svg viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" className="block h-44 w-full" aria-hidden="true">
              <defs>
                <linearGradient id={`${id}-g`} x1="0" x2="0" y1="0" y2="1">
                  <stop offset="0" stopColor={up ? "var(--chart-2)" : "var(--destructive)"} stopOpacity="0.28" />
                  <stop offset="1" stopColor={up ? "var(--chart-2)" : "var(--destructive)"} stopOpacity="0" />
                </linearGradient>
              </defs>
              <path d={area} fill={`url(#${id}-g)`} />
              <path d={line} fill="none" strokeWidth="2.4" strokeLinejoin="round" strokeLinecap="round" vectorEffect="non-scaling-stroke" stroke={up ? "var(--chart-2)" : "var(--destructive)"} />
            </svg>
            {at !== null ? (
              <>
                <span aria-hidden="true" className="bg-foreground/30 pointer-events-none absolute inset-y-0 w-px" style={{ left: `${(x(at) / W) * 100}%` }} />
                <span aria-hidden="true" className="border-background bg-foreground pointer-events-none absolute size-3.5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2" style={{ left: `${(x(at) / W) * 100}%`, top: `${(y(data[at]!) / H) * 100}%` }} />
              </>
            ) : null}
          </div>
        </div>

        <div className="bg-card rounded-3xl border p-5 sm:p-6">
          <h2 className="text-sm font-semibold">Allocation</h2>
          <div className="relative mx-auto mt-4 size-40">
            <svg role="img" aria-label={`Allocation: ${byKind.map((b) => `${b.k} ${((b.v / total) * 100).toFixed(0)}%`).join(", ")}`} viewBox="0 0 120 120" className="size-full -rotate-90 rtl:rotate-90">
              <circle cx="60" cy="60" r={R} fill="none" strokeWidth="14" className="stroke-muted" />
              {byKind.map((b) => {
                const len = (b.v / total) * C
                const el = <circle key={b.k} cx="60" cy="60" r={R} fill="none" strokeWidth="14" strokeDasharray={`${Math.max(0, len - 2)} ${C - Math.max(0, len - 2)}`} strokeDashoffset={-acc} style={{ stroke: b.c }} />
                acc += len
                return el
              })}
            </svg>
            <span className="absolute inset-0 flex flex-col items-center justify-center text-center">
              <span className="text-muted-foreground text-xs">{list.length} holdings</span>
              <span className="text-lg font-semibold tabular-nums">{money(total)}</span>
            </span>
          </div>
          <ul className="mt-5 grid gap-2 text-sm">
            {byKind.map((b) => (
              <li key={b.k} className="flex items-center justify-between gap-3">
                <span className="flex items-center gap-2">
                  <span aria-hidden="true" className="size-2.5 rounded-full" style={{ background: b.c }} />
                  {b.k}
                </span>
                <span className="tabular-nums">{((b.v / total) * 100).toFixed(1)}%</span>
              </li>
            ))}
          </ul>
        </div>
      </div>

      <div className="bg-card min-w-0 rounded-2xl border">
        <div className="flex flex-wrap items-center justify-between gap-3 border-b px-5 py-3.5">
          <h2 className="font-semibold tracking-tight">Holdings</h2>
          <div role="group" aria-label="Filter by type" className="flex flex-wrap gap-2">
            {[null, ...kinds].map((k) => (
              <button key={k ?? "all"} type="button" aria-pressed={kind === k} onClick={() => setKind(k)} className={cn("focus-visible:ring-ring/50 rounded-full border px-3 py-1 text-sm font-medium outline-none transition-colors focus-visible:ring-[3px] motion-reduce:transition-none", kind === k ? "bg-foreground text-background border-foreground" : "hover:bg-accent")}>
                {k ?? "All"}
              </button>
            ))}
          </div>
        </div>
        <div role="region" tabIndex={0} aria-label="Holdings table" className="focus-visible:ring-ring/50 overflow-x-auto outline-none focus-visible:ring-[3px] focus-visible:ring-inset">
          <table className="w-full min-w-[40rem] text-sm">
            <caption className="sr-only">Holdings with value, share of the portfolio and change today</caption>
            <thead className="text-muted-foreground text-xs">
              <tr>
                <Th k="symbol">Holding</Th>
                <th scope="col" className="px-4 py-3 text-end font-medium">Price</th>
                <Th k="value" end>
                  Value
                </Th>
                <th scope="col" className="px-4 py-3 text-end font-medium">Share</th>
                <Th k="day" end>
                  Today
                </Th>
                <th scope="col" className="px-4 py-3 text-end font-medium">Trend</th>
              </tr>
            </thead>
            <tbody className="divide-y border-t">
              {rows.map(({ h, value }) => (
                <tr key={h.id}>
                  <th scope="row" className="px-4 py-3 text-start font-normal">
                    <span className="block font-semibold">{h.symbol}</span>
                    <span className="text-muted-foreground block text-xs">
                      {h.name} · {new Intl.NumberFormat(locale).format(h.shares)} {h.shares === 1 ? "share" : "shares"}
                    </span>
                  </th>
                  <td className="px-4 py-3 text-end tabular-nums">{money(h.price, 2)}</td>
                  <td className="px-4 py-3 text-end font-medium tabular-nums">{money(value)}</td>
                  <td className="px-4 py-3 text-end tabular-nums">{((value / total) * 100).toFixed(1)}%</td>
                  <td className="px-4 py-3 text-end tabular-nums">
                    <span className="inline-flex items-center gap-1 font-medium">
                      {h.dayPct > 0 ? <ArrowUp aria-hidden="true" className="text-chart-2 size-3.5" /> : h.dayPct < 0 ? <ArrowDown aria-hidden="true" className="text-destructive size-3.5" /> : null}
                      {signed(h.dayPct)}
                    </span>
                  </td>
                  <td className="px-4 py-3">
                    <span className="ms-auto block w-fit">
                      <Spark data={h.spark} up={h.spark.at(-1)! >= h.spark[0]!} />
                    </span>
                  </td>
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      </div>
    </div>
  )

  return (
    <section data-slot="portfolio-1" data-state={state} aria-labelledby={`${id}-title`} className={cn("mx-auto w-full max-w-6xl px-4 py-10 sm:px-6", className)} {...frameProps(props)}>
      <h1 id={`${id}-title`} className="mb-6 text-3xl font-semibold tracking-[-0.035em] text-balance">
        {title}
      </h1>
      <StateView state={state === "loading" || state === "error" ? state : "ready"} onRetry={onRetry} label="Loading the portfolio" loading={<Skel />} error={{ title: errorState?.title ?? "The portfolio could not be loaded", description: errorState?.description ?? "Your holdings are safe. Check your connection and try again.", ...errorState }}>
        {content}
      </StateView>
    </section>
  )
}

export { Portfolio1, type Portfolio1Props, type Portfolio1Holding, type Portfolio1Range }
```
