Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

Risk scale

New

Every open loan as a dot on one health-factor track, with a repay suggestion for the weakest.

risk-scale/risk-scale.tsx
Live · interactive

Liquidation riskHealth factor of each open position

  • Liquidation < 1.0
  • At risk 1.0–1.2
  • Watch 1.2–1.5
  • Safe 1.5+

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 lending overview where every open loan has a health factor and the question is which one is closest to liquidation.
  • Up to about ten positions on one ordered measure, read against fixed thresholds: liquidation, at risk, watch, safe.
  • Values that crowd at the low end, where the difference between 1.05 and 1.2 matters more than 3 and 4: the log scale spreads them out.
  • When one action follows from the reading: the tooltip names the repayment that lifts the weakest loan to the target.
  • Dashboards where hovering a loan should answer the next question (its debt, its collateral, its liquidation price) without leaving the card.

When not to use

  • Dozens of positions: the labels run out of rows. Use a sortable table with a health column.
  • A single loan’s health. A gauge or a meter with its bands reads faster.
  • Health over time. Use a line chart with the thresholds drawn as bands.
  • Measures without meaningful thresholds or a direction of danger: the four coloured bands would invent a judgement.
  • Touch-only screens where the hover card is the point: on touch, only the tap’s selection and its tooltip remain.

You can add this component with the shadcn CLI or by hand:

Into components/risk-scale of any React project, with no setup:

Terminal
npx shadcn@latest add https://brunodelorence.com/r/risk-scale.json

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

loans-overview.tsx
import { RiskScale, type RiskPosition } from './risk-scale';const positions: RiskPosition[] = [  { id: 'a', value: 1.08, venue: 'Halden', asset: 'dln', debt: 13700 },  { id: 'b', value: 1.34, venue: 'Corvo', asset: 'tsl', debt: 2300 },  { id: 'c', value: 2.4, venue: 'Quill', asset: 'vlr', debt: 9000 },];export function LoansOverview() {  return (    <RiskScale      positions={positions}      target={1.5}      autoplay={false}      onSelect={(position) => console.log('Open', position.id)}    />  );}

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 card for lending positions. A track cut into four bands (liquidation, at risk, watch, safe) carries a dot for each loan, its coin on a stem above and its value and venue below. The weakest loan under the target wears a halo and a dark tooltip saying how much to repay to lift it back. Hover or focus a loan and the others fade while a card shows its debt, its collateral and the price that would liquidate it.

  • The scale is logarithmic, from 0.9 to 5 by default: a step from 1.0 to 1.2 takes as much room as one from 3 to 3.6, so the crowded low end is spread out and a 4.29 still fits. Values past the ends sit at the ends.
  • The track is four absolutely placed pills, one per band, each drawn by a ::before inset 1.25 px so the bands stand 2.5 px apart; only the scale’s two ends are fully round.
  • Each loan is a zero-width button at its place on the scale, with its coin, stem, dot, halo and labels hanging from it, so the whole column is its hover area and one left: n% places it all.
  • The labels are measured once laid out: left to right, each takes the first row where it clears the last label placed there by 10 px (up to three rows), and those at the ends are nudged back inside the card.
  • The repay suggestion follows from health being collateral over debt: the debt that reaches the target is debt × value ÷ target, so the repayment is debt × (1 − value ÷ target), rounded up to $10.
  • The liquidation price in the hover card is the collateral’s price × 1.0 ÷ health, and the drop it takes is 1 − 1 ÷ health: a loan at 1.86 liquidates after a 46.2% fall.
  • The tooltip and the hover card move by transforms only, the card sliding from loan to loan in 260 ms; it is an overlay that takes no room in the layout, opening above the coins when the screen (and any frame that clips) has the room there, and under the labels otherwise. A card held open from the start (defaultHovered) has the room above reserved for it.
  • The first time the card comes on screen, the loans rise 4 px into place and fade in, 40 ms apart from left to right, the tooltip with the first; under reduced motion they are simply there.
  • While on screen and untouched, a demo pointer hovers the two weakest loans, selects the second (the halo and the tooltip move to it), visits a safe one and selects the weakest again. Any real input stops it.
  • Every colour is a light-dark() token on the root. At night the bands become deep wine, amber and pine, the coins turn pastel and the tooltip turns white with dark words.
PropTypeDefaultDescription
positionsRiskPosition[]SAMPLE_POSITIONSThe open loans, in any order: { id, value (health factor), venue, asset (‘dln’ | ‘tsl’ | ‘vlr’ | ‘orn’), network?, debt?, collateral?: { amount, asset, value } }. Debt prices the repay suggestion; collateral fills the hover card and its liquidation price.
thresholds{ liquidation: number; risk: number; watch: number }{ liquidation: 1, risk: 1.2, watch: 1.5 }Where the bands meet; the legend is written from them.
targetnumber1.5The health the repay suggestion lifts the selected loan to.
range[number, number][0.9, 5]The ends of the logarithmic track; values beyond them sit at its ends.
titlestring'Liquidation risk'The card’s title.
subtitlestring'Health factor of each open position'The muted line beside the title.
selectedstring—The selected loan’s id, controlled: it wears the halo and the tooltip.
defaultSelectedstring—The loan selected at first; by default the weakest one under the target.
onSelect(position: RiskPosition) => void—Called when a loan is clicked, tapped, or chosen with Enter or Space.
defaultHoveredstring—A loan’s id whose hover card opens at first, the other loans faded, as if pointed at; held until the reader’s pointer or focus moves on. Turns the demo off.
entrancebooleantrueThe loans rise into place the first time it comes on screen; false draws them in place.
autoplaybooleantrueWhile on screen and untouched, a demo pointer hovers a few loans and selects one.
colorScheme'light' | 'dark' | 'system'—Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one.
classNamestring—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.

risk-scale.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import {  useEffect,  useEffectEvent,  useId,  useLayoutEffect,  useMemo,  useRef,  useState,  type KeyboardEvent as ReactKeyboardEvent,} from 'react';import { ASSETS, CoinGlyph, type RiskAsset } from './assets';import { DemoPointer, runDemo } from './demo-pointer';import { useAutoplay } from './use-autoplay';import styles from './risk-scale.module.css';/* * Risk scale: the health factor of every open loan on one track. The track is cut in four bands * (liquidation, at risk, watch, safe); each loan is a dot on it, its coin on a stem above and its * value and venue below. One loan is selected, by default the weakest under the target: a halo * marks it and a dark tooltip says how much to repay to lift it to the target.
risk-scale.module.css
/* Risk scale (risk-scale.tsx): a soft grey card with a title, a legend and the plot. The plot is a   stack of layers on one coordinate system, in px from its top: the tooltip (1.5–28.5), the coins   (middle at 48) on their stems, the track (middle at 77) with its dots, and the labels from 88.5,   one row every 38 px.   Colours are the tokens below, light-dark(light, dark) where the two differ: the component takes   the color-scheme it inherits from the page (light unless the page says otherwise), or the one   its colorScheme prop sets (data-color-scheme). By day the coins are deep and the tooltip dark;   at night the coins turn pastel and the tooltip light.   Memory. Everything that moves (the tooltip, the hover card, the dots, the demo pointer) moves   by transform or opacity; the labels are measured once per layout, never per frame. */.root {  --surface: #fafafa;  --line: rgb(0 0 0 / 0.05);  --ink: #161618;  --ink-2: #161618;  --muted: #939396;  --legend-ink: #5c5c61;  /* The bands of the track, and the dots on them. */  --band-liquidation: #eb8c8a;  --band-risk: #fbcecb;  --band-watch: #fcdfbd;
assets.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.import type { ReactNode } from 'react';/* * The coins a position can be in: invented names, each with a small drawn glyph. The glyph's two * colours are tokens of the stylesheet (risk-scale.module.css), a deep coin by day and a pastel * one at night, so the drawings are never restyled from here. */export type RiskAsset = 'dln' | 'tsl' | 'vlr' | 'orn';export const ASSETS: Record<RiskAsset, { symbol: string; name: string }> = {  dln: { symbol: 'DLN', name: 'Dollen' },  tsl: { symbol: 'TSL', name: 'Tessel' },  vlr: { symbol: 'VLR', name: 'Velar' },  orn: { symbol: 'ORN', name: 'Orrin' },};/** Each glyph in a 20 by 20 box: the coin's shape in --coin, its mark in --coin-ink. */const GLYPHS: Record<RiskAsset, ReactNode> = {  // A round dollar coin: a disc, a fine inner ring and an S with its bar.  dln: (    <>
demo-pointer.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'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 (anything that * 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 = {
use-autoplay.ts
// Copyright (c) 2026 Bruno Delorence. MIT License.'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(
  • The card is a section named by its title, and the legend a list. The loans are a group of buttons with a single tab stop: the arrow keys move between them in the order of the track, Home and End jump to the ends, and Enter or Space selects (aria-pressed marks the selected one).
  • Each loan’s button is named in full (“Halden, DLN loan, health 1.08, at risk”), and while it is hovered or focused the card is its description (role tooltip, aria-describedby). Escape hides the card.
  • Selecting a loan announces it with its repay suggestion in a polite live region. The coins, the track and the visible tooltip are decorative (aria-hidden).
  • Focus shows the same card as hover, with a visible ring around the coin. Under reduced motion nothing slides or fades and the demo never plays.