A lightweight TypeScript + React component kit for building accounting and bookkeeping UIs. It ships the fiddly, presentation-heavy pieces that every ledger, reconciliation, or billing screen needs and that are tedious to get right from scratch.
These components render the data you pass in with consistent layout, formatting, and interaction patterns for accounting screens.
Accounting UIs have a recurring set of small, easy-to-botch widgets:
- A register / ledger table with aligned debit/credit columns, a running balance, and column totals.
- A money input that shows a formatted value at rest but is forgiving about what users type (currency symbols, thousands separators, accounting parens).
- A reconciliation diff that lines up "books" against "bank" and flags matches, mismatches, and one-sided entries.
- Status pills for row states (reconciled, unreviewed, cleared, ...).
- A Select whose trigger shows the selected option's label, not its raw
value -- derived automatically from declarative children, so a parallel
itemsarray can never drift out of sync with the options you rendered. - A stage stepper for ordered workflows (engagement progress, a monthly close, a tax-prep stage model) with done / current / upcoming states.
- A fiscal-period picker that scopes a report or filing to a month, quarter, or year and round-trips to a stable token and a readable label.
- A summary card of labeled figures for report headers and dashboard tiles, with currency formatting and tone-coded values built in.
- A progress meter for counted work (a close checklist, a request list, an engagement's tasks) that renders an accessible bar with a fraction or percent caption and auto-grades its color as completion rises.
- A delta badge for a period-over-period change (a revenue swing, a budget variance, a count of new open items) that shows a direction arrow and the change as an amount, a percent, or both, and tone-codes itself by whether the movement is favorable, which you declare per metric.
That last one is a genuine, real-world pain point with primitive select
components (Base UI / Radix style): the trigger only knows the raw value, so
you end up hand-maintaining a value -> label map next to your <Option>s.
<Select> here derives that map from its <SelectItem> children for you.
npm install @maxed-oss/maxed-ui
# peer deps if you don't already have them
npm install react react-domOr install straight from the repository (no registry required):
npm install github:maxed-oss/maxed-uiRequires React 18+.
Everything is exported from the package root. CSS is a separate, optional entry.
| Import path | What you get |
|---|---|
@maxed-oss/maxed-ui |
All components, helpers, and theme tokens (see the table below) |
@maxed-oss/maxed-ui/styles.css |
Optional theme stylesheet: enables themeable tokens + dark mode |
| Export | Kind | Purpose |
|---|---|---|
LedgerTable |
component | Register/ledger data table with debit/credit + running balance |
MoneyInput |
component | Forgiving money/number input with formatted display |
ReconciliationDiff |
component | Side-by-side books-vs-bank diff viewer |
StatusPill |
component | Compact, tone-coded status label |
Select |
component | Select that auto-derives its label map from children |
SelectItem |
component | Declarative option child of Select |
StageStepper |
component | Ordered workflow stepper (done / current / upcoming) |
PeriodPicker |
component | Fiscal-period selector (month / quarter / year) |
SummaryCard |
component | Card of labeled figures for headers and dashboard tiles |
ProgressMeter |
component | Completion bar for counted work (fraction / percent caption) |
DeltaBadge |
component | Tone-coded period-over-period change (arrow + amount / percent) |
deriveItems |
helper | Build the value -> label item list from SelectItem children |
formatMoney |
helper | Format an amount as a currency string |
formatNumber |
helper | Format a plain number (no currency symbol) |
parseMoney |
helper | Parse user-typed money (symbols, grouping, accounting parens) |
formatPeriod |
helper | Render a Period as a stable, sortable token (e.g. 2026-Q2) |
formatPeriodLabel |
helper | Render a Period as a readable label (e.g. Q2 2026) |
tokens |
object | The --mx-* design tokens consumed by every component |
tonePalette |
helper | Resolve the {bg, fg, dot} token triple for a status tone |
THEME_ATTR |
const | The theme attribute name ("data-mx-theme") |
LedgerEntry, LedgerTableProps, MoneyInputProps, ReconLine, ReconRow, ReconciliationDiffProps, StatusPillProps, StatusTone, SelectProps, SelectItemProps, MoneyFormatOptions, Step, StepState, StageStepperProps, Period, PeriodGranularity, PeriodPickerProps, SummaryItem, SummaryCardProps, ProgressMeterProps, DeltaBadgeProps, DeltaPolarity |
types | Public TypeScript types |
Every color the components use is a CSS custom property (var(--mx-*)) with a
baked-in light-mode fallback. Two consequences:
- Zero setup renders the default light theme. You don't need to import any stylesheet for the components to look right.
- Theming is opt-in and tree-shakeable. The colors live in a standalone stylesheet you import only if you want themeable tokens or dark mode. It is not bundled into the JS, so projects that don't theme pay nothing for it.
// Opt into themeable tokens + dark mode:
import "@maxed-oss/maxed-ui/styles.css";With the stylesheet imported, dark mode applies either automatically (via
prefers-color-scheme) or explicitly by setting data-mx-theme on any
ancestor. The explicit attribute always wins:
<div data-mx-theme="dark"> {/* force dark, ignoring the OS setting */}
<LedgerTable entries={entries} />
</div>You can also override individual tokens yourself without the stylesheet -- just
define the --mx-* variables on a container:
.my-scope {
--mx-tone-success-fg: #0f7b3f;
--mx-surface: #fbfbfd;
}The full token list is exported as tokens for programmatic use.
import { LedgerTable, StatusPill } from "@maxed-oss/maxed-ui";
const entries = [
{
id: "1",
date: "2024-03-01",
reference: "INV-1001",
description: "Consulting revenue",
account: "Sales",
credit: 4200,
status: <StatusPill tone="success" dot>Reconciled</StatusPill>,
},
{
id: "2",
date: "2024-03-03",
reference: "CHK-2042",
description: "Office rent",
account: "Rent Expense",
debit: 1800,
},
];
<LedgerTable
entries={entries}
showRunningBalance
openingBalance={1000}
/>;import { MoneyInput } from "@maxed-oss/maxed-ui";
function FeeField() {
const [amount, setAmount] = useState<number | null>(1234.5);
return <MoneyInput aria-label="Fee" value={amount} onChange={setAmount} />;
}
// Displays "$1,234.50" at rest; accepts "$1,234.50", "1234.5", "(50.00)" on input.import { Select, SelectItem } from "@maxed-oss/maxed-ui";
<Select aria-label="Rate tier" value={tier} onChange={setTier}>
<SelectItem value="standard">Standard</SelectItem>
<SelectItem value="discounted">Discounted</SelectItem>
<SelectItem value="vip">VIP</SelectItem>
</Select>;
// The trigger shows "VIP" when value is "vip" -- no separate items map to maintain.import { ReconciliationDiff } from "@maxed-oss/maxed-ui";
const rows = [
{
id: "r1",
status: "matched",
left: { id: "b1", date: "2024-03-01", description: "Deposit", amount: 4200 },
right: { id: "s1", date: "2024-03-01", description: "ACH credit", amount: 4200 },
},
{
id: "r2",
status: "onlyLeft",
left: { id: "b2", date: "2024-03-07", description: "Software", amount: -250 },
},
];
<ReconciliationDiff rows={rows} onlyDifferences />;The caller computes which lines match; ReconciliationDiff renders the result.
import { StageStepper } from "@maxed-oss/maxed-ui";
const steps = [
{ id: "gathering", label: "Gathering", description: "Collect documents" },
{ id: "in-prep", label: "In prep" },
{ id: "review", label: "Review" },
{ id: "filed", label: "Filed" },
];
<StageStepper steps={steps} activeIndex={2} aria-label="Tax prep stage" />;
// Steps before the active index render as done, the active one as current,
// and the rest as upcoming. Pass onStepClick to make the steps navigable.import {
PeriodPicker,
formatPeriod,
formatPeriodLabel,
} from "@maxed-oss/maxed-ui";
const [period, setPeriod] = useState({ granularity: "month", year: 2026, month: 6 });
<PeriodPicker
value={period}
onChange={setPeriod}
granularities={["month", "quarter", "year"]}
/>;
formatPeriod(period); // "2026-M06" (stable, sortable token)
formatPeriodLabel(period); // "Jun 2026" (readable label)import { SummaryCard } from "@maxed-oss/maxed-ui";
<SummaryCard
title="Receivables"
items={[
{ id: "ar", label: "Outstanding", amount: 42850 },
{ id: "overdue", label: "Overdue", amount: -7600, tone: "danger", hint: "5 invoices" },
{ id: "open", label: "Open invoices", value: "18" },
]}
/>;
// Numeric `amount`s format as currency (negatives in accounting style);
// pass a pre-rendered `value` for anything that is not a plain amount.import { ProgressMeter } from "@maxed-oss/maxed-ui";
<ProgressMeter label="Close checklist" value={7} total={12} />;
// Renders "7 / 12" and a bar at 58%. The bar color auto-grades with completion;
// pass caption="percent" for "58%", or tone="info" to pin a fixed color.The bar is an accessible role="progressbar" with aria-valuenow / valuemax
/ valuetext, and it clamps gracefully (an over-full value caps at 100%, a zero
total reports 0%).
import { DeltaBadge } from "@maxed-oss/maxed-ui";
// Revenue up 5.4% reads as a success-toned "↑ +5.4%".
<DeltaBadge percent={0.054} caption="vs last month" />;
// For metrics where a drop is the good outcome, declare the polarity so a
// decrease reads as success instead of danger.
<DeltaBadge value={-1200} asMoney polarity="decrease-good" caption="spend" />;
// Show both an absolute amount and a percent at once.
<DeltaBadge value={200} percent={0.1} asMoney />;The badge derives direction (up / down / flat) and tone from the figures you
pass; polarity tells it which direction is favorable for that metric (or
"neutral" to color nothing by direction). Zero renders a neutral, flat badge.
npm install
npm run build # type-check + bundle to dist/ (incl. styles.css)
npm test # vitest (jsdom + React Testing Library)
npm run storybook # interactive component explorer on :6006
npm run build-storybook # static Storybook buildCI (GitHub Actions) runs the type-check and tests on Node 18/20/22 and builds
both the library and Storybook on every push and pull request. See
.github/workflows/ci.yml.
All sample data in the stories and tests is synthetic and fictional.