Skip to content

ReconciliationView

Two-pane matcher. The match animation.
Implemented
Status
Implemented
Family
5 of 13
Registry
@lopifi/reconciliation-view
npx shadcn@latest add @lopifi/theme @lopifi/reconciliation-view

The component, rendering

Real data, the shipped component. Switch the theme to check the claims the spec makes below.

Preview

The four-layer spec

Two panes: bank statement lines, book entries. Suggested matches with confidence, one-click accept, manual multi-select match (many-to-one and one-to-many both supported — real reconciliation is rarely 1:1).

The match animation is one of the four sanctioned moments: highlight → FLIP together → merge with a check. Spec. Bulk match runs the sequence once for the group, staggered at 40ms with a 240ms cap.

Running totals for both sides, with the remaining difference always visible. That difference is the number the user is trying to get to zero, so it never scrolls away.

Lifted verbatim from Ledger & tables. The doc and this page are the same paragraphs, so they cannot disagree.

What lands in your repository

One component is never one file. Everything below is copied into your project, where you own it.

Files

components/ledger/reconciliation-view.tsx

Lopifi dependencies · 9
buttoncnfiscal-periodformat-moneyiconsmoneymoney.typestooltipuse-media-query
npm packages
@base-ui-components/reactclass-variance-authorityclsxtailwind-merge
Registry entry
/r/reconciliation-view.json

The whole component

No compiled bundle, no black box. This is the file the registry copies, and the file you will edit.

components/ledger/reconciliation-view.tsx
"use client";

import * as React from "react";
import { useReducedMotion } from "@/hooks/ledger/use-media-query";
import { cn } from "@/lib/ledger/cn";
import { formatDateShort } from "@/lib/ledger/fiscal-period";
import type { MoneyValue } from "@/lib/ledger/money.types";
import { Button } from "@/components/ledger/button";
import { CheckIcon, LinkIcon } from "@/components/ledger/icons";
import { Money } from "@/components/ledger/money";

export type ReconLine = {
  id: string;
  date: Date;
  description: string;
  amount: MoneyValue;
  matched?: boolean;
};

export type Suggestion = {
  bankIds: string[];
  bookIds: string[];
  /** The basis, not a bare score. */
  basis: string;
};

export interface ReconciliationViewProps {
  bankLines: ReconLine[];
  bankTotal: MoneyValue;
  bookLines: ReconLine[];
  bookTotal: MoneyValue;
  className?: string;
  /** The number the user is trying to get to zero. It never scrolls away. */
  difference: MoneyValue;
  onMatch?: (bankIds: string[], bookIds: string[]) => void;
  suggestions: Suggestion[];
}

/**
 * Two panes: bank statement lines, book entries. Many-to-one and one-to-many
 * are both supported, because real reconciliation is rarely 1:1.
 *
 * The match animation is one of the four sanctioned moments: highlight →
 * move together → merge with a check. Bulk match runs the sequence once for
 * the group, staggered at 40ms and capped at 240ms.
 */
