caliper uidocs

Reading progress

Free

Where you are in a long page, in a small pill that appears once you start scrolling: a ring that fills as you read, the section you are in and how far through you are. Press it to jump to another section.

Installs components/ui/reading-progress.tsxDepends on lucide-reactMotion honours reduced motion
pnpm dlx shadcn@latest add https://caliperui.site/r/reading-progress.json

Release note

Multi-currency matching

Scroll this panel: the pill appears and follows the section you are reading.

Why we changed matching

Payments in one currency now match invoices in another. Until now, a euro invoice paid into a sterling account sat unmatched until someone booked the difference by hand.

Payments in one currency now match invoices in another. Until now, a euro invoice paid into a sterling account sat unmatched until someone booked the difference by hand.

Payments in one currency now match invoices in another. Until now, a euro invoice paid into a sterling account sat unmatched until someone booked the difference by hand.

Which rate is used

The rate on the payment date is used, taken from the European Central Bank reference rates published that afternoon. If a payment lands on a weekend, the previous working day's rate applies.

The rate on the payment date is used, taken from the European Central Bank reference rates published that afternoon. If a payment lands on a weekend, the previous working day's rate applies.

The rate on the payment date is used, taken from the European Central Bank reference rates published that afternoon. If a payment lands on a weekend, the previous working day's rate applies.

Where the difference goes

The difference between the invoice amount at that rate and what arrived is posted to FX gains and losses as its own line, so it shows in reports and can be reviewed in the close.

The difference between the invoice amount at that rate and what arrived is posted to FX gains and losses as its own line, so it shows in reports and can be reviewed in the close.

The difference between the invoice amount at that rate and what arrived is posted to FX gains and losses as its own line, so it shows in reports and can be reviewed in the close.

Rules and IBAN matching

Rules can now match on IBAN as well as reference. A rule on IBAN is checked first, because a customer's bank account changes less often than how they write the reference.

Rules can now match on IBAN as well as reference. A rule on IBAN is checked first, because a customer's bank account changes less often than how they write the reference.

Rules can now match on IBAN as well as reference. A rule on IBAN is checked first, because a customer's bank account changes less often than how they write the reference.

What it does not do yet

Part payments across currencies are matched one at a time. Bulk matching treats foreign-currency lines as suggestions you confirm, never automatic matches.

Part payments across currencies are matched one at a time. Bulk matching treats foreign-currency lines as suggestions you confirm, never automatic matches.

Part payments across currencies are matched one at a time. Bulk matching treats foreign-currency lines as suggestions you confirm, never automatic matches.

When to use #

Use it for

  • Long articles, guides and policies, especially on phones where there is no side outline.
  • Reports read top to bottom.

Skip it when

  • Short pages that fit in two screens.
  • Pages that already show an outline beside the text; use scroll spy nav there.

Decisions #

Why it is built this way, so you know what to keep when you change your copy.

Behaviour

Progress is derived, not stored
The ring and the section are read from the scroll position every frame the page moves, so they can never drift from what is on screen.
Jumping moves focus too
Choosing a section scrolls there and focuses it, so a keyboard or screen reader user continues reading from that section rather than from the pill.

Styling

Out of the way until you scroll
The pill appears after the first screen and floats over the page with the frosted surface, because it floats; it never covers the article's own outline.

Usage

One per scrolling surface
Use placement fixed for the window, or sticky as the last child of a scrolling panel with its ref passed as scrollRef.

Installation #

terminal
pnpm dlx shadcn@latest add https://caliperui.site/r/reading-progress.json

Adds its 2 files to components/ui/, each with its own stylesheet, and installs their packages. No account needed. The first part also adds Caliper UI’s tokens; import them once as in Installation.

Usage #

This is the code behind the preview above.

example.tsx
import { useRef } from "react"
import { ReadingProgress } from "@/components/ui/reading-progress"

const chapters = [
  { id: "rp-why", title: "Why we changed matching", text: "Payments in one currency now match invoices in another. Until now, a euro invoice paid into a sterling account sat unmatched until someone booked the difference by hand." },
  { id: "rp-rate", title: "Which rate is used", text: "The rate on the payment date is used, taken from the European Central Bank reference rates published that afternoon. If a payment lands on a weekend, the previous working day's rate applies." },
  { id: "rp-difference", title: "Where the difference goes", text: "The difference between the invoice amount at that rate and what arrived is posted to FX gains and losses as its own line, so it shows in reports and can be reviewed in the close." },
  { id: "rp-rules", title: "Rules and IBAN matching", text: "Rules can now match on IBAN as well as reference. A rule on IBAN is checked first, because a customer's bank account changes less often than how they write the reference." },
  { id: "rp-limits", title: "What it does not do yet", text: "Part payments across currencies are matched one at a time. Bulk matching treats foreign-currency lines as suggestions you confirm, never automatic matches." },
];

export function ReadingProgressDemo() {
  const scroller = useRef<HTMLDivElement>(null);
  return (
    <div ref={scroller} className="h-[440px] w-full max-w-2xl overflow-auto rounded-[14px] px-6 pt-6 shadow-[inset_0_0_0_1px_var(--border)] max-sm:px-4">
      <article className="grid gap-2">
        <p className="font-mono text-xs text-muted-foreground">Release note</p>
        <h2 className="text-2xl font-bold tracking-tight">Multi-currency matching</h2>
        <p className="text-sm text-muted-foreground">Scroll this panel: the pill appears and follows the section you are reading.</p>
        {chapters.map((chapter) => (
          <section key={chapter.id} className="mt-6 grid gap-3">
            <h3 id={chapter.id} className="scroll-mt-4 text-lg font-semibold outline-none">{chapter.title}</h3>
            {[0, 1, 2].map((n) => <p key={n} className="text-sm leading-6">{chapter.text}</p>)}
          </section>
        ))}
        <div className="h-40" />
      </article>
      <ReadingProgress sections={chapters} scrollRef={scroller} placement="sticky" showAfter={60} />
    </div>
  );
}

API reference #

ReadingProgress

component
PropTypeDefaultDescription
sections *readonly ReadingSection[]—The sections in page order; each id is an element on the page.
scrollRefRefObject<HTMLElement | null>—The element that scrolls; the window when absent.
placement"sticky" | "fixed""fixed""fixed" floats at the bottom of the window; "sticky" sits at the bottom of its scrolling parent (render it last inside that parent).
showAfternumber120Pixels scrolled before the pill appears.
labelstring"Reading progress"
classNamestring—

Accessibility #

It is built on native elements and keeps aria-current, aria-hidden, aria-expanded, aria-controls and aria-label in step with state.

No custom key handling; Tab and the native activation keys do the work.

Motion #

It checks prefers-reduced-motion itself and settles immediately instead of travelling. Every state is still reachable.