WebberUI

Alphabet Ledger

An alphabetical index scroll — a poster-scale initial letter is the sticky anchor of each section, and at the handover the new letter pushes the previous one out with a thin-to-bold weight baton animation.

Loading preview…
npx shadcn@latest add https://webberui.com/r/alphabet-ledger.json

Playground

Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.

200
800
<AlphabetLedger />

Installation

npx shadcn@latest add https://webberui.com/r/alphabet-ledger.json

Or, once registries are configured in components.json, install it as @webberui/alphabet-ledger.

Usage

import {
  AlphabetLedger,
  AlphabetLedgerSection,
} from "@/components/ui/alphabet-ledger";

<AlphabetLedger>
  <AlphabetLedgerSection letter="A" label="Animation">
    <p>The content scrolls alongside the giant letter……</p>
  </AlphabetLedgerSection>
  <AlphabetLedgerSection letter="B" label="Baton">
    <p>Scroll to here and B pushes A out, picking up the bold weight.</p>
  </AlphabetLedgerSection>
</AlphabetLedger>

If the scrolling happens inside a nested overflow-y-auto container, pass that container's ref to container:

const scrollerRef = React.useRef<HTMLDivElement>(null);

<div ref={scrollerRef} className="h-[320px] overflow-y-auto">
  <AlphabetLedger container={scrollerRef}>{/* ... */}</AlphabetLedger>
</div>

Props

AlphabetLedger

PropTypeDefaultDescription
childrenReactNodePut AlphabetLedgerSections here, one per letter section
showIndexbooleantrueWhether to show the clickable letter index scroll on the right
thinWeightnumber200Font weight of an inactive letter (thin)
boldWeightnumber800Font weight of the active letter (bold), reached at the instant of the handover
letterSizeClassNamestring"text-[5.5rem] leading-none sm:text-[8rem]"Type-size classes for the giant letter
letterClassNamestringOverrides the giant letter's styles (colour, tracking, and so on)
containerRefObject<HTMLElement | null>Nested scroll container ref, used for pin detection and as the scroll target
onActiveChange(index: number, letter: string) => voidCallback when the active section changes
classNamestringAppended to the outer container

AlphabetLedgerSection

PropTypeDefaultDescription
letterstringThe giant initial letter used as the section anchor
childrenReactNodeSection content, which scrolls alongside the letter
labelReactNodeSmall caption under the letter; when it is a string it also serves as the section's accessible name
idstringAnchor id applied to the section
classNamestringAppended to the section container

How it works

  • Each section's giant initial letter is pinned to the top with position: sticky; top: 0, and its movement is bounded by its own section; when a section scrolls out, the next section's letter pins immediately, visually "pushing" the previous one out.
  • At the instant of the handover, the incoming letter's fontWeight climbs from thinWeight to boldWeight (paired with a slight scale) while the previous one falls back to the thin weight, producing the "baton" animation. How smooth that weight transition looks depends on whether the typeface is a variable-weight font (variable font).
  • The active section is decided by an IntersectionObserver: the deepest section that has passed the top pinning line is the active one, and the index scroll on the right and the font weights update in sync.
  • When container is passed, both the pin detection (the IntersectionObserver root) and the index scroll's scroll positioning switch to that container; otherwise the window is the scroll container.

Accessibility

  • The giant letter is a decorative visual anchor and is marked aria-hidden; please provide a real heading in the section content, and when label is a string it becomes the <section>'s aria-label.
  • The index scroll on the right consists of native <button>s that are keyboard-focusable and triggered with Enter, carrying an aria-label and aria-current to mark the current section.
  • When the user has "reduce motion" enabled at the system level, the sticky stacking layout is fully preserved and only the weight baton transition and smooth scrolling are disabled (it jumps into position instead).

On this page