export function ReconciliationView({
  bankLines,
  bookLines,
  suggestions,
  bankTotal,
  bookTotal,
  difference,
  onMatch,
  className,
}: ReconciliationViewProps) {
  const [bankSel, setBankSel] = React.useState<string[]>([]);
  const [bookSel, setBookSel] = React.useState<string[]>([]);
  const [matching, setMatching] = React.useState<string[]>([]);
  const reduced = useReducedMotion();

  const runMatch = (bankIds: string[], bookIds: string[]) => {
    if (reduced) {
      onMatch?.(bankIds, bookIds);
      return;
    }
    setMatching([...bankIds, ...bookIds]);
    setTimeout(() => {
      setMatching([]);
      setBankSel([]);
      setBookSel([]);
      onMatch?.(bankIds, bookIds);
    }, 520);
  };

  const settled = Number(difference.amount) === 0;

  return (
    <div className={cn("space-y-4", className)}>
      {/* The remaining difference is always visible. */}
      <div
        className={cn(
          "sticky top-0 z-sticky flex flex-wrap items-center justify-between gap-4 rounded-md border px-4 py-3",
          settled ? "border-positive bg-positive-subtle" : "border-border bg-surface",
        )}
      >
        <span className="flex items-baseline gap-4">
          <span className="text-caption text-text-secondary">
            Bank <Money accounting size="xs" tone="inherit" value={bankTotal} />
          </span>
          <span className="text-caption text-text-secondary">
            Books <Money accounting size="xs" tone="inherit" value={bookTotal} />
          </span>
        </span>
        <span className="flex items-baseline gap-2">
          <span className="text-label font-medium">Difference</span>
          <Money
            accounting
            className={settled ? "text-positive" : "text-negative"}
            size="md"
            value={difference}
          />
        </span>
      </div>

      {suggestions.length > 0 ? (
        <div className="rounded-md border border-border bg-surface p-4">
          <h3 className="text-label font-medium text-text-secondary">Suggested matches</h3>
          <ul className="mt-2 space-y-2">
            {suggestions.map((s, i) => (
              <li className="flex flex-wrap items-center gap-3" key={i}>
                <LinkIcon aria-hidden="true" className="size-4 shrink-0 text-text-tertiary" />
                <span className="flex-1 text-body">{s.basis}</span>
                <Button onClick={() => runMatch(s.bankIds, s.bookIds)} size="sm" variant="primary">
                  Accept
                </Button>
              </li>
            ))}
          </ul>
        </div>
      ) : null}

      <div className="grid gap-4 lg:grid-cols-2">
        <Pane
          lines={bankLines}
          matching={matching}
          onToggle={(id) => setBankSel((s) => (s.includes(id) ? s.filter((x) => x !== id) : [...s, id]))}
          selected={bankSel}
          title="Bank statement"
        />
        <Pane
          lines={bookLines}
          matching={matching}
          onToggle={(id) => setBookSel((s) => (s.includes(id) ? s.filter((x) => x !== id) : [...s, id]))}
          selected={bookSel}
          title="Book entries"
        />
      </div>

      <Button
        disabled={bankSel.length === 0 || bookSel.length === 0}
        onClick={() => runMatch(bankSel, bookSel)}
        variant="primary"
      >
        Match {bankSel.length} bank {bankSel.length === 1 ? "line" : "lines"} to {bookSel.length} book{" "}
        {bookSel.length === 1 ? "entry" : "entries"}
      </Button>
    </div>
  );
}

function Pane({
  title,
  lines,
  selected,
  onToggle,
  matching,
}: {
  title: string;
  lines: ReconLine[];
  selected: string[];
  onToggle: (id: string) => void;
  matching: string[];
}) {
  return (
    <section className="overflow-hidden rounded-xl border border-border bg-surface">
      <h3 className="border-b border-border-subtle px-4 py-3 text-label font-medium text-text-secondary">
        {title}
      </h3>
      <ul>
        {lines.map((l, i) => {
          const isMatching = matching.includes(l.id);
          return (
            <li
              className={cn(
                "flex items-center gap-3 border-b border-border-subtle px-4 py-3 last:border-b-0",
                selected.includes(l.id) && "bg-surface-selected",
                l.matched && "opacity-60",
              )}
              key={l.id}
              style={
                isMatching
                  ? {
                      animation: `ledger-match 320ms var(--ease-out) ${Math.min(i * 40, 240)}ms both`,
                    }
                  : undefined
              }
            >
              <input
                aria-label={`Select ${l.description}`}
                checked={selected.includes(l.id)}
                className="size-4 accent-[var(--accent)]"
                disabled={l.matched}
                onChange={() => onToggle(l.id)}
                type="checkbox"
              />
              <span className="numeric w-16 shrink-0 text-caption text-text-secondary">
                {formatDateShort(l.date)}
              </span>
              <span className="min-w-0 flex-1 truncate text-body">{l.description}</span>
              {l.matched ? (
                <CheckIcon aria-label="Matched" className="size-4 shrink-0 text-positive" />
              ) : null}
              <Money accounting value={l.amount} />
            </li>
          );
        })}
      </ul>
      <style>{`@keyframes ledger-match {
        0% { background-color: var(--accent-subtle); transform: none; }
        60% { background-color: var(--accent-subtle); transform: translateX(4px); }
        100% { background-color: var(--positive-subtle); transform: none; }
      }`}</style>
    </section>
  );
}

The accounting it encodes

Concepts this component renders. Each one is defined once, in the lexicon, and used identically in the code and the copy.

Also in Ledger & tables

See also

A component is specified in one place, defined in another, and running in a third. All three, from here.

Elsewhere in the system

Where ReconciliationView sits in the rest of the system — the live section it renders in, the calculators that compute what it displays, and the ledger payloads it is fed from.

Components are copied into your repository and meant to be edited — rounding policy, fiscal calendar and compliance copy differ per company.

Told when Ledger & tables changes

Components are copied into your repo, so a fix here does not reach you on its own. One email when a component in this family changes in a way that needs a migration — and nothing when it does not.

One address, no list rental, unsubscribe in one click.

Works in your QuickBooks Online or Xero file

Get your books closed every month

Lopifi is building AI bookkeeping that keeps your books closed on US GAAP as transactions happen, with a CPA review every quarter, for $200 a month. We’re not open yet.

$200/month when we open. No card now.