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.
| Component | Offer A | Offer 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 maximum — Each 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 offer — When 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 gradient — The 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 entry — Bars 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 only — All 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-jurisdiction — Supports 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 conversion — Gross 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 package — Pass 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.