Loans dashboard
NewA lending app’s Loans screen: outstanding debt, a liquidation-risk map of every position’s health factor, and a table to repay from.
loans-dashboard/loans-dashboard.tsxLoans
Every borrow position across Halden, Corvo, Merrow, Sable and Vela.
Outstanding debt
- Collateral
- —
- Loan-to-value
- —
- Positions
- —
Liquidation risk Health factor of each open position
- Liquidation < 1.0
- At risk 1.0–1.2
- Watch 1.2–1.5
- Safe 1.5+
| Position | Collateral | Repaid | APR | Health | Status | Actions |
|---|
The live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.
When to use
- A borrower’s home for loans spread over several venues: how much is owed, how much is repaid, and which position is closest to liquidation.
- Risk that is one number per item (a health factor, a score, a margin) and reads best laid on one shared scale, coloured by zone.
- A list that people act on row by row: repay, add collateral, copy an ID, with a preview of the result before they confirm.
- Screens where one action has to move every figure at once: the total, the bar, the map and the table all come from one list.
When not to use
- A single loan. Show its own page: the health, the debt and one Repay button, with no map or table.
- Read-only reporting over time (interest paid, rates by month). Use a chart dashboard with a period picker.
- Hundreds of positions. The map’s labels drop to new lines and stop reading; page the table on the server and replace the map with a histogram.
- Trading or swapping. The dialog only repays or adds collateral; a trade needs quotes, slippage and routes.
You can add this component with the shadcn CLI or by hand:
Into components/loans-dashboard of any React project, with no setup:
npx shadcn@latest add https://brunodelorence.com/r/loans-dashboard.json- Copy the component files
Copy these files into one folder of your project, keeping their relative imports together, or download them as a ZIP:
- Import it
As shown under Usage, below.
Either way it needs React 19.2 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: it follows your page’s color-scheme, light unless the page sets one, or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.
Usage
import { LoansDashboard } from './loans-dashboard';export function LoansPage() { return ( <LoansDashboard autoplay={false} defaultSort="health" onRepay={(position, kind, amount) => console.log(kind, position.id, amount)} /> );}Each state below is a live instance, locked so you can study it.
The pieces it is built from, each with its measurements read live.
A whole Loans screen for a borrower with positions on several lending venues. The debt counts in over a repaid bar; a map lays every open position on one bar of health factors, coloured from liquidation to safe, with a card on hover and a pill that says what lifting the weakest one would take. The table filters by status, searches, filters by venue and rate, sorts and pages, and every row repays or adds collateral through a dialog that previews the health factor, plays the wallet’s steps and updates the screen.
- One list of positions feeds the whole screen. The debt, the share repaid, the collateral, the loan-to-value, the map, the tab counts, the rows and the pages are all computed from it on each render, so one repayment moves every figure at once.
- A health factor is the collateral, valued at its liquidation threshold, over the debt. So health × debt stays constant while prices hold: repaying $3,840 of $13,700 at 1.08 lifts it to 14,796 / 9,860 = 1.50. The amount that lifts a position to 1.50 is solved from the same constant and rounded up to $10, and the price that would liquidate it is the collateral’s price over its health.
- The map is logarithmic, from 0.9 to 5, so the crowded zone under 2 gets the room. Each marker is a button: the asset’s mark, a stem, a dot on the bar and its health and venue under it. Labels that would come closer than 44 px drop to the next line, measured from the map’s own width (a ResizeObserver), and the map grows a line rather than overlap.
- The map and the table are linked through one hovered id: a marker lights its row (and shows its card), a row lights its marker and dims the rest. A dark pill rides over the weakest position under 1.50 and opens the dialog with the lifting amount already in.
- The repay dialog also adds collateral. Presets fill the amount (lift to 1.50, a quarter, half, all of it); a small copy of the risk bar shows the old and new health; the wallet’s two steps, "Confirm in wallet" and "Repaying", run on timers before the receipt. The screen updates as the receipt shows: the debt counts down, the bars grow, the marker slides and a toast offers to show the row.
- Menus (a row’s, sort, filter, account, notifications) are drawn at the dashboard’s root and placed under their button, so the table’s horizontal scroll never clips them.
- The debt counts with requestAnimationFrame on an ease-out; the repaid bars scale by transform; markers slide by their left offset on a transition. Under reduced motion, there is no skeleton and every figure lands at once.
- Layout is by container queries on the root, so it answers to its own width, not the window’s: a full sidebar from 1100 px, an icon rail from 760 px, a drawer behind a menu button below that. The overview stacks under 980 px and the table scrolls inside its box.
- While on screen and untouched, a demo pointer hovers two markers, repays the weakest position from the pill, lights a row, sorts by lowest health, searches and opens a row’s menu, then puts the positions back. Any real input hands control to the reader; once the reader has changed a position, the demo leaves the positions alone.
- Every colour is a light-dark() token on the root, so the dashboard follows the page’s color-scheme or the colorScheme prop, and its account menu can switch it to light, dark or the system’s.
| Prop | Type | Default | Description |
|---|---|---|---|
positions | Position[] | POSITIONS | The borrow positions shown (data.ts has the type and twelve invented ones). The dashboard keeps its own copy as they are repaid. |
defaultSort | 'newest' | 'oldest' | 'health' | 'largest' | 'smallest' | 'apr' | 'repaid' | 'newest' | The table’s first sort. |
alerts | boolean | true | Whether the liquidation alerts switch in the sidebar starts on. |
onRepay | (position: Position, kind: 'repay' | 'collateral', amount: number) => void | — | Called when a repayment (in dollars) or added collateral (in units) lands, with the position as it now is. |
defaultState | LoansDashboardState | — | Opens in this state, fully loaded unless loading (the first load’s skeleton, held): tab ('all' | 'active' | 'risk' | 'closed'), query, venues and rates (the search and its filters), card (a position’s id, its card pinned on the map and its row lit), menu ('row' | 'sort' | 'filter' | 'account' | 'bell'; 'row' is the first row’s), dialog ({ id, mode?: 'repay' | 'collateral' }, the dialog open on an open position) and drawer (the sidebar drawer, under 760 px). Turns the demo off. |
autoplay | boolean | true | While on screen and untouched, a demo pointer shows the map, a repayment, the sort, the search and a row’s menu. Real input hands control to the reader. |
colorScheme | 'light' | 'dark' | 'system' | — | Light or dark colours, or the reader’s system setting; without it, the page’s color-scheme. The account menu can change it. |
className | string | — | Extra class for the root. |
The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useEffect, useEffectEvent, useRef, useState, useSyncExternalStore, type CSSProperties, type KeyboardEvent, type ReactNode,} from 'react';import { ASSETS, POSITIONS, VENUES, collateralValue, dollars, healthAfterCollateral, isAtRisk, isOpen, liftRepay,// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useEffect, useRef, useState, type CSSProperties } from 'react';import { ASSETS, ZONES, ZONE_NAME, collateralValue, compact, cents, dollars, liftRepay, liquidationPrice, scale, units, zoneOf, type Position,} from './data';import { AssetMark } from './icons';import styles from './loans-dashboard.module.css';/*// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useEffect, useEffectEvent, useRef, useState, type KeyboardEvent } from 'react';import { ASSETS, WALLET, ZONES, ZONE_NAME, cents, dollars, healthAfterCollateral, healthAfterRepay, healthText, liftCollateral, liftRepay, scale, units, zoneOf, type Position,} from './data';import { AssetMark, Icon } from './icons';import styles from './loans-dashboard.module.css';// Copyright (c) 2026 Bruno Delorence. MIT License./* * The loans dashboard's data: invented venues, chains, assets and twelve borrow positions, and the * arithmetic of a health factor. * * A position's health factor is its collateral, valued at the venue's liquidation threshold, * over its debt. So `health × debt` is a constant of the collateral (its "cover"): repaying lowers * the debt and lifts the health in proportion, adding collateral lifts the cover. The collateral * is liquidated once the health falls under 1.0, that is once its price falls by 1 − 1/health. */export type AssetId = 'dolr' | 'talr' | 'orb' | 'flint' | 'lume' | 'peak' | 'comb';export type Asset = { symbol: string; /** The price in dollars. Stable assets are worth 1. */ price: number; /** Decimals shown for an amount of it. */ decimals: number;};export const ASSETS: Record<AssetId, Asset> = { dolr: { symbol: 'DOLR', price: 1, decimals: 2 },// Copyright (c) 2026 Bruno Delorence. MIT License./* * The dashboard's drawings, all decorative (aria-hidden): line icons on a 24-unit grid, stroked * in the current colour, and the invented assets' marks on a 20-unit grid, in their own colours. * The stylesheet sizes every SVG; nothing here sets a width or height. */import type { ReactNode } from 'react';import type { AssetId } from './data';const LINE: Record<string, string> = { overview: 'M4 4h6v6H4zM14 4h6v6h-6zM4 14h6v6H4zM14 14h6v6h-6z', portfolio: 'M4 7.5A2.5 2.5 0 0 1 6.5 5h11A2.5 2.5 0 0 1 20 7.5v9a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 4 16.5zM4 9.5h16', loans: 'M3.5 9.5 12 4.5l8.5 5M5 20h14M6.5 11v6.5M10 11v6.5M14 11v6.5M17.5 11v6.5M4 9.5h16', swap: 'M8 20V5M4.5 8.5 8 5l3.5 3.5M16 4v15M12.5 15.5 16 19l3.5-3.5', activity: 'M3 12h4l2.5-6 5 12 2.5-6h4', explore: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18zM15.5 8.5l-2 5-5 2 2-5z', settings: 'M4 8h9M17 8h3M4 16h3M11 16h9M15 6v4M9 14v4', help: 'M7 17 17 7M8.5 7H17v8.5', bell: 'M6.5 16.5V11a5.5 5.5 0 0 1 11 0v5.5l1.5 1.5H5zM10 20.5a2.2 2.2 0 0 0 4 0', chevronDown: 'm7 10 5 5 5-5', chevronLeft: 'm14.5 6.5-5.5 5.5 5.5 5.5','use client';import type { CSSProperties, Ref } from 'react';/* * The demo's pointer, and the steps that move it. A demo is a script of awaited steps: wait, or * glide the pointer somewhere. Stopping the demo (the reader takes over, or it scrolls away) ends * the step in progress at once: its promise rejects, which ends the script right there, and the * pointer fades out. * * Each glide is a curved flight played by the Web Animations API, so nothing runs frame by frame. * The points of the flight are handed to whoever wants to move along with it (the eye of the * badge, which plays its own animation on the same clock). The arrow and the hand are HTML boxes * that swap by their visibility: a change of style inside an SVG would lay the SVG out again. */export type Point = { x: number; y: number };/** A glide's path and timing, for an animation that plays along with it. */export type Flight = { points: Point[]; timing: KeyframeAnimationOptions };export type Demo = { /** Resolves after `ms`. */ wait: (ms: number) => Promise<void>;'use client';import { useCallback, useEffect, useRef, useState } from 'react';/* * When a demo may play on its own: while part of it is on screen and the tab is visible, never * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it * takes back over `idle` ms after their last input. */export function useAutoplay<T extends HTMLElement>(idle = 6000) { const ref = useRef<T>(null); const [visible, setVisible] = useState(false); const [reduced, setReduced] = useState(true); const [taken, setTaken] = useState(false); const timer = useRef(0); useEffect(() => { const element = ref.current; if (!element) return; let onScreen = false; const update = () => setVisible(onScreen && !document.hidden); const observer = new IntersectionObserver( ([entry]) => { onScreen = entry.isIntersecting;/* Loans dashboard (loans-dashboard.tsx): a white app screen with a pale sidebar and a pale card of figures, lavender for the actions and the repaid bars, red to green for the four zones of the health factor; at night near-black, with the zones' tracks dimmed and their dots lit. Colours are the tokens below, light-dark(light, dark) where the two differ: the dashboard takes the color-scheme it inherits from the page, or the one its colorScheme prop or its account menu sets (data-color-scheme). Layout is by container queries on the root: a full sidebar from 1100 px, an icon rail from 760 px, and below that a drawer behind a menu button; the overview stacks under 980 px, and the table scrolls inside its own box when it does not fit. Memory. Only transforms and opacity animate (the repaid bars scale, the markers slide, menus and the dialog fade in); the debt counts in text. The skeleton does not shimmer. */.root { --bg: #fff; --side: #fafafa; --panel: #f8f8f9; --ink: #1b1b1f; --ink-2: #36363c; --muted: #7c7c84; --muted-2: #96969d; --nav-icon: #5f5f66;- Landmarks and headings: the sidebar is an aside with a nav, the screen a main with an h1, the overview, map and positions are labelled sections, and the breadcrumb marks the current page.
- Each marker on the map is a button named for its position, health and zone; focusing it shows the same card as hovering, and Enter opens the repay dialog. The card is a role="tooltip".
- The status filters are a tablist with arrow keys; the table has real headers, and aria-sort on the column the current sort applies to. Every row’s Repay and menu buttons are named for the position.
- Menus are role="menu" with menuitem, menuitemradio and menuitemcheckbox items: arrow keys, Home and End move through them, Escape closes them and returns to their button, Tab or a press outside closes them.
- The repay dialog is modal: focus moves to the amount, Tab is kept inside, Escape closes it while nothing is pending, and focus returns to the control that opened it. Its errors and the wallet’s steps are announced through aria-live.
- The debt, the toast and the table’s count are live regions; the debt announces its final value, not every step of the count. The alerts switch is a role="switch".
- Every control shows a :focus-visible ring, the drawer closes with Escape, and reduced motion removes the skeleton, the counting and the demo. The demo pointer and the asset marks are aria-hidden.