WebberUI

Case Study Dossier

A dossier-style case study layout — a sticky metrics rail on the left lights up and counts as the long-form copy scrolls by, while full-bleed pull quotes periodically break the two-column rhythm to let it breathe.

This is a WebberUI Pro component

Free during the launch campaign: sign up or sign in, then hit “Copy install command” in the preview above and it installs straight away — no payment, no credit card. The command below returns 401 while you are signed out.

How to install Pro components →See the plans →

Loading preview…
npx shadcn@latest add "https://webberui.com/r/case-study-dossier.json?t=<install token>"

Playground

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

24
<CaseStudyDossier />

Installation

npx shadcn@latest add "https://webberui.com/r/case-study-dossier.json?t=<install token>"

Or, once registries are configured in components.json, install it as @webberui/case-study-dossier.

Usage

CaseStudyDossier wraps a number of DossierSections, and each section can bind one metric. DossierQuote is the full-bleed pull quote that breaks the two-column rhythm. The metrics rail on the left is generated automatically from each section's metric; scrolling to a section lights up its metric and counts it up from 0.

import {
  CaseStudyDossier,
  DossierSection,
  DossierQuote,
} from "@/components/ui/case-study-dossier";

<CaseStudyDossier eyebrow="Case study" title="Northwind rollout results">
  <DossierSection
    id="roi"
    eyebrow="Financial impact"
    title="Return on investment"
    metric={{ label: "Return on investment", value: 320, suffix: "%" }}
    accentClassName="text-emerald-600 dark:text-emerald-400"
  >
    <p>Over a twelve-month window, the savings the platform delivered added up to 3.2× the initial investment.</p>
  </DossierSection>

  <DossierQuote cite="Ada Lin, COO">
    The data is already in place before breakfast, so the team gets that time back for the decisions that actually matter.
  </DossierQuote>

  <DossierSection
    id="retention"
    title="Net revenue retention"
    metric={{ label: "Net revenue retention", value: 118, suffix: "%" }}
  >
    <p>Faster service response times and sharper renewal reminders pushed retention up to 118%.</p>
  </DossierSection>
</CaseStudyDossier>

If the layout sits inside a nested scroll container (overflow-y-auto), pass that container's ref to container and lower stickyTop as needed:

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

<div ref={scrollerRef} className="h-[420px] overflow-y-auto">
  <CaseStudyDossier container={scrollerRef} stickyTop={8}>
    {/* … */}
  </CaseStudyDossier>
</div>

Props

CaseStudyDossier

PropTypeDefaultDescription
childrenReact.ReactNodePut DossierSection and DossierQuote here
containerRefObject<HTMLElement | null>Ref of the nested scroll container; defaults to the window as scroll container
eyebrowReact.ReactNodeSmall label at the top of the metrics rail
titleReact.ReactNodeTitle of the metrics rail
labelstring"Case metrics"Accessible label for the metrics rail <aside>
stickyTopnumber24Offset of the sticky metrics rail from the top of the scroll container (px)

DossierSection

PropTypeDefaultDescription
idstringSection anchor id; metric clicks and the observer match on it
titlestringSection title
eyebrowReact.ReactNodeCategory label above the title
metricDossierMetricThe metric number bound to this section
accentClassNamestringAccent text color class for the number while its metric is the current section

DossierMetric

FieldTypeDefaultDescription
labelstringMetric description
valuenumberTarget value, counted up to from 0
prefixstring""Prefix for the number, such as "$"
suffixstring""Suffix for the number, such as "%" or "×"
decimalsnumber0Number of decimal places

DossierQuote

PropTypeDefaultDescription
childrenReact.ReactNodeQuote content
citeReact.ReactNodeAttribution for the quote

How it works

  • The metrics rail uses an IntersectionObserver to get each section's visible ratio; the highest one is the "current section", and that metric plus everything before it counts as reached (lit up).
  • Each metric updates its count frame by frame through a MotionValue, which does not trigger a React re-render; the first time you reach a section it counts from 0 up to the target value.
  • In a wide container the full-bleed quote (DossierQuote) reclaims the left rail's space with a negative margin, spanning the whole layout to break the two-column rhythm.
  • The left metrics rail uses @container queries: in a narrow container (< 34rem) it collapses, and each section shows its metric in place instead.

Accessibility

  • The metrics rail is an <aside> with an accessible label; each metric is a focusable button that smoothly scrolls to its section on click, and its aria-label reads out the full "section, metric value".
  • The animated number while counting is hidden from assistive technology (aria-hidden), so the final value is what gets announced.
  • The current section and its matching metric are marked with aria-current.
  • When the user has "reduce motion" enabled at the system level, numbers show their final value directly without counting, the reveal animations on quotes and body copy are disabled, and metrics light up as an instant switch.

On this page