Compensation Breakdown

Horizontal waterfall chart for visualising the components of a compensation package — base, bonus, equity, and benefits stacked into a single total. Supports multi-offer comparison for side-by-side evaluation.

Preview

Offer A — Series B Startup

Base Salary
$160k
Target Bonus
$24k
Equity (RSUs)
$80k
Benefits
$18k
Total
$282k

Offer comparison

Present multiple packages side by side in a tabular layout so the user can scan each component and identify where the offers diverge.

ComponentOffer AOffer BΔ
Base Salary$160k$195k+$35k
Target Bonus$24k$39k+$15k
Equity (RSUs)$80k$62.5k$-17.5k
Benefits$18k$22k+$4k
Total$282k$318.5k+$36.5k

Design notes

Bar width is relative to total, not per-row maximumEach bar width is (component ÷ total), not (component ÷ max-component). This preserves the proportional relationship and makes it immediately obvious which component dominates the package.
Use a consistent colour family per offerWhen comparing offers, assign each offer a hue (e.g. purple for Offer A, blue for Offer B) and use tints for each component within that offer. This makes the source of each bar obvious when switching between offers.
Show the total row as a full-width gradientThe total bar always spans 100% of the track. A gradient communicates 'this is a combined value' and distinguishes it from individual component bars.
Animate on entryBars should grow from left to right on mount. A 400–600ms ease-out with slight stagger per row adds clarity to the waterfall metaphor and draws the eye to the final total.

Implementation

CompensationBreakdown.tsx
tsx
interface CompComponent {
  label: string;
  amount: number;
  color: string;
}

interface Offer {
  name: string;
  components: CompComponent[];
}

function WaterfallBar({ comp, total }: { comp: CompComponent; total: number }) {
  const pct = Math.max(4, (comp.amount / total) * 100);
  const fmt = (n: number) => n >= 1000 ? `$${(n / 1000).toFixed(0)}k` : `$${n}`;

  return (
    <div className="flex items-center gap-3">
      <div className="w-28 shrink-0 text-right text-[12px] font-medium
                      text-[rgb(var(--text-secondary))]">
        {comp.label}
      </div>
      <div className="flex-1 h-7 rounded-md bg-[rgb(var(--surface-raised))] overflow-hidden">
        <div
          className="h-full rounded-md flex items-center pl-2.5 transition-all duration-500"
          style={{ width: `${pct}%`, backgroundColor: comp.color, minWidth: "2rem" }}
          role="meter"
          aria-valuenow={comp.amount}
          aria-valuemin={0}
          aria-valuemax={total}
          aria-label={`${comp.label}: ${fmt(comp.amount)}`}
        >
          <span className="text-[11px] font-semibold text-white/90 whitespace-nowrap">
            {fmt(comp.amount)}
          </span>
        </div>
      </div>
    </div>
  );
}

export function CompensationBreakdown({ offer }: { offer: Offer }) {
  const total = offer.components.reduce((s, c) => s + c.amount, 0);
  const fmt   = (n: number) => n >= 1000 ? `$${(n / 1000).toFixed(0)}k` : `$${n}`;

  return (
    <section aria-label={`Compensation breakdown: ${offer.name}`}>
      <h3 className="text-[15px] font-semibold mb-4">{offer.name}</h3>

      <div className="space-y-2">
        {offer.components.map((comp) => (
          <WaterfallBar key={comp.label} comp={comp} total={total} />
        ))}

        {/* Total */}
        <div className="pt-3 mt-3 border-t border-[rgb(var(--border))] flex items-center gap-3">
          <div className="w-28 shrink-0 text-right text-[12px] font-semibold
                          text-[rgb(var(--text-primary))]">
            Total
          </div>
          <div className="flex-1 h-7 rounded-md overflow-hidden">
            <div
              className="h-full w-full rounded-md flex items-center pl-2.5"
              style={{ background: "linear-gradient(90deg, #6366f1, #a78bfa)" }}
              role="status"
              aria-label={`Total compensation: ${fmt(total)}`}
            >
              <span className="text-[12px] font-bold text-white">{fmt(total)}</span>
            </div>
          </div>
        </div>
      </div>
    </section>
  );
}

Take-home calculator

Pair the compensation waterfall with an on-device progressive tax engine so the user sees net take-home alongside gross compensation numbers. All computation runs locally — no network calls, no API keys.

On-device onlyAll 2026 tax bracket data is embedded as static Swift constants. The engine runs synchronously in the calling thread — no async, no server, no cache expiry.
Multi-jurisdictionSupports Canada (federal + all 13 provinces/territories), Australia, United Kingdom, Singapore, and 10 EU countries. Each jurisdiction uses the correct marginal-rate calculation with standard deduction or basic personal amount applied first.
Currency conversionGross salary is accepted in any of the supported currencies (USD, CAD, GBP, EUR, AUD, SGD). The engine converts to the jurisdiction's native currency for tax calculation, then converts net result back to the user's chosen display currency.
Pre-fill from packagePass initialGross and initialCurrency from the top CompensationPackage so the calculator opens with the relevant salary pre-filled. The user can then explore different regions without re-entering their base.
SwiftUI — TaxBreakdownView.swift
struct TaxBreakdownView: View {
    var initialGross: Double? = nil
    var initialCurrency: String? = nil

    @State private var grossText: String = ""
    @State private var country:   String = "CA"
    @State private var region:    String = "ON"
    @State private var currency:  String = "CAD"
    @State private var result:    TaxResult? = nil

    var body: some View {
        VStack(alignment: .leading, spacing: 16) {
            Text("Take-Home Calculator")
                .font(.system(size: 14, weight: .semibold))

            // Pickers: currency / country / region
            inputSection

            if let r = result { resultSection(r) }
        }
        .padding(16)
        .glassCard()
        .onAppear {
            if let g = initialGross    { grossText = String(Int(g)) }
            if let c = initialCurrency { currency = c }
            recalculate()
        }
    }

    private func recalculate() {
        guard let gross = Double(grossText.replacingOccurrences(of: ",", with: "")),
              gross > 0, !region.isEmpty else { result = nil; return }
        result = TaxCalculatorService.calculate(
            gross: gross, currency: currency,
            country: country, region: region
        )
    }
}

// TaxResult carries: effectiveRatePct, netAnnualLocal,
// netMonthlyLocal, netMonthlyCAD, nativeCurrency, detail: [TaxDetailItem]

Accessibility

  • Each bar track is a visual aid only — wrap it in aria-hidden='true' and expose the value as an accessible label on the row container: aria-label='Base Salary: $160k'.
  • The total row should carry role='status' or be included in an aria-live region if the offer can be reconfigured by the user.
  • Colour alone does not distinguish bars in a multi-offer view. Add a legend with shape + colour + label, and ensure bar labels are always visible (never rely on hover tooltips for primary data).
  • For the comparison table, use a proper <table> with <caption> describing what is being compared, and <th scope='col'> for each offer column.