Revenue Dashboard Hero
A SaaS hero with date and channel filters that update revenue, chart and transactions together.
Payment analytics
Know where revenue comes from.
Track collected payments, compare channels, and follow every number back to a transaction.
Reporting
Revenue overview
Daily collections
Sep 28 – Oct 27, 2026| Date | Revenue (USD) |
|---|---|
| 2026-09-28 | $0.00 |
| 2026-09-29 | $0.00 |
| 2026-09-30 | $0.00 |
| 2026-10-01 | $680.00 |
| 2026-10-02 | $0.00 |
| 2026-10-03 | $1,240.00 |
| 2026-10-04 | $0.00 |
| 2026-10-05 | $420.00 |
| 2026-10-06 | $0.00 |
| 2026-10-07 | $1,860.00 |
| 2026-10-08 | $320.00 |
| 2026-10-09 | $960.00 |
| 2026-10-10 | $0.00 |
| 2026-10-11 | $1,580.00 |
| 2026-10-12 | $740.00 |
| 2026-10-13 | $0.00 |
| 2026-10-14 | $420.00 |
| 2026-10-15 | $2,190.00 |
| 2026-10-16 | $1,150.00 |
| 2026-10-17 | $0.00 |
| 2026-10-18 | $880.00 |
| 2026-10-19 | $1,740.00 |
| 2026-10-20 | $960.00 |
| 2026-10-21 | $1,450.00 |
| 2026-10-22 | $680.00 |
| 2026-10-23 | $2,480.00 |
| 2026-10-24 | $560.00 |
| 2026-10-25 | $1,240.00 |
| 2026-10-26 | $2,950.00 |
| 2026-10-27 | $2,700.00 |
Transactions
22 payments| Customer | Date | Amount |
|---|---|---|
| ParallelSubscriptions | $860.00 | |
| Loom HouseInvoices | $1,840.00 | |
| FieldworkInvoices | $2,950.00 | |
| Orbit LabsSubscriptions | $1,240.00 | |
| WayfinderOne-time | $560.00 | |
| NorthstarInvoices | $2,480.00 | |
| Forma StudioSubscriptions | $680.00 | |
| Common GroundInvoices | $1,450.00 | |
| Loom HouseSubscriptions | $960.00 | |
| NorthstarSubscriptions | $1,740.00 | |
| ParallelOne-time | $880.00 | |
| Common GroundSubscriptions | $1,150.00 | |
| FieldworkInvoices | $2,190.00 | |
| WayfinderSubscriptions | $420.00 | |
| Orbit LabsOne-time | $740.00 | |
| ParallelInvoices | $1,580.00 | |
| Loom HouseSubscriptions | $960.00 | |
| Forma StudioOne-time | $320.00 | |
| FieldworkInvoices | $1,860.00 | |
| WayfinderSubscriptions | $420.00 | |
| Orbit LabsInvoices | $1,240.00 | |
| Forma StudioSubscriptions | $680.00 |
Fictional product and sample transactions.
'use client';
import { useId, useRef, useState } from 'react';
import './revenue-dashboard-hero.css';
export type RevenueDashboardTransaction = {
id: string;
customer: string;
/** UTC calendar date in YYYY-MM-DD format. */
date: string;
/** A nonnegative payment in major currency units; for example, 129.50. */
amount: number;
channel: string;
};
export type RevenueDashboardHeroProps = {
transactions: RevenueDashboardTransaction[];
brand?: string;
title?: string;
description?: string;
actionLabel?: string;
actionHref?: string;
currency?: string;
/** Inclusive range end. Defaults to the latest valid transaction date. */
endDate?: string;
defaultRange?: 7 | 30;
className?: string;
};
type Payment = RevenueDashboardTransaction & { day: number };
const DAY = 86_400_000;
function calendarDay(value: string): number | null {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return null;
const [year, month, date] = value.split('-').map(Number);
const day = Date.UTC(year, month - 1, date);
return Number.isFinite(day) && new Date(day).toISOString().slice(0, 10) === value ? day : null;
}
function shortDate(day: number) {
return new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' }).format(day);
}
function LedgerMark({ className = '' }: { className?: string }) {
return <svg className={className} viewBox="0 0 28 28" fill="none" aria-hidden="true"><path d="M5 5h7v18H5V5Zm11 0h7v7h-7V5Zm0 11h7v7h-7v-7Z" fill="currentColor"/></svg>;
}
/** A SaaS hero with one filtered source of truth for its metrics, chart and payments. */
export function RevenueDashboardHero({
transactions,
brand = 'Ledger',
title = 'Know where revenue comes from.',
description = 'Track collected payments, compare channels, and follow every number back to a transaction.',
actionLabel = 'Explore the dashboard',
actionHref,
currency = 'USD',
endDate,
defaultRange = 30,
className = '',
}: RevenueDashboardHeroProps) {
const uid = useId();
const dashboardRef = useRef<HTMLDivElement>(null);
const [range, setRange] = useState<7 | 30>(defaultRange === 7 ? 7 : 30);
const [selectedChannel, setSelectedChannel] = useState<string | null>(null);
const seenIds = new Set<string>();
const payments: Payment[] = [];
for (const transaction of transactions) {
const day = calendarDay(transaction.date);
if (day === null || !Number.isFinite(transaction.amount) || transaction.amount < 0 || !transaction.id || seenIds.has(transaction.id)) continue;
seenIds.add(transaction.id);
payments.push({ ...transaction, amount: Math.round(transaction.amount * 100) / 100, channel: transaction.channel.trim() || 'Other', day });
}
const channels = [...new Set(payments.map(payment => payment.channel))].sort();
const channel = selectedChannel !== null && channels.includes(selectedChannel) ? selectedChannel : null;
const latestDay = payments.reduce<number | null>((latest, payment) => latest === null ? payment.day : Math.max(latest, payment.day), null);
const rangeEnd = (endDate ? calendarDay(endDate) : null) ?? latestDay;
const rangeStart = rangeEnd === null ? null : rangeEnd - (range - 1) * DAY;
const filtered = payments.filter(payment => rangeStart !== null && rangeEnd !== null && payment.day >= rangeStart && payment.day <= rangeEnd && (channel === null || payment.channel === channel)).sort((a, b) => b.day - a.day || a.id.localeCompare(b.id));
const total = filtered.reduce((sum, payment) => sum + Math.round(payment.amount * 100), 0) / 100;
const average = filtered.length ? total / filtered.length : 0;
const daily = rangeStart === null ? [] : Array.from({ length: range }, (_, index) => {
const day = rangeStart + index * DAY;
return { day, amount: filtered.filter(payment => payment.day === day).reduce((sum, payment) => sum + Math.round(payment.amount * 100), 0) / 100 };
});
let currencyCode = currency;
try { new Intl.NumberFormat('en-US', { style: 'currency', currency: currencyCode }); } catch { currencyCode = 'USD'; }
const money = (value: number, precise = false) => new Intl.NumberFormat('en-US', { style: 'currency', currency: currencyCode, minimumFractionDigits: precise ? 2 : 0, maximumFractionDigits: 2 }).format(value);
const compactMoney = (value: number) => new Intl.NumberFormat('en-US', { style: 'currency', currency: currencyCode, notation: 'compact', maximumFractionDigits: 1 }).format(value);
const chartMax = Math.max(1, ...daily.map(day => day.amount));
const chartWidth = 600;
const chartHeight = 130;
const slotWidth = chartWidth / Math.max(daily.length, 1);
const dateRange = rangeStart !== null && rangeEnd !== null ? `${shortDate(rangeStart)} – ${shortDate(rangeEnd)}, ${new Date(rangeEnd).getUTCFullYear()}` : 'No transaction dates';
function exploreDashboard() {
dashboardRef.current?.focus({ preventScroll: true });
dashboardRef.current?.scrollIntoView({ block: 'nearest', behavior: 'auto' });
}
return <section className={`ac-revenue-hero ${className}`.trim()} aria-labelledby={`${uid}-title`}>
<div className="ac-revenue-hero-shell">
<header className="ac-revenue-masthead"><span className="ac-revenue-brand"><LedgerMark/>{brand}</span><span className="ac-revenue-masthead-label">Revenue analytics</span></header>
<div className="ac-revenue-layout">
<div className="ac-revenue-introduction">
<p className="ac-revenue-eyebrow"><span aria-hidden="true"/>Payment analytics</p>
<h2 id={`${uid}-title`}>{title}</h2>
{description && <p className="ac-revenue-description">{description}</p>}
{actionHref ? <a className="ac-revenue-action" href={actionHref}>{actionLabel}<span aria-hidden="true">↗</span></a> : <button type="button" className="ac-revenue-action" onClick={exploreDashboard}>{actionLabel}<span aria-hidden="true">↗</span></button>}
</div>
<div className="ac-revenue-product-field">
<div className="ac-revenue-field-label"><span>Revenue workspace</span><span aria-hidden="true">↙</span></div>
<div className="ac-revenue-dashboard" ref={dashboardRef} tabIndex={-1} role="region" aria-label={`${brand} interactive revenue dashboard`}>
<div className="ac-revenue-app-bar"><span><LedgerMark/>{brand}<b> / </b>Overview</span><span className="ac-revenue-preview-label">Product preview</span></div>
<div className="ac-revenue-app-content">
<div className="ac-revenue-app-heading"><div><p>Reporting</p><h3>Revenue overview</h3></div><span className="ac-revenue-currency">{currencyCode}</span></div>
<div className="ac-revenue-controls">
<div className="ac-revenue-range" role="group" aria-label="Revenue date range">{([7, 30] as const).map(days => <button key={days} type="button" aria-pressed={range === days} onClick={() => setRange(days)}>{days} days</button>)}</div>
<label className="ac-revenue-channel"><span className="ac-revenue-sr">Payment channel</span><select value={channel ?? ''} onChange={event => setSelectedChannel(event.target.value || null)}><option value="">All channels</option>{channels.map(value => <option key={value} value={value}>{value}</option>)}</select><svg aria-hidden="true" viewBox="0 0 12 12" fill="none"><path d="m3 4.5 3 3 3-3" stroke="currentColor" strokeWidth="1.3"/></svg></label>
</div>
<div className="ac-revenue-metrics" aria-live="polite" aria-atomic="true"><div className="ac-revenue-total"><span>Collected revenue</span><strong>{money(total)}</strong></div><div><span>Payments</span><strong>{filtered.length}</strong></div><div><span>Average payment</span><strong>{money(average)}</strong></div></div>
<div className="ac-revenue-chart-heading"><h4>Daily collections</h4><span>{dateRange}</span></div>
<div className="ac-revenue-chart-wrap">
<div className="ac-revenue-y-axis" aria-hidden="true"><span>{compactMoney(chartMax)}</span><span>{compactMoney(chartMax / 2)}</span><span>{money(0)}</span></div>
<div className="ac-revenue-chart"><svg viewBox={`0 0 ${chartWidth} ${chartHeight}`} preserveAspectRatio="none" role="img" aria-labelledby={`${uid}-chart-title ${uid}-chart-description`}>
<title id={`${uid}-chart-title`}>{`Daily collected revenue in ${currencyCode}`}</title><desc id={`${uid}-chart-description`}>{`${money(total)} from ${filtered.length} payments. ${dateRange}. ${channel ?? 'All channels'}. Daily values follow in a table.`}</desc>
{[0, 0.5, 1].map(position => <line key={position} x1="0" x2={chartWidth} y1={position * (chartHeight - 4) + 2} y2={position * (chartHeight - 4) + 2} className="ac-revenue-grid-line"/>)}
{daily.map((day, index) => <rect key={day.day} x={index * slotWidth + slotWidth * .17} y={chartHeight - 2 - day.amount / chartMax * (chartHeight - 4)} width={slotWidth * .66} height={day.amount / chartMax * (chartHeight - 4)} rx={Math.min(3, slotWidth * .13)} className="ac-revenue-chart-bar"><title>{`${shortDate(day.day)}: ${money(day.amount, true)}`}</title></rect>)}
</svg><div className="ac-revenue-x-axis" aria-hidden="true">{daily.length ? <><span>{shortDate(daily[0].day)}</span><span>{shortDate(daily[Math.floor((daily.length - 1) / 2)].day)}</span><span>{shortDate(daily[daily.length - 1].day)}</span></> : <span>No dates available</span>}</div></div>
</div>
<div className="ac-revenue-sr"><table><caption>Daily collected revenue: {dateRange}, {channel ?? 'all channels'}</caption><thead><tr><th scope="col">Date</th><th scope="col">Revenue ({currencyCode})</th></tr></thead><tbody>{daily.map(day => <tr key={day.day}><th scope="row">{new Date(day.day).toISOString().slice(0, 10)}</th><td>{money(day.amount, true)}</td></tr>)}</tbody></table></div>
<div className="ac-revenue-transactions-heading"><h4>Transactions</h4><span>{filtered.length} {filtered.length === 1 ? 'payment' : 'payments'}</span></div>
<div className="ac-revenue-transaction-scroll" tabIndex={filtered.length > 3 ? 0 : undefined} role="region" aria-label="Filtered payments, scroll for all transactions">
<table className="ac-revenue-transactions"><caption className="ac-revenue-sr">Payments included in the revenue total</caption><thead><tr><th scope="col">Customer</th><th scope="col">Date</th><th scope="col">Amount</th></tr></thead><tbody>{filtered.map(payment => <tr key={payment.id}><th scope="row"><span className="ac-revenue-customer-avatar" aria-hidden="true">{payment.customer.trim().slice(0, 1) || '·'}</span><span>{payment.customer || 'Unnamed customer'}<small>{payment.channel}</small></span></th><td><time dateTime={payment.date}>{shortDate(payment.day)}</time></td><td>{money(payment.amount, true)}</td></tr>)}</tbody></table>
{filtered.length === 0 && <p className="ac-revenue-empty">No payments in this date range{channel ? ` for ${channel}` : ''}.</p>}
</div>
</div>
</div>
</div>
</div>
</div>
</section>;
}
Installation
Run this in your shadcn-configured React project. The component and stylesheet are added together.
npx shadcn@latest add http://localhost:5173/r/revenue-dashboard-hero.jsonThis command uses the current site address. For localhost, keep this preview running on the same computer. Files follow your configured components alias.
Usage
Import the component and make the details your own.
import { RevenueDashboardHero } from '@/components/anticraft/RevenueDashboardHero';
export function Example() {
return <RevenueDashboardHero
brand="Ledger"
title="Know where revenue comes from."
description="Track collected payments, compare channels, and follow every number back to a transaction."
transactions={[
{ id: 'pay-1', customer: 'Forma Studio', date: '2026-10-22', amount: 680, channel: 'Subscriptions' },
{ id: 'pay-2', customer: 'Orbit Labs', date: '2026-10-25', amount: 1240, channel: 'Invoices' },
{ id: 'pay-3', customer: 'Fieldwork', date: '2026-10-27', amount: 2950, channel: 'Invoices' },
]}
/>;
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
transactions | RevenueDashboardTransaction[] | Required | Payments with a unique id, customer, date (YYYY-MM-DD), nonnegative amount in major currency units, and channel. All metrics, daily bars and table rows use this same data. Invalid dates, negative/nonfinite amounts and repeated ids are ignored; blank channels become Other. Amounts are rounded to cents. Empty data shows zero totals and a clear empty state. |
brand | string | 'Ledger' | Brand label in the masthead and interactive product preview. |
title | string | 'Know where revenue comes from.' | Visible h2 and section label. Keep it short; adjust the copied heading level if it is the page’s main h1. |
description | string | 'Track collected payments, compare channels, and follow every number back to a transaction.' | Supporting sentence. An empty string hides it. |
actionLabel | string | 'Explore the dashboard' | Primary action text. The default action moves keyboard focus to the dashboard and scrolls it into view when needed. |
actionHref | string | undefined | Supply a real destination to render the primary action as an ordinary link. Without a destination it is a button that focuses the preview. |
currency | string | 'USD' | ISO currency code used for every amount; all transactions must use the same currency. Invalid codes fall back to USD. Formatting uses en-US for stable server/client output. |
endDate | string | Latest valid transaction date | Inclusive UTC calendar date in YYYY-MM-DD format. Both ranges end here; no browser clock is used. Invalid values fall back to the newest transaction. Seven days includes the end date and the preceding six days. |
defaultRange | 7 | 30 | 30 | Initial date range. Native pressed buttons switch the range. A removed channel selection falls back to All channels when transaction props change. |
className | string | '' | Additional classes. Customize --ac-revenue-ink, --ac-revenue-text, --ac-revenue-muted, --ac-revenue-accent, --ac-revenue-chart and --ac-revenue-field. Container queries stack the introduction above the dashboard below 1000px component width. |
Motion & accessibility
Range buttons and the native channel select update the total, payment count, average, daily chart and transaction table in one render. A live region reports the three totals. The chart includes a daily-value table for assistive technology; the transaction list is keyboard scrollable. No automatic animation runs. Reduced motion removes action and filter transitions.
Use semantic content and meaningful labels in your composition. Check contrast and keyboard behavior with your own styles and content.
Inspiration
SpendHub’s clear introduction and substantial product window informed the composition. Ledger uses original branding, copy, interface and sample data, with working linked filters rather than a screenshot.
Visit SpendHub by Excited on DribbbleThe linked site is the design or content reference. See the repository for demo artwork credits